# 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. ## 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** — `default-src 'self'`, no `unsafe-inline`. This is why the stylesheet and script are separate files instead of inline in the page: an injected `