Files
Aryan SinghandClaude Opus 5 eeb0e096a7
release / build (push) Canceled after 0s
Import a shared playlist link and build it in Navidrome
Paste a Spotify, Apple Music or Deezer playlist URL; muse resolves it, matches
every track through the same Deezer path a pasted line takes, and writes an
.m3u the library scanner picks up.

Written per import rather than at the end, so the playlist appears in Navidrome
with the first track that lands and grows as the rest arrive -- twenty minutes
of nothing, then everything, is the worse shape. A track rejected in review
drops out on the next rewrite, and one that never resolves is reported instead
of silently vanishing.

Resolve is split from queue on purpose: a shared link is opaque, you cannot
tell twelve tracks from four hundred by looking at it, and four hundred is a
quota decision.

Two of the three sources are scrapes and the code says so plainly. Deezer has a
real API. Spotify and Apple Music have no free way to read a public playlist --
Apple's MusicKit needs a paid developer account -- so those read the public page
and parse the JSON it already embeds for its own player. No key, no OAuth, no
account, and no guarantee: they break whenever either company reshuffles their
frontend. So the resolvers fail loudly and name themselves, because an empty
playlist and a changed page must not produce the same message.

The .m3u uses MUSE_PLAYLIST_PREFIX (/music), not muse's own library path. Both
containers mount the same directory at different points and a playlist written
with muse's view resolves to nothing on Navidrome's side.

Also reorders the hooks in search-bar so the URL-seeding effect is declared
after the state it sets, rather than closing over a binding declared below it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:41:57 -05:00

397 lines
11 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;
/** The peer currently serving this transfer, if one is. */
username: string | null;
remote_file: string | null;
confidence: number | null;
match_why: string | null;
/** Peers already attempted for this request, and why each one fell through. */
tried: { username: string; filename: string; reason: string }[];
attempts: number;
remaining: number;
};
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 type Health = {
ok: boolean;
slskd?: number | string;
logged_in?: boolean;
shared_files?: number | null;
library_writable?: boolean;
downloads_readable?: boolean;
};
export type PlaylistPreview = {
source: "deezer" | "spotify" | "apple";
name: string;
count: number;
tracks: { artist: string; title: string }[];
};
export type PlaylistRow = {
id: string;
name: string;
source: string;
source_url: string | null;
tracks_total: number;
tracks_written: number;
m3u_path: string | null;
created_at: string;
done: number;
pending: number;
failed: number;
review: number;
};
export const api = {
playlistPreview: (url: string) =>
post<PlaylistPreview>("/api/playlist", { url, preview: true }),
playlistImport: (url: string, name?: string) =>
post<{
playlist_id: string;
name: string;
source: string;
queued: { id: string; line: string; matched: string; cover: string | null }[];
skipped: { line: string; why: string }[];
}>("/api/playlist", { url, name }),
playlists: () => get<{ playlists: PlaylistRow[] }>("/api/playlists"),
me: () => get<Me>("/api/me"),
health: () => get<Health>("/api/health"),
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"),
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<AdminUser & { invite_sent: boolean; invite_why: string | null }>(
"/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")}`;
};