Files
muse/web/lib/api.ts
T
Aryan SinghandClaude Opus 5 205e0a44b8 Add review, discover, email; rebuild the design system
Three features and a typographic pass over everything.

REVIEW. Picking a file and trusting it are different questions, so they now
use different numbers. The existing rank decides which candidate to fetch and
is right to weigh peer upload slots and queue length -- but none of that says
anything about whether the file is the correct recording, and a fast peer must
not be able to raise the confidence of a wrong match. So confidence() scores
the filename alone: title 0.75, artist 0.25, album +0.05, and a variant tag
you did not ask for multiplies by 0.8 AND caps below the auto threshold.

Confidence gates the IMPORT, not the download. The transfer completes either
way and the file waits in downloads, so approving is instant and rejecting is
a delete. Holding the transfer instead would mean the peer is usually gone by
the time anyone looks.

The two cases it exists for: "02 Creep.flac" in an unsorted folder scores 0.75
-- title exactly right, artist simply absent, the commonest honest case there
is -- and must be reviewed rather than binned. And "Creep (Live at
Glastonbury)" scores 0.80 with BOTH artist and title perfect; the cap is what
stops a live recording auto-filing over the studio version. Ask for "Creep
(Acoustic)" and it auto-imports at 0.97, because then the variant is the
request.

DISCOVER. Related artists seeded from what you have actually imported, plus a
recap of top artists, genres and decades. Built on muse's own request history,
never on listening data: Navidrome owns play counts and history, and a second
source of truth for those would disagree with the one you listen through.

EMAIL. Optional, off unless MUSE_SMTP_HOST is set. Sends on import and on
review-needed. smtplib is blocking so it runs off the event loop, and every
failure is logged and swallowed -- a notification is never worth failing an
import over. Google accounts need no configuration, since the identity is an
address. Each user owns their own setting under Settings, deliberately not an
admin route: an address you cannot change yourself is someone else's chore.

DESIGN. A real type scale (display/title/headline/body/callout/caption) whose
tracking tightens as size grows, which is the single biggest tell of typography
nobody set. Specular top edges on glass surfaces -- blur alone reads as a grey
rectangle. A two-part elevation scale, contact shadow plus soft light. Semantic
colours are now per-theme: the amber tuned for the dark background failed
contrast outright on white, which was exactly the text a reviewer most needs to
read. The tab underline became a sliding pill so the eye tracks one object, and
Review only appears when something is actually waiting.

Add tests/test_matching.py: 13 assertions on the confidence bands rather than
the exact scores. 90 tests pass across the three suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 02:10:33 -05:00

336 lines
9.0 KiB
TypeScript

/** Types mirror what app.py's track_row() and endpoints actually return. */
export type Track = {
deezer_id: number;
title: string;
artist: string;
artist_id: number | null;
artist_pic: string | null;
album: string;
album_id: number | null;
cover: string | null;
cover_big: string | null;
length: number;
preview: string | null;
explicit: boolean;
isrc: string | null;
rank: number;
year: string | null;
genre: string | null;
track: number | null;
disc?: number | null;
};
export type AlbumHeader = {
album_id: number;
title: string;
artist: string;
artist_id: number | null;
cover: string | null;
cover_big: string | null;
year: string | null;
genres: string[];
label: string | null;
nb_tracks: number | null;
record_type: string | null;
duration: number | null;
};
export type ArtistHeader = {
artist_id: number;
name: string;
picture: string | null;
nb_fan: number | null;
nb_album: number | null;
};
export type AlbumCard = {
album_id: number;
title: string;
cover: string | null;
cover_big: string | null;
year: string | null;
record_type: string | null;
nb_tracks: number | null;
};
export type Suggestions = {
tracks: {
deezer_id: number;
title: string;
artist: string;
cover: string | null;
preview: string | null;
}[];
albums: {
album_id: number;
title: string;
artist: string;
cover: string | null;
nb_tracks: number | null;
}[];
artists: {
artist_id: number;
name: string;
cover: string | null;
nb_album: number | null;
}[];
};
export type ReviewItem = {
id: string;
artist: string;
title: string;
album: string | null;
year: string | null;
cover: string | null;
/** 0..1. Below the auto threshold, which is why this is here at all. */
confidence: number | null;
/** Human-readable reasons behind the score, semicolon-joined. */
why: string | null;
/** Basename of the file sitting in downloads, awaiting a decision. */
file: string;
path: string | null;
size: number | null;
requested_by: string | null;
created_at: string;
};
export type Discover = {
seeds: string[];
artists: {
id: number;
name: string;
picture: string | null;
fans: number | null;
}[];
};
export type Recap = {
days: number;
tracks: number;
bytes: number;
artists: { name: string; n: number }[];
genres: { name: string; n: number }[];
decades: { decade: string; n: number }[];
busiest_day: { date: string; n: number } | null;
};
export type Job = {
id: string;
state: "queued" | "searching" | "downloading" | "review" | "imported" | "failed";
detail: string | null;
artist: string;
title: string;
album: string | null;
year: string | null;
cover: string | null;
genre: string | null;
duration: number | null;
requested_by: string | null;
created_at: string;
};
export type Usage = { requests: number; storage: number };
export type Me = {
user: string | null;
auth: string;
role?: string | null;
display?: string | null;
avatar?: string | null;
provider?: string;
quota?: number;
storage_quota?: number;
usage?: Usage;
providers?: string[];
email?: string | null;
notify?: boolean;
/** SMTP is configured server-side; without it the settings are inert. */
mail_configured?: boolean;
};
export type AdminUser = {
username: string;
provider: string;
role: string;
display: string | null;
avatar: string | null;
quota: number;
storage_quota: number;
disabled: boolean;
created_at: string;
last_seen: string | null;
invited_by: string | null;
usage: Usage;
email: string | null;
notify: boolean;
/** In MUSE_ALLOWED_USERS — cannot actually be locked out by disabling. */
bootstrap: boolean;
};
export type DiskStat = { total: number; used: number; free: number; pct: number | null };
export type SystemStats = {
system: {
cpu_pct: number | null;
cpus: number | null;
mem_total: number | null;
mem_used: number | null;
mem_pct: number | null;
load: number[] | null;
disks: Record<string, DiskStat | null>;
};
workers: number;
pending: number;
jobs: Record<string, number>;
library: { tracks: number; bytes: number };
users: number;
sessions: number;
slskd: {
ok: boolean;
connected?: boolean;
logged_in?: boolean;
version?: string;
shared_files?: number | null;
error?: string;
};
};
export type Activity = {
days: number;
series: { date: string; n: number; imported: number; failed: number }[];
per_user: { u: string; n: number; bytes: number }[];
top_artists: { artist: string; n: number }[];
};
/**
* Every response goes through here so a 401 is a typed failure rather than a
* JSON parse error thirty lines away. Sessions expire, and when one does the
* UI should say "signed out", not "Unexpected token < in JSON".
*/
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
async function get<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(path, { credentials: "same-origin", ...init });
if (r.status === 401) throw new ApiError(401, "signed out");
if (!r.ok) {
// FastAPI puts the useful part in `detail` — quota messages, "only active
// admin", and so on. Surfacing "HTTP 429" instead would throw away the
// only text that tells the user what to do about it.
let msg = `HTTP ${r.status}`;
try {
const body = await r.json();
if (typeof body?.detail === "string") msg = body.detail;
else if (typeof body?.error === "string") msg = body.error;
} catch {
/* not JSON */
}
throw new ApiError(r.status, msg);
}
return (await r.json()) as T;
}
function send<T>(method: string, path: string, body?: unknown): Promise<T> {
return get<T>(path, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
const post = <T,>(path: string, body?: unknown) => send<T>("POST", path, body);
export const api = {
me: () => get<Me>("/api/me"),
search: (q: string) =>
get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`),
suggest: (q: string, signal?: AbortSignal) =>
get<Suggestions>(`/api/suggest?q=${encodeURIComponent(q)}`, { signal }),
album: (id: number | string) =>
get<{ album: AlbumHeader; tracks: Track[] }>(
`/api/album/${encodeURIComponent(String(id))}`,
),
artist: (id: number | string) =>
get<{ artist: ArtistHeader; tracks: Track[]; albums: AlbumCard[] }>(
`/api/artist/${encodeURIComponent(String(id))}`,
),
request: (t: Track) => post<{ id: string }>("/api/request", t),
requestBatch: (tracks: Track[]) =>
post<{ queued: number; ids: string[]; pending: number }>(
"/api/request/batch",
{ tracks },
),
jobs: () =>
get<{ jobs: Job[]; counts: Record<string, number>; pending: number }>(
"/api/jobs",
),
retry: (id: string) => post<{ ok: boolean }>(`/api/retry/${id}`),
settings: (s: { email?: string | null; notify?: boolean }) =>
send<{ ok: boolean; mail_configured: boolean }>("PATCH", "/api/me", s),
review: () =>
get<{
items: ReviewItem[];
thresholds: { auto: number; review: number };
}>("/api/review"),
decide: (id: string, action: "approve" | "reject") =>
post<{ ok: boolean; state: string }>(`/api/review/${id}`, { action }),
discover: () => get<Discover>("/api/discover"),
recap: () => get<Recap>("/api/recap"),
importList: (text: string) =>
post<{
queued: { id: string; line: string; matched: string; cover: string | null }[];
skipped: { line: string; why: string }[];
pending: number;
}>("/api/import", { text }),
admin: {
users: () => get<{ users: AdminUser[]; roles: string[] }>("/api/admin/users"),
addUser: (u: {
username: string;
provider: string;
role: string;
quota: number;
storage_quota_gb: number;
}) => post<AdminUser>("/api/admin/users", u),
patchUser: (
username: string,
patch: Partial<{
role: string;
quota: number;
storage_quota_gb: number;
disabled: boolean;
}>,
) => send<AdminUser>("PATCH", `/api/admin/users/${encodeURIComponent(username)}`, patch),
deleteUser: (username: string) =>
send<{ ok: boolean }>("DELETE", `/api/admin/users/${encodeURIComponent(username)}`),
system: () => get<SystemStats>("/api/admin/system"),
activity: (days = 30) => get<Activity>(`/api/admin/activity?days=${days}`),
},
};
/** Bytes → human. Binary units, because that's what df and the OS report. */
export function bytes(n: number | null | undefined): string {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
let v = n;
while (v >= 1024 && i < u.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
}
export const mmss = (ms: number | null | undefined) => {
const t = Math.round((ms || 0) / 1000);
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
};