/** 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; }; workers: number; pending: number; jobs: Record; 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(path: string, init?: RequestInit): Promise { 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(method: string, path: string, body?: unknown): Promise { return get(path, { method, headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); } const post = (path: string, body?: unknown) => send("POST", path, body); export const api = { me: () => get("/api/me"), search: (q: string) => get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`), suggest: (q: string, signal?: AbortSignal) => get(`/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; 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("/api/discover"), recap: () => get("/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"), resend: (username: string) => post<{ ok: boolean; sent_to: string }>( `/api/admin/users/${encodeURIComponent(username)}/resend`, ), addUser: (u: { username: string; provider: string; role: string; quota: number; storage_quota_gb: number; email?: string; }) => post( "/api/admin/users", u, ), patchUser: ( username: string, patch: Partial<{ role: string; quota: number; storage_quota_gb: number; disabled: boolean; }>, ) => send("PATCH", `/api/admin/users/${encodeURIComponent(username)}`, patch), deleteUser: (username: string) => send<{ ok: boolean }>("DELETE", `/api/admin/users/${encodeURIComponent(username)}`), system: () => get("/api/admin/system"), activity: (days = 30) => get(`/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")}`; };