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>
11 KiB
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
- https://github.com/settings/developers → New OAuth App
- Homepage URL:
https://muse.yourdomain.com - Authorization callback URL:
https://muse.yourdomain.com/auth/callback
- Homepage URL:
- Generate a client secret.
- Set
MUSE_GITHUB_CLIENT_ID,MUSE_GITHUB_CLIENT_SECRET,MUSE_ALLOWED_USERS(your GitHub login), andMUSE_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
stateis generated per login and compared withsecrets.compare_digest, so a callback you didn't initiate is rejected. - Strict CSP —
default-src 'self', nounsafe-inline. This is why the stylesheet and script are separate files instead of inline in the page: an injected<script>cannot execute. Plusframe-ancestors 'none',base-uri 'none',nosniff,X-Frame-Options: DENY,no-referrer, HSTS. - No API docs —
/docs,/redocand/openapi.jsonare 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.
/healthzis 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
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:
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:
- Create a classic PAT at https://github.com/settings/tokens with only
read:packages. - On the server:
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.