Commit Graph
3 Commits
  • Publish arm64 alongside amd64
    muse is now the front door of a public project, and it was the only
    image in that stack without an arm64 build. Raspberry Pis, arm64 NASes
    and Ampere VPSes are a large share of self-hosters, so amd64-only made
    the whole stack un-runnable for them — gluetun, slskd, soularr, lidarr,
    navidrome and the socket proxy all already publish both.
    
    Three parts, because changing `platforms:` alone would have failed:
    
    - setup-qemu-action registers the binfmt handlers buildx needs to
      produce arm64 on an amd64 runner. Without it the build errors with
      "exec format error"; it does not quietly fall back to amd64.
    - The web stage is pinned to --platform=$BUILDPLATFORM so the Next.js
      build runs natively once instead of being emulated per platform. It
      is the expensive step, and its output is static HTML/JS/CSS with no
      native code, so the bytes are identical either way. This is what
      makes the old comment's "3-5x slower for nothing" no longer true.
    - platforms: linux/amd64,linux/arm64.
    
    Verified locally before pushing: both platforms build in ~52s, and the
    loaded arm64 image reports aarch64 with uvloop, watchfiles, httptools
    and pydantic_core all importing, `import app` succeeding, and the
    static UI present.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • 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>
  • muse: request a single song, get it tagged in your library
    Lidarr's smallest unit is a MusicBrainz release, so asking for one track
    means monitoring a whole discography. Soulseek trades individual files but
    has no concept of a request. muse bridges the two: search, click, and the
    song lands in the library correctly tagged.
    
    The design point is that the user picks the exact track before anything
    downloads, so muse holds artist, album, year and track number as ground
    truth and tags deterministically rather than inferring them afterwards from
    whatever a stranger named the file.
    
    Search is Deezer rather than MusicBrainz, decided on measurement:
    MusicBrainz put eight covers above the real recording for "radiohead creep"
    and never surfaced Queen for "bohemian rhapsody" across 90 results, because
    its index matches a word in a cover's title as readily as in the artist
    field. Re-ranking cannot rescue a result set missing the right row.
    
    Imports are os.link(), so downloads keep seeding from the Soulseek share
    while the library gets a second name for the same bytes.