/** 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 Job = { id: string; state: "queued" | "searching" | "downloading" | "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 Me = { user: string | null; auth: string; role?: string; display?: string | null; avatar?: string | null; quota?: 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) throw new ApiError(r.status, `HTTP ${r.status}`); return (await r.json()) as T; } function post(path: string, body?: unknown): Promise { return get(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(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}`), importList: (text: string) => post<{ queued: { id: string; line: string; matched: string; cover: string | null }[]; skipped: { line: string; why: string }[]; pending: number; }>("/api/import", { text }), }; 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")}`; };