The old page was hand-written HTML/CSS/JS. That was fine for a search box and
a list, but the roadmap here is multi-user, an admin dashboard with usage
graphs, and an import-review queue — all of which get painful in vanilla DOM
code. Moves to Next.js 16 / React 19 / Tailwind 4 / Framer Motion.
Built as a STATIC EXPORT served by FastAPI. Running `next start` alongside
uvicorn would add a Node runtime, a second process to supervise, and a second
thing that can be down while the other is up. There is no use for SSR — every
view is behind auth and renders from live JSON — so the deployment stays one
container, one process, one healthcheck. Image is 60MB; Node only exists in
the build stage.
Search now carries what a music UI needs:
- year and genre, grafted on from /album/{id} concurrently, deduplicated by
album and cached forever since a release date is immutable
- 30-second previews, so you can hear a track before committing a Soulseek
search to it. Not a library player — that is Navidrome's job.
- autocomplete over songs/albums/artists, debounced and aborted per keystroke
so a slow response to "rad" cannot land after "radiohead"
- album and artist pages, with `Get all` to queue a release in one request
- the accent colour is sampled from the artwork, so the palette follows the
music. Deezer's CDN sends ACAO:*, which is what makes canvas sampling work
with no image proxy.
/api/album/{id} deliberately calls Deezer twice: the track list embedded in
the album response has no track_position, and using the list index instead
misnumbers every multi-disc release — verified against the White Album, where
disc 2 track 1 would have been filed as 18.
Schema is multi-user ready ahead of the feature: users table with roles and
quotas, requested_by on jobs, and the first login becomes admin. Auth is still
the single-user GitHub allowlist.
CSP survives all of this intact. Next emits inline hydration scripts, which
script-src 'self' blocks; rather than per-request nonces, their SHA-256 is
computed at startup from the built page — a static export never varies between
requests, so hashes need no maintenance and no HTML rewriting. The one
concession is style-src-attr 'unsafe-inline', because Framer Motion writes its
pre-hydration state into style="" and without it every element flashes in.
That permits style attributes only. img-src/media-src now name Deezer's CDN
explicitly instead of allowing https: wholesale, closing an exfiltration
channel that survived connect-src 'self'. /_next/* sits behind the session
too — there is no local login page to style, so the bundle need not be public.
Verified: unauthenticated /→302 and every /api/*→401 including the four new
endpoints, forged state→400, forged cookie→401, misconfigured auth refuses to
start, and the production image hydrates with zero CSP violations.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
344 lines
15 KiB
Markdown
344 lines
15 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.
|
||
|
||
## 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. <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, 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 `<style>` blocks and remote stylesheets are
|
||
still blocked, and CSS-based exfiltration goes through `url()`, which
|
||
`img-src` already pins to two hosts.
|
||
- **`img-src`/`media-src` name Deezer's CDN explicitly** rather than allowing
|
||
`https:` wholesale. Under a blanket `https:`, injected markup can beacon to
|
||
any host by requesting an image from it — a working exfiltration channel even
|
||
with `connect-src` locked to `'self'`.
|
||
- Plus `frame-ancestors 'none'`, `base-uri 'none'`, `object-src 'none'`,
|
||
`nosniff`, `X-Frame-Options: DENY`, `no-referrer`, HSTS.
|
||
- **The JS bundle is behind the session too.** There is no local login page to
|
||
style — an unauthenticated visitor is redirected straight to GitHub and never
|
||
receives the HTML — so `/_next/*` requires a cookie like everything else, and
|
||
doesn't hand out a map of the app to whoever finds the URL.
|
||
- **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=` | track search, enriched with year and genre |
|
||
| `GET /api/suggest?q=` | autocomplete — songs, albums, artists |
|
||
| `GET /api/album/{id}` | album header plus its full track list |
|
||
| `GET /api/artist/{id}` | artist, top tracks, discography |
|
||
| `POST /api/request` | queue a track |
|
||
| `POST /api/request/batch` | queue many known tracks (a whole album) |
|
||
| `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 |
|
||
|
||
Everything except `/healthz` requires a session.
|
||
|
||
## 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`.
|