Rebuild the UI on Next.js, and give search real metadata

The old page was hand-written HTML/CSS/JS. That was fine for a search box and
a list, but the roadmap here is multi-user, an admin dashboard with usage
graphs, and an import-review queue — all of which get painful in vanilla DOM
code. Moves to Next.js 16 / React 19 / Tailwind 4 / Framer Motion.

Built as a STATIC EXPORT served by FastAPI. Running `next start` alongside
uvicorn would add a Node runtime, a second process to supervise, and a second
thing that can be down while the other is up. There is no use for SSR — every
view is behind auth and renders from live JSON — so the deployment stays one
container, one process, one healthcheck. Image is 60MB; Node only exists in
the build stage.

Search now carries what a music UI needs:

- year and genre, grafted on from /album/{id} concurrently, deduplicated by
  album and cached forever since a release date is immutable
- 30-second previews, so you can hear a track before committing a Soulseek
  search to it. Not a library player — that is Navidrome's job.
- autocomplete over songs/albums/artists, debounced and aborted per keystroke
  so a slow response to "rad" cannot land after "radiohead"
- album and artist pages, with `Get all` to queue a release in one request
- the accent colour is sampled from the artwork, so the palette follows the
  music. Deezer's CDN sends ACAO:*, which is what makes canvas sampling work
  with no image proxy.

/api/album/{id} deliberately calls Deezer twice: the track list embedded in
the album response has no track_position, and using the list index instead
misnumbers every multi-disc release — verified against the White Album, where
disc 2 track 1 would have been filed as 18.

Schema is multi-user ready ahead of the feature: users table with roles and
quotas, requested_by on jobs, and the first login becomes admin. Auth is still
the single-user GitHub allowlist.

CSP survives all of this intact. Next emits inline hydration scripts, which
script-src 'self' blocks; rather than per-request nonces, their SHA-256 is
computed at startup from the built page — a static export never varies between
requests, so hashes need no maintenance and no HTML rewriting. The one
concession is style-src-attr 'unsafe-inline', because Framer Motion writes its
pre-hydration state into style="" and without it every element flashes in.
That permits style attributes only. img-src/media-src now name Deezer's CDN
explicitly instead of allowing https: wholesale, closing an exfiltration
channel that survived connect-src 'self'. /_next/* sits behind the session
too — there is no local login page to style, so the bundle need not be public.

Verified: unauthenticated /→302 and every /api/*→401 including the four new
endpoints, forged state→400, forged cookie→401, misconfigured auth refuses to
start, and the production image hydrates with zero CSP violations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-08-04 14:26:31 -05:00
1 parent bc46fd80b3
commit 3b4b68daaf
34 files changed
+10655 -281

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
/** 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<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) throw new ApiError(r.status, `HTTP ${r.status}`);
return (await r.json()) as T;
}
function post<T>(path: string, body?: unknown): Promise<T> {
return get<T>(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(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}`),
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")}`;
};