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