Measured against a live instance: 47 failed jobs against 33 imported. Three independent causes, all of which ended the request. The Soulseek server filters some artist names. "Michael Jackson Billie Jean" and bare "Michael Jackson" both return zero responses; "Billie Jean" returns 1083 files. The Weeknd, Kanye West and Frank Ocean behave the same way. Since pick_and_enqueue sent exactly one query of `artist title`, every request for a filtered artist failed reporting "searched 0 files" -- 28 of 29 occurrences. search_queries() now walks a ladder, loosest last, ending at the bare title, and escalates only when a search underdelivers so the common case still costs one search. Dropping the artist from the query does not mean ignoring it: match_ratio() looks for it in the folder path, where an organised share puts it. A rejected transfer ended the request. pick_and_enqueue kept a running maximum and discarded every runner-up, so "Transfer rejected: File not shared." -- a per-file condition where candidate two almost always works -- was terminal, as were timeouts and slskd's 500 on an unreachable peer. The ranked list is now persisted on the job row, ordered to put a different peer next, and try_next_candidate() advances through it. A job fails only once the list is exhausted, and then reports every peer it tried and what each one said. norm() destroyed every non-ASCII character, so "Beyonce" never matched Beyonce, nor Bjork Bjork, nor Dont Don't. It now folds accents and drops apostrophes while leaving & as a separator. Also: availability outranks format in score(), because a FLAC behind 5000 queued users used to beat an immediately available MP3; transfers that stall or that slskd forgets are abandoned instead of sitting in `downloading` forever; searches are deleted only once finished, since deleting one mid-flight makes slskd throw an optimistic-concurrency error and lose the record; a failed search POST is retried rather than failing the job on one blip. The UI moves to a sidebar shell -- rail at tablet, bottom bar on phones -- with neutral chrome and the sampled artwork colour confined to hero areas rather than repainting the whole interface. Adds URL routing, a primitive set under components/ui, and a request sheet showing the attempt ladder. Fixes hover:bg-glass-hi, which was never a valid class, so track rows and the search field had no hover or focus state at all; a reduced-motion path that could strand elements at opacity 0; the login page's hardcoded palette; and the missing 404 and error boundaries. tests/test_acquisition.py covers this path against a faked slskd. It had none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
364 lines
9.8 KiB
TypeScript
364 lines
9.8 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 const api = {
|
|
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")}`;
|
|
};
|