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:
1 parent
bc46fd80b3
commit
3b4b68daaf
34 files changed
+10655
-281
No files matched your search
@@ -50,6 +50,62 @@ Deezer's API needs no key and no OAuth. Its search payload omits track number
|
||||
and release date, so those come from `/track/{id}` at request time — one extra
|
||||
call per request, not per search result.
|
||||
|
||||
## The interface
|
||||
|
||||
Next.js 16 + React 19 + Tailwind 4 + Framer Motion, built as a **static
|
||||
export** and served by the FastAPI process.
|
||||
|
||||
That last part is the design decision worth stating: `next start` alongside
|
||||
uvicorn would mean a Node runtime in the image, a second process to supervise,
|
||||
and a second thing that can be down while the other is up. muse has no use for
|
||||
SSR — every view sits behind auth and renders from live JSON — so `next build`
|
||||
emits plain files, Python serves them, and the deployment stays one container,
|
||||
one process, one healthcheck.
|
||||
|
||||
What the UI does with the extra metadata:
|
||||
|
||||
- **Search returns songs with artwork, album, year, genre, duration** and an
|
||||
explicit flag. Year and genre aren't in Deezer's track search, so they're
|
||||
grafted on from `/album/{id}` — concurrently, deduplicated by album, and
|
||||
cached forever, because a release date is immutable.
|
||||
- **Autocomplete** across songs, albums and artists as you type. Debounced, and
|
||||
aborted on each keystroke so a slow response to `rad` can't land after
|
||||
`radiohead` and repopulate a stale list.
|
||||
- **30-second previews.** Deezer's payload includes a preview clip, so you can
|
||||
hear a track *before* committing a Soulseek search to it — the cheapest
|
||||
possible guard against importing the wrong take. It is not a library player;
|
||||
that's Navidrome's job, and its native clients do it better.
|
||||
- **Album and artist pages.** Click through to a full track list with real
|
||||
track numbers, or an artist's top tracks and discography. `Get all` queues a
|
||||
whole album in one request.
|
||||
- **The accent colour is sampled from the artwork.** The dominant colour of
|
||||
whatever is on screen drives the entire palette. Deezer's CDN sends
|
||||
`access-control-allow-origin: *`, which is what makes client-side canvas
|
||||
sampling possible with no image proxy and no server CPU.
|
||||
|
||||
### Album track numbers
|
||||
|
||||
`/api/album/{id}` calls Deezer twice: once for the header, once for
|
||||
`/album/{id}/tracks`. The track list embedded in the first response has no
|
||||
`track_position` — taking the list index instead would misnumber every
|
||||
multi-disc release, filing disc 2 track 1 as `18 - Birthday.flac`. The
|
||||
dedicated endpoint is the only source that carries `track_position` and
|
||||
`disk_number`, and those numbers go straight into the filename and the tags.
|
||||
|
||||
### Developing
|
||||
|
||||
```bash
|
||||
# terminal 1 — the API
|
||||
uvicorn app:app --port 5041
|
||||
|
||||
# terminal 2 — the UI, proxying /api and /auth to the above
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
`next dev` serves on :3000 and rewrites `/api/*` and `/auth/*` to the FastAPI
|
||||
process, so the browser sees one origin and cookies behave exactly as they do
|
||||
in production. `npm run build` writes `web/out/`, which is what ships.
|
||||
|
||||
## Bulk import
|
||||
|
||||
Open **Import a list**, paste, hit **Queue all**. These all parse:
|
||||
@@ -143,10 +199,27 @@ deploy quietly serving an unauthenticated download client to the internet.
|
||||
immediately rather than whenever their session expires.
|
||||
- **OAuth `state`** is generated per login and compared with
|
||||
`secrets.compare_digest`, so a callback you didn't initiate is rejected.
|
||||
- **Strict CSP** — `default-src 'self'`, no `unsafe-inline`. This is why the
|
||||
stylesheet and script are separate files instead of inline in the page: an
|
||||
injected `<script>` cannot execute. Plus `frame-ancestors 'none'`,
|
||||
`base-uri 'none'`, `nosniff`, `X-Frame-Options: DENY`, `no-referrer`, HSTS.
|
||||
- **Strict CSP, with hashed scripts.** `script-src 'self'` plus a SHA-256 for
|
||||
each inline script Next emits. Those hashes are computed *at startup* by
|
||||
reading the built page, so a rebuild updates them with no maintenance and no
|
||||
per-request HTML rewriting — the usual nonce dance is unnecessary precisely
|
||||
because a static export never varies between requests.
|
||||
`style-src-attr 'unsafe-inline'` is the one concession: Framer Motion writes
|
||||
its pre-hydration `initial` state into `style=""`, and without it every
|
||||
animated element flashes in at full opacity. That directive permits style
|
||||
*attributes* only — injected `<style>` blocks and remote stylesheets are
|
||||
still blocked, and CSS-based exfiltration goes through `url()`, which
|
||||
`img-src` already pins to two hosts.
|
||||
- **`img-src`/`media-src` name Deezer's CDN explicitly** rather than allowing
|
||||
`https:` wholesale. Under a blanket `https:`, injected markup can beacon to
|
||||
any host by requesting an image from it — a working exfiltration channel even
|
||||
with `connect-src` locked to `'self'`.
|
||||
- Plus `frame-ancestors 'none'`, `base-uri 'none'`, `object-src 'none'`,
|
||||
`nosniff`, `X-Frame-Options: DENY`, `no-referrer`, HSTS.
|
||||
- **The JS bundle is behind the session too.** There is no local login page to
|
||||
style — an unauthenticated visitor is redirected straight to GitHub and never
|
||||
receives the HTML — so `/_next/*` requires a cookie like everything else, and
|
||||
doesn't hand out a map of the app to whoever finds the URL.
|
||||
- **No API docs** — `/docs`, `/redoc` and `/openapi.json` are disabled, so the
|
||||
login page doesn't hand out a map of every endpoint.
|
||||
- **Denied logins are logged** with the GitHub username — on an
|
||||
@@ -250,13 +323,19 @@ package page → Package settings → Change visibility.
|
||||
| endpoint | purpose |
|
||||
|---|---|
|
||||
| `GET /` | web UI |
|
||||
| `GET /api/search?q=` | Deezer track search |
|
||||
| `GET /api/search?q=` | track search, enriched with year and genre |
|
||||
| `GET /api/suggest?q=` | autocomplete — songs, albums, artists |
|
||||
| `GET /api/album/{id}` | album header plus its full track list |
|
||||
| `GET /api/artist/{id}` | artist, top tracks, discography |
|
||||
| `POST /api/request` | queue a track |
|
||||
| `POST /api/request/batch` | queue many known tracks (a whole album) |
|
||||
| `POST /api/import` | queue many from pasted text |
|
||||
| `POST /api/retry/{id}` | requeue a failed job |
|
||||
| `GET /api/jobs` | request history and state |
|
||||
| `GET /api/health` | slskd reachability, mount writability |
|
||||
|
||||
Everything except `/healthz` requires a session.
|
||||
|
||||
## Releases
|
||||
|
||||
Every push to `main` publishes `:latest` and `:sha-<short>`. Tagging `vX.Y.Z`
|
||||
|
||||
Reference in new issue
Block a user