Files
muse/Dockerfile
T
Aryan SinghandClaude Opus 5 3b4b68daaf 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>
2026-08-04 14:26:31 -05:00

41 lines
1.5 KiB
Docker

# ── 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
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
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.
EXPOSE 5040
HEALTHCHECK --interval=60s --timeout=10s --start-period=15s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5040/healthz',timeout=8).status==200 else 1)"
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5040", "--no-access-log"]