# muse Request a single song, get it in your music library, correctly tagged. ``` you type "radiohead creep" ↓ Deezer track search you click Get ↓ exact album, year and track number looked up ↓ slskd searches Soulseek, candidates scored ↓ best one downloaded ↓ tags written, hardlinked into the library /music/Radiohead/Pablo Honey (1993)/02 - Creep.flac ``` ## Why this exists Lidarr thinks in MusicBrainz *releases*, so the smallest thing it can want is an album — ask for one song and you end up monitoring a discography. Soulseek trades individual files but has no concept of a request. Nothing bridged the two, so getting one track meant either pulling whole albums through Lidarr or hand-filing downloads out of slskd's UI. muse is the missing piece: search → click → it appears in your library. ## The design point **You pick the exact track before anything downloads.** That means muse holds the artist, album, year and track number as ground truth *before the file exists*, and tags deterministically. Taggers like beets have to infer all of that afterwards from whatever a stranger happened to name the file. muse never guesses. ## Why Deezer for search, not MusicBrainz MusicBrainz was tried first and measured worse: | query | MusicBrainz | Deezer | |---|---|---| | `radiohead creep` | 8 covers/mashups above the real recording | `Creep / Radiohead / Pablo Honey` first | | `bohemian rhapsody` | Queen absent from 90 results | covers, but Queen appears once artist is named | MusicBrainz is an excellent database and a poor search engine — its index matches a word in a cover's *title* as readily as one in the artist field. Client-side re-ranking can only reorder what came back, so it cannot rescue a result set that lacks the right row. 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: ``` Radiohead - Karma Police Juice WRLD – Lucid Dreams (en dash) 3. Gorillaz - Feel Good Inc (numbered list) Black Hole Sun by Soundgarden Come As You Are (bare title — Deezer copes) https://deezer.com/album/12345 (expanded into its tracks) https://deezer.com/playlist/6789 (same) ``` In bulk mode there's no picker — the top-ranked Deezer hit is taken automatically. Every job shows what it *resolved to*, so a bad auto-pick is visible in the list rather than quietly landing the wrong song. Lines that match nothing are reported back as skipped instead of failing the batch. Failed jobs get a **Retry** button. Most Soulseek failures are the peer going offline, not a bad request, so retrying later usually just works. ### Everything goes through a bounded queue `MUSE_WORKERS` (default 3) caps concurrent Soulseek searches. This matters: each search is 10-40s of polling plus real load on slskd, so one task per request — which an earlier version did — means a 50-track paste launches 50 concurrent searches and drowns the client. Requests return instantly and the queue drains at a civil rate. The queue is in memory, so anything still `queued` or `searching` is put back on it at startup. A restart mid-import resumes instead of stranding jobs. ## How results are ranked Two separate rankings, and they fail in different ways. **Picking the track (Deezer).** Title relevance dominates — whatever query words the artist name doesn't explain must appear in the title. That term exists because without it a *completely different song by the right artist* competes on equal footing: `Oasis - Live Forever` once resolved to `Cloudburst`, because a small variant penalty was enough to tip between two unrelated tracks. Artist match, popularity, and a penalty for derivative versions (cover, karaoke, live, demo, remix…) come after. Derivative terms match on **word boundaries**, not substrings — a bare `live` is needed to catch `Come As You Are (Live)`, but substring matching would also penalise `Deliver Me` and `Olive`. And a term only counts against a result when you didn't type it, so `oasis live forever` and `nirvana unplugged` still work. **Picking the file (Soulseek).** Format preference first, then whether the peer can actually serve you (free upload slot, short queue), then filename plausibility, then bitrate. A marginally better file behind 200 queued users loses to a good-enough one available now. ## Authentication Single-user GitHub OAuth, built so this can face the internet. **It fails closed.** Auth is on unless `MUSE_AUTH=off` is set explicitly, and if it is on with incomplete config the app *refuses to start*. The failure mode worth engineering against isn't a broken login page — it's a misconfigured deploy quietly serving an unauthenticated download client to the internet. ### Setting it up 1. → **New OAuth App** - Homepage URL: `https://muse.yourdomain.com` - Authorization callback URL: `https://muse.yourdomain.com/auth/callback` 2. Generate a client secret. 3. Set `MUSE_GITHUB_CLIENT_ID`, `MUSE_GITHUB_CLIENT_SECRET`, `MUSE_ALLOWED_USERS` (your GitHub login), and `MUSE_BASE_URL`. | variable | default | meaning | |---|---|---| | `MUSE_AUTH` | `github` | `github` or `off` | | `MUSE_GITHUB_CLIENT_ID` | — | OAuth app client id | | `MUSE_GITHUB_CLIENT_SECRET` | — | OAuth app secret | | `MUSE_ALLOWED_USERS` | — | comma-separated GitHub logins; **nobody else gets in** | | `MUSE_BASE_URL` | — | external URL, e.g. `https://muse.yourdomain.com` | | `MUSE_COOKIE_SECURE` | `true` | set `false` only for plain-http LAN testing | | `MUSE_SESSION_DAYS` | `7` | session lifetime | ### What it does - **Server-side sessions.** The cookie carries a 256-bit random token and nothing else; the database stores only its SHA-256. No signed payload to get wrong, a stolen database yields no usable cookies, and logout genuinely revokes. Cookie is `HttpOnly`, `Secure`, `SameSite=Lax`. - **Allowlist re-checked every request**, so removing someone locks them out 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, 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 `