aryansinghandClaude Opus 5 bc46fd80b3 Add single-user GitHub OAuth, hardened for internet exposure
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>
bc46fd80b3 · 2026-08-04 12:09:51 -05:00
3 Commits

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

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:

  1. Create a classic PAT at https://github.com/settings/tokens with only read:packages.
  2. 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.

S
Description
Recovered from Google Drive GitHub backup
Readme
315 KiB
0 Stars 1 Watchers 0 Forks
Languages
Python 51.8%
TypeScript 44%
CSS 3%
Dockerfile 0.7%
Shell 0.4%
Other 0.1%