From 3b4b68daaf91e06ae78141014d9cf3934c03f10b Mon Sep 17 00:00:00 2001 From: Aryan Singh Date: Tue, 4 Aug 2026 14:26:31 -0500 Subject: [PATCH] Rebuild the UI on Next.js, and give search real metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 15 + .github/workflows/release.yml | 17 +- Dockerfile | 24 +- README.md | 89 +- app.py | 430 +- static/app.css | 68 - static/app.js | 121 - static/index.html | 43 - web/.gitignore | 41 + web/app/favicon.ico | Bin 0 -> 25931 bytes web/app/globals.css | 174 + web/app/layout.tsx | 28 + web/app/page.tsx | 335 ++ web/components/album-view.tsx | 116 + web/components/artist-view.tsx | 118 + web/components/aurora.tsx | 42 + web/components/bulk-import.tsx | 126 + web/components/hero.tsx | 88 + web/components/player.tsx | 148 + web/components/requests.tsx | 148 + web/components/search-bar.tsx | 233 + web/components/skeletons.tsx | 20 + web/components/toast.tsx | 48 + web/components/track-row.tsx | 170 + web/components/ui/button.tsx | 51 + web/eslint.config.mjs | 18 + web/lib/api.ts | 169 + web/lib/colour.ts | 85 + web/lib/utils.ts | 7 + web/next.config.ts | 42 + web/package-lock.json | 7843 ++++++++++++++++++++++++++++++++ web/package.json | 38 + web/postcss.config.mjs | 7 + web/tsconfig.json | 34 + 34 files changed, 10655 insertions(+), 281 deletions(-) create mode 100644 .dockerignore delete mode 100644 static/app.css delete mode 100644 static/app.js delete mode 100644 static/index.html create mode 100644 web/.gitignore create mode 100644 web/app/favicon.ico create mode 100644 web/app/globals.css create mode 100644 web/app/layout.tsx create mode 100644 web/app/page.tsx create mode 100644 web/components/album-view.tsx create mode 100644 web/components/artist-view.tsx create mode 100644 web/components/aurora.tsx create mode 100644 web/components/bulk-import.tsx create mode 100644 web/components/hero.tsx create mode 100644 web/components/player.tsx create mode 100644 web/components/requests.tsx create mode 100644 web/components/search-bar.tsx create mode 100644 web/components/skeletons.tsx create mode 100644 web/components/toast.tsx create mode 100644 web/components/track-row.tsx create mode 100644 web/components/ui/button.tsx create mode 100644 web/eslint.config.mjs create mode 100644 web/lib/api.ts create mode 100644 web/lib/colour.ts create mode 100644 web/lib/utils.ts create mode 100644 web/next.config.ts create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/postcss.config.mjs create mode 100644 web/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8075efb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# node_modules is ~400MB and is reinstalled from the lockfile inside the +# build stage anyway — sending it as build context would dwarf the image. +web/node_modules +web/.next +web/out + +__pycache__ +*.pyc +*.db +config/ +.venv/ +.git +.github +.DS_Store +*.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a2db6c..25ce75d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,11 +28,26 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Sanity-check the app before publishing + - name: Sanity-check the API before publishing run: | python3 -m pip install --quiet -r requirements.txt python3 -c "import app; print('app imports OK')" + # Typechecks and compiles the UI. The Docker build runs this too, but + # doing it up front fails in ~40s instead of after the whole image is + # assembled, and surfaces TS errors as their own step in the log. + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Build the UI + working-directory: web + run: | + npm ci + npm run build + - uses: docker/setup-buildx-action@v3 - name: Log in to GHCR diff --git a/Dockerfile b/Dockerfile index 855c49d..c12b6ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,24 @@ -# Slim, no build toolchain needed — every dependency ships wheels for +# ── stage 1: build the UI ─────────────────────────────────────────────── +# +# Next.js is a BUILD-time dependency only. `output: "export"` emits plain +# HTML/JS/CSS, so Node never runs in the final image and the runtime stays a +# single Python process — same container, same healthcheck, same everything +# the deploy already expects. +FROM node:22-slim AS web + +WORKDIR /web + +# Copied separately so editing a component doesn't re-run npm ci. +COPY web/package.json web/package-lock.json ./ +RUN npm ci + +COPY web/ ./ +RUN npm run build + + +# ── stage 2: runtime ──────────────────────────────────────────────────── +# +# Slim, no build toolchain needed — every Python dependency ships wheels for # linux/amd64 on py3.12. FROM python:3.12-slim @@ -8,7 +28,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . -COPY static ./static +COPY --from=web /web/out ./web/out # Runs as 1000:1000 via the compose `user:` key, matching every other # container that touches /data. Nothing here needs root. diff --git a/README.md b/README.md index fe0f0db..d1d07e1 100644 --- a/README.md +++ b/README.md @@ -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 `", re.S) + + +def script_hashes() -> list[str]: + out: set[str] = set() + for page in ("index.html", "404.html"): + f = WEB / page + if not f.exists(): + continue + for body in _INLINE_SCRIPT.findall(f.read_bytes()): + digest = base64.b64encode(hashlib.sha256(body).digest()).decode() + out.add(f"'sha256-{digest}'") + return sorted(out) + + +SCRIPT_HASHES = " ".join(script_hashes()) + +# Framer Motion writes its pre-hydration `initial` state into style="" on the +# server-rendered markup, so those attributes have to be allowed or every +# animated element flashes in at full opacity before hydration corrects it. +# +# style-src-attr is the narrow lever for that: it permits style ATTRIBUTES +# while style-src 'self' still blocks injected