muse drives a download client and writes to the media library, so exposing it publicly needs more than a login form. It fails closed. Auth is on unless MUSE_AUTH=off is set explicitly, and when it is on with incomplete config the app refuses to start rather than serving open. The mode worth engineering against is not a broken login page, it is a misconfigured deploy quietly publishing an unauthenticated download client. Sessions are server-side: the cookie carries a 256-bit random token and nothing else, and only its SHA-256 is stored. There is no signed payload to get subtly wrong, a stolen database yields no usable cookies, and logout revokes for real. The allowlist is re-checked on every request so removing a user takes effect immediately rather than at session expiry. OAuth state is compared with secrets.compare_digest, so a callback the user did not initiate is rejected. MUSE_BASE_URL comes from config rather than X-Forwarded-* because those headers are attacker-controllable and this one is a redirect target. CSP is default-src 'self' with no unsafe-inline, which is why the stylesheet and script moved out of index.html into static files -- an injected <script> now cannot execute. Also frame-ancestors 'none', base-uri 'none', nosniff, DENY, no-referrer and HSTS. FastAPI's /docs, /redoc and /openapi.json are disabled so the login page does not hand out a map of every endpoint. /healthz is the only unauthenticated route and returns a bare ok, so the container healthcheck works without leaking anything. Verified: misconfigured auth refuses to boot; unauthenticated / redirects and /api/* returns 401; a forged state is rejected 400; a junk cookie is 401; a valid session for a non-allowlisted user is 401; all headers present; and the UI renders with no CSP violations in the console. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
265 lines
11 KiB
Markdown
265 lines
11 KiB
Markdown
# 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. <https://github.com/settings/developers> → **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 `<script>` cannot execute. Plus `frame-ancestors 'none'`,
|
||
`base-uri 'none'`, `nosniff`, `X-Frame-Options: DENY`, `no-referrer`, HSTS.
|
||
- **No API docs** — `/docs`, `/redoc` and `/openapi.json` are disabled, so the
|
||
login page doesn't hand out a map of every endpoint.
|
||
- **Denied logins are logged** with the GitHub username — on an
|
||
internet-facing box that's your signal someone found the page.
|
||
- `/healthz` is the only unauthenticated route and returns a bare `{"ok":true}`
|
||
— no versions, no config, no counts.
|
||
|
||
Behind a reverse proxy, `MUSE_BASE_URL` is read from config rather than from
|
||
`X-Forwarded-*` headers on purpose: those are attacker-controllable and this
|
||
one is a redirect target.
|
||
|
||
## Running it
|
||
|
||
```yaml
|
||
services:
|
||
muse:
|
||
image: ghcr.io/geneticglitch1/muse:latest
|
||
container_name: muse
|
||
user: 1000:1000
|
||
environment:
|
||
- SLSKD_URL=http://slskd-host:5030
|
||
- SLSKD_API_KEY=<your slskd api key>
|
||
- MUSE_DOWNLOAD_DIR=/data/torrents/soulseek/complete
|
||
- MUSE_LIBRARY_DIR=/data/media/music
|
||
- MUSE_DB=/config/muse.db
|
||
volumes:
|
||
- ./config:/config
|
||
# ONE mount covering both the download dir and the library. See below.
|
||
- /data:/data
|
||
ports:
|
||
- 5040:5040
|
||
```
|
||
|
||
### Mount both paths under a single volume
|
||
|
||
Imports are `os.link()`, never a move, so the download keeps being shared by
|
||
slskd while the library gets a second name for the same bytes.
|
||
|
||
`link()` between two different **mounts** fails with `EXDEV` even when both sit
|
||
on the same filesystem. Split `/data` into a downloads mount and a library
|
||
mount and every import silently degrades to a full copy, storing everything
|
||
twice. Verify after your first import:
|
||
|
||
```bash
|
||
stat -c "%h" "/data/media/music/<Artist>/<Album> (<Year>)/<NN> - <Title>.flac"
|
||
```
|
||
|
||
`2` is correct. `1` means it copied.
|
||
|
||
### Configuration
|
||
|
||
| variable | default | meaning |
|
||
|---|---|---|
|
||
| `SLSKD_URL` | `http://192.168.1.87:5030` | slskd base URL |
|
||
| `SLSKD_API_KEY` | — | slskd API key (**required**) |
|
||
| `MUSE_DOWNLOAD_DIR` | `/data/torrents/soulseek/complete` | where slskd puts finished files |
|
||
| `MUSE_LIBRARY_DIR` | `/data/media/music` | where the library lives |
|
||
| `MUSE_DB` | `/config/muse.db` | job history |
|
||
| `MUSE_FORMATS` | `flac,mp3` | ordered preference; anything else is rejected |
|
||
| `MUSE_MIN_MATCH` | `0.45` | filename-to-title similarity floor |
|
||
| `MUSE_WORKERS` | `3` | concurrent Soulseek searches |
|
||
| `MUSE_LOG_LEVEL` | `INFO` | |
|
||
|
||
`MUSE_FORMATS` is a preference list, best first — rejecting everything not on
|
||
it is what keeps `.cue` and `.m4a` junk out.
|
||
|
||
`MUSE_MIN_MATCH` guards against importing the wrong song. Soulseek filenames
|
||
are chaotic (`08-radiohead-creep_(accoustic)-ent.mp3`), so the comparison is
|
||
against a normalised form and also accepts plain containment. Raise it if you
|
||
get bad matches; lowering it is how you quietly corrupt a library.
|
||
|
||
### How candidates are scored
|
||
|
||
In priority order: format preference, 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 you
|
||
can have now.
|
||
|
||
## Pulling on a server
|
||
|
||
The GHCR package inherits this repo's visibility, so it is **private**. The
|
||
machine pulling it needs to authenticate once:
|
||
|
||
1. Create a classic PAT at <https://github.com/settings/tokens> with only
|
||
**`read:packages`**.
|
||
2. On the server:
|
||
|
||
```bash
|
||
echo "<token>" | docker login ghcr.io -u <github-username> --password-stdin
|
||
```
|
||
|
||
That writes `~/.docker/config.json` and persists across reboots. Classic PATs
|
||
can be set to no expiry; if you set one, the pull starts failing on that date
|
||
with `denied`.
|
||
|
||
To make the image public instead — no login needed anywhere — go to the
|
||
package page → Package settings → Change visibility.
|
||
|
||
## API
|
||
|
||
| endpoint | purpose |
|
||
|---|---|
|
||
| `GET /` | web UI |
|
||
| `GET /api/search?q=` | Deezer track search |
|
||
| `POST /api/request` | queue a track |
|
||
| `POST /api/import` | queue many from pasted text |
|
||
| `POST /api/retry/{id}` | requeue a failed job |
|
||
| `GET /api/jobs` | request history and state |
|
||
| `GET /api/health` | slskd reachability, mount writability |
|
||
|
||
## Releases
|
||
|
||
Every push to `main` publishes `:latest` and `:sha-<short>`. Tagging `vX.Y.Z`
|
||
also publishes `X.Y.Z` and `X.Y`. Pin a tag in production if you'd rather not
|
||
track `latest`.
|