Files
Aryan SinghandClaude Opus 5 eeb0e096a7
release / build (push) Canceled after 0s
Import a shared playlist link and build it in Navidrome
Paste a Spotify, Apple Music or Deezer playlist URL; muse resolves it, matches
every track through the same Deezer path a pasted line takes, and writes an
.m3u the library scanner picks up.

Written per import rather than at the end, so the playlist appears in Navidrome
with the first track that lands and grows as the rest arrive -- twenty minutes
of nothing, then everything, is the worse shape. A track rejected in review
drops out on the next rewrite, and one that never resolves is reported instead
of silently vanishing.

Resolve is split from queue on purpose: a shared link is opaque, you cannot
tell twelve tracks from four hundred by looking at it, and four hundred is a
quota decision.

Two of the three sources are scrapes and the code says so plainly. Deezer has a
real API. Spotify and Apple Music have no free way to read a public playlist --
Apple's MusicKit needs a paid developer account -- so those read the public page
and parse the JSON it already embeds for its own player. No key, no OAuth, no
account, and no guarantee: they break whenever either company reshuffles their
frontend. So the resolvers fail loudly and name themselves, because an empty
playlist and a changed page must not produce the same message.

The .m3u uses MUSE_PLAYLIST_PREFIX (/music), not muse's own library path. Both
containers mount the same directory at different points and a playlist written
with muse's view resolves to nothing on Navidrome's side.

Also reorders the hooks in search-bar so the URL-seeding effect is declared
after the state it sets, rather than closing over a binding declared below it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:41:57 -05:00

715 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
./dev.sh # http://127.0.0.1:5041
```
Creates the venv on first run, keeps its state in `dev/` (gitignored), and
serves the prebuilt `web/out` exactly as the container does — same CSP, same
routes, same middleware. Add `--build` after editing the UI.
It sets **`MUSE_AUTH=off`**, so there is no OAuth app to register against a
localhost callback. With auth off every role check reports `admin`, which is
what keeps the admin panel reachable; the startup banner says so in capitals.
What works with nothing else running: search, autocomplete, 30-second previews,
album and artist pages, artwork-derived accent, the admin panel, and queueing.
Requests reach `searching` and then fail, because acquisition needs slskd —
point `SLSKD_URL`/`SLSKD_API_KEY` at a real instance to test that half.
Two things read differently on a Mac. **CPU and memory show `—`**: those come
from `/proc`, which is Linux-only, so they populate in the container and not
here (load average and disk use `getloadavg`/`statvfs` and work everywhere).
And the **`Users` count includes accounts you seed by hand** — with auth off
nobody can sign in, so add them through the panel's Invite button or the API.
For UI work, leave that running and start Next's dev server beside it:
```bash
cd web && npm install && npm run dev
```
`next dev` serves on :3000 with hot reload 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. Note that :3000 serves the UI without the CSP the Python process
attaches — check anything CSP-sensitive on :5041 after a `--build`.
## 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, but far fewer jobs reach that state now:
a request that loses its peer falls through to the next candidate on its own.
See "When a peer says no" below.
## Shared playlist links
Paste a Spotify, Apple Music or Deezer playlist link and muse resolves it,
matches every track the same way a pasted line is matched, and writes an `.m3u`
into the library that Navidrome picks up on its next scan — within seconds, in
practice. The playlist is written **per import**, not at the end, so it appears
with the first track that lands and grows as the rest arrive. Tracks that never
resolve are reported rather than silently dropped, and a track rejected in
review disappears from the playlist on the next rewrite.
Resolving is deliberately separate from queueing. A shared link is opaque — you
cannot tell twelve tracks from four hundred by looking at it — so the track list
and count are shown before anything is committed against a quota.
**Two of the three sources are scrapes, and this is worth understanding before
you rely on it.** Deezer has a real public API. Spotify and Apple Music have no
free way to read a public playlist — Apple's MusicKit requires a paid developer
account — so muse reads the public web page and parses the JSON blob the page
already embeds for its own player. No key, no OAuth, no account. It also means
those two will break the day either company reshuffles their frontend. The
resolvers therefore fail loudly and specifically ("Spotify's embed page no
longer carries `__NEXT_DATA__` — the resolver in app.py needs updating") rather
than quietly returning nothing, because an empty playlist and a changed page
must not look the same to whoever has to fix it.
One detail that is easy to get wrong: the `.m3u` has to use **Navidrome's**
mount path, not muse's. Both containers see the same directory, muse at
`/data/media/music` and Navidrome at `/music`, and a playlist written with the
former resolves to nothing on the other side. `MUSE_PLAYLIST_PREFIX` is that
path.
### 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).** Whether the peer can actually serve you comes
first — a free upload slot and a short queue — then format preference, then
filename plausibility, then bitrate. A marginally better file behind 200 queued
users loses to a good-enough one available now.
That order is deliberate and was once the other way round, which was a bug
worth naming: format outranked availability, so a FLAC behind a five-thousand
deep queue always beat an MP3 that was free to download immediately. Whether
the peer can serve you is the single most predictive signal for whether the
job ever finishes, so nothing outranks it.
## Searching for a name the file doesn't use
Two things go wrong between "what Deezer calls this track" and "what a stranger
named the file", and both used to end the request.
**The Soulseek server filters some artist names.** This is measurable: on a
healthy, logged-in client, `Michael Jackson Billie Jean` and bare
`Michael Jackson` both return **zero** responses, while `Billie Jean` returns
over a thousand files. The same is true of The Weeknd, Kanye West and Frank
Ocean. Sending one query of `artist title` — which is all muse used to do —
means every request for a filtered artist fails reporting "searched 0 files",
indistinguishable from a track nobody shares.
So muse tries a **ladder** of queries, loosest last, and stops at the first
rung that yields enough candidates:
```
Michael Jackson Billie Jean 0 responses
Billie Jean 251 responses, 1083 files
```
The rungs drop, in order: a parenthetical suffix (`Novacane (Explicit Version)`),
a `feat.` credit, a leading article, everything after a `/` in a split title,
and finally the artist itself. Since most requests are answered by the first
query, the extra searches cost nothing in the common case — `MUSE_MAX_QUERIES`
caps how far it will go.
Dropping the artist from the *query* does not mean ignoring it — it makes the
artist matter more. A bare-title search stops returning "this song from various
peers" and starts returning *every song anyone ever called Chicago*: The Fall,
Groove Armada, Sufjan Stevens. The artist is then the only thing separating the
right recording from a different song entirely, so it is a **gate**: candidates
whose path names the artist are preferred outright, and ones that do not are
used only when nothing else is left.
That has to be a gate rather than a weighting, and the reason is worth stating.
A perfect title with no artist scores 0.75, comfortably above `MUSE_MIN_MATCH`,
so as a mere contribution it loses to availability — and an idle peer holding
the wrong song beats a busy one holding the right song. That is not
hypothetical either: it filed The Fall's "Chicago, Now!" as Michael Jackson.
The same principle the confidence score is built on applies here: a fast peer
must not be able to promote a wrong match.
The artist is matched against the whole **folder path**, which is where a
well-organised share puts it anyway:
```
anjya music/Michael Jackson/1982 - Thriller [flac]/06 - Billie Jean.flac
^^^^^^^^^^^^^^^^ artist found here
```
**Names differ in punctuation and accents.** Normalisation folds accents away
before comparing, so `Beyoncé` matches a file called `Beyonce`, `Björk` matches
`Bjork`, and `Don't Stop` matches `Dont Stop`. Apostrophes are removed rather
than turned into a space; `&` still separates, so `R&B` stays two tokens
instead of collapsing into the unrelated word `rb`. Track numbers and release
tags (`02 - `, `[320kbps]`) are stripped before the comparison, and a
reordered filename (`Creep - Radiohead`) scores the same as `Radiohead - Creep`.
The title has to stand on its own: a matching artist cannot carry a filename
whose title is wrong. Without that floor, a strong artist match let a
*different song by the right artist* through.
## When a peer says no
`Transfer rejected: File not shared.` is the commonest failure on Soulseek —
the peer's share index is stale and the file it advertised is gone. It is a
condition of one *file*, not of the request, and the right response is to take
the next candidate.
muse keeps the whole ranked list (`MUSE_CANDIDATES`, default 8) on the job row
rather than only the winner, so when a transfer is rejected, times out, errors,
or slskd cannot open a connection to the peer at all, the next candidate is
enqueued instead of the request failing. The list is ordered to put a different
peer next, so one offline user cannot burn three attempts in a row. Only when
the list is exhausted — or `MUSE_MAX_ATTEMPTS` is reached — does the job fail,
and it then reports every peer it tried and what each one said.
A download that sits with no bytes for `MUSE_STALL_MINUTES` is abandoned the
same way, as is one slskd has forgotten about. Both used to leave the job in
`downloading` forever, with a cheerful pulsing dot and no log line.
**Unreachable peers are the norm, not the exception.** slskd answers 500 when it
cannot open a connection to a peer within its five-second window, and on a live
instance that is common: one request walked four such peers before the fifth
served it. This is why the ladder is worth having and why `MUSE_MAX_ATTEMPTS`
defaults to the full `MUSE_CANDIDATES` rather than a smaller number — a cap
below the ladder size throws away candidates that would have worked.
Attempts are spaced by `MUSE_ATTEMPT_BACKOFF`. That is politeness rather than a
fix for anything measured: each enqueue holds a peer connection slot for up to
five seconds, and there is no reason to ask for eight of them in immediate
succession. Separately, `MUSE_SYSTEMIC_LIMIT` consecutive *connection* errors —
meaning slskd itself is unreachable, not a peer — stop the walk and **keep the
untried candidates on the row**, so retrying resumes the ladder rather than
paying for a search whose answer has not changed. A 500 is deliberately not
counted as systemic: it describes one peer, and treating it as systemic would
have abandoned the request that succeeded on its fifth attempt.
## Review: when muse isn't sure
Picking a file and *trusting* it are different questions, so they use different
numbers. The rank above decides which candidate to fetch and is right to weigh
peer speed and queue length. None of that says anything about whether the file
is the correct recording — a fast peer must not be able to raise the confidence
of a wrong match. So a second score, **confidence**, is computed from the
filename alone:
| | weight |
|---|---|
| title present in the filename | 0.75 |
| artist present in the filename, folder, or partially | 0.25 |
| album name somewhere in the path | +0.05 |
| a variant tag you didn't ask for (live, remix, cover, karaoke…) | ×0.8, and capped below auto |
Then:
**Length is checked against the audio, not the name.** Every textual signal can
agree and the file still be the wrong song: right title, right folder, right
album — and six minutes of a track that runs four. So once the download
finishes, the file's real duration is compared against what Deezer said, and a
drift past `MUSE_LENGTH_TOLERANCE` multiplies confidence down and says so in
words: *"runs 5:59 but the track is 4:05 (47% out), so this is probably a
different recording"*. This is applied on import rather than inside
`confidence()` for the obvious reason that the file does not exist yet when the
candidate is picked.
- **≥ 0.95** — filed automatically. Nothing to do.
- **0.70 – 0.95** — held in **Review** with the reasons shown.
- **< 0.70** — also held, but it will almost certainly be rejected.
Confidence gates the **import, not the download**. The transfer completes
either way and the file waits in the downloads directory: approving is then
instant and rejecting is a delete. Holding the *transfer* instead would mean
the peer has usually vanished by the time anyone looks.
The two cases this exists for are worth naming. A file called `02 Creep.flac`
in an unsorted folder scores 0.75 — the title is exactly right and the artist
is simply absent, which is the commonest honest case there is, so it must land
in review rather than be binned. And `Creep (Live at Glastonbury)` scores 0.80
with *both* artist and title matching perfectly: the variant cap is what stops
a live recording auto-filing over the studio version you asked for. Ask for
`Creep (Acoustic)` explicitly and it auto-imports at 0.97, because then the
variant is the request.
Tune with `MUSE_AUTO_CONFIDENCE` and `MUSE_REVIEW_CONFIDENCE`.
`tests/test_matching.py` asserts the bands, not the exact scores.
## Discover
Recommendations and a personal recap, both built from **muse's own request
history** plus Deezer's related-artists graph — never from listening data.
Navidrome owns play counts, favourites and history; a second source of truth
for those would end up disagreeing with the one you actually listen through.
So "because you added" seeds off the artists you have imported most, and the
recap counts requests, genres and release decades. What you *played* is
Navidrome's business.
## Email
Off unless `MUSE_SMTP_HOST` and `MUSE_SMTP_FROM` are set. muse writes to you
when a request lands in your library, and when a download finished but needs
your call in Review.
```
MUSE_SMTP_HOST=smtp.fastmail.com
MUSE_SMTP_PORT=587
MUSE_SMTP_USER=you@example.com
MUSE_SMTP_PASS=an-app-password
MUSE_SMTP_FROM=muse@example.com
MUSE_SMTP_TLS=starttls # or ssl, or none
```
Each user sets their own address under **Settings**, or turns mail off
entirely. Google accounts need no configuration — the identity *is* an email
address. Sending runs on a worker thread and every failure is logged and
swallowed: a notification is never worth failing an import over.
## Authentication
OAuth against GitHub and/or Google, 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.
### Who may log in
Two rules, checked in order:
1. **`MUSE_ALLOWED_USERS`** — the bootstrap allowlist. Always admitted, on any
enabled provider. This is how the first admin gets in when the database is
empty, and the recovery path if someone locks themselves out.
2. **A `users` row that isn't disabled, on the provider it was invited for** —
how everyone invited later gets in, with no env change and no redeploy.
Anyone matching neither is rejected and the attempt is logged with their
username. The check runs on **every request**, not just at login: an
authorization check that only runs at sign-in isn't revocation, it's a delay.
The first account to ever sign in becomes **admin**. No bootstrap ceremony, no
default password.
#### Invites are bound to a provider
Invite `friend@gmail.com` for Google and Google is the only way in for that
identity — the same address arriving over a different provider is refused. So
"the owner signs in with GitHub, everyone else with Google" is an enforced
boundary rather than a convention, and there is never a second, weaker door
onto an existing account.
For the same reason a successful login no longer refreshes the stored
`provider`: it is part of the identity an admin issued, so letting a sign-in
rewrite it would let the login quietly rewrite the rule that permitted it.
A Google invite must be the full email address, because a Google identity *is*
one — inviting a bare username would authorise something Google can never
return, and the person would just see "not authorised" with no explanation.
### 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`.
To add Google, set `MUSE_AUTH=github,google` plus the two Google variables.
**The callback URL is the same** — `/auth/callback` for every provider, with
the provider carried in the state cookie rather than the URL — so adding
Google needs no change to an already-registered GitHub app. With more than one
provider enabled, `/auth/login` shows a chooser.
Google identities are the **verified** email address; an unverified one is
refused outright, since anyone can put someone else's address on an account
they control and the allowlist is keyed on exactly that string.
| variable | default | meaning |
|---|---|---|
| `MUSE_AUTH` | `github` | comma list of `github`, `google`, or `off` |
| `MUSE_GITHUB_CLIENT_ID` / `_SECRET` | — | GitHub OAuth app |
| `MUSE_GOOGLE_CLIENT_ID` / `_SECRET` | — | Google OAuth client |
| `MUSE_ALLOWED_USERS` | — | bootstrap allowlist; required only while the users table is empty |
| `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 |
## Multiple users
Roles are **admin**, **user** and **guest**. Admins get an extra tab.
**The row is the authorisation** — add someone's Google address in the admin
panel and they can sign in. Remove them and they can't.
**The email is a notification, not a credential.** If SMTP is configured they
also get a message telling them they have access and which account to use. That
link carries **no token**, deliberately: the row already names the exact
identity permitted, and Google will not hand muse an unverified address — so
possession of the email is not what lets anyone in. Which means a forwarded or
leaked invite grants nothing, nothing expires, and **resend is always safe**.
The admin panel offers resend on anyone who hasn't signed in yet.
The alternative — a signed, expiring token — buys you the ability to withdraw
an invitation before it's used. Deleting the row does that already, so the
token would be machinery guarding something already guarded.
### Quotas
Two per-user limits, both `0` = unlimited:
- **Requests per month** — calendar month, resets on the 1st. A rolling 30-day
window was rejected on purpose: "12 left until the 1st" is something a person
can plan around, where a rolling window drips capacity back at unpredictable
times.
- **Storage** — bytes of imported audio attributable to that user, measured
from the file actually written rather than the size the Soulseek peer
advertised.
Batches are checked against the **whole batch**: queueing a 30-track album with
5 requests left is refused up front, not accepted and then abandoned halfway.
Admins are exempt — somebody has to be able to fix the library when a quota is
what's in the way.
### What the admin panel shows
Host CPU, memory and disk (read straight from `/proc` — no psutil, no compiled
dependency, and inside a container `/proc` reports the *host*, which is what
you want for a homelab box), library size, slskd connection state, a 30-day
request chart, per-user and per-artist breakdowns, and the user table with
inline role and quota editing.
### Guard rails
- **The last active admin cannot be demoted, disabled or deleted.** An install
with no admin can't be administered and nothing in the UI can undo it.
- **Disabling revokes sessions immediately** rather than at expiry.
- **Deleting a user keeps their jobs.** The history is a record of what's in
the library and why; removing a person shouldn't rewrite it.
- **Requests are private by default.** Users see their own; admins see all
(`?mine=true` to scope down). Retrying someone else's job returns **404**,
not 403 — a distinct error would confirm the id exists.
### 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-track similarity floor |
| `MUSE_TITLE_FLOOR` | `0.5` | the title alone must clear this before the artist counts |
| `MUSE_CANDIDATES` | `8` | ranked candidates kept per request, for fallback |
| `MUSE_MAX_ATTEMPTS` | `MUSE_CANDIDATES` | peers tried before a request is given up on |
| `MUSE_ATTEMPT_BACKOFF` | `4` | seconds between candidate attempts |
| `MUSE_SYSTEMIC_LIMIT` | `2` | consecutive connection failures before stopping |
| `MUSE_STALL_MINUTES` | `20` | a transfer moving no bytes for this long is abandoned |
| `MUSE_MAX_QUERIES` | `4` | rungs of the search ladder to try |
| `MUSE_MIN_CANDIDATES` | `3` | below this, escalate to the next query |
| `MUSE_SEARCH_RETRIES` | `3` | attempts at issuing a search before failing |
| `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking |
| `MUSE_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong |
| `MUSE_LENGTH_TOLERANCE` | `0.2` | duration drift before a file is called a different recording |
| `MUSE_MAX_PLAYLIST_TRACKS` | `500` | cap on one playlist link |
| `MUSE_PLAYLIST_PREFIX` | `/music` | the library path *Navidrome* sees, for `.m3u` lines |
| `MUSE_WORKERS` | `3` | concurrent Soulseek searches |
| `MUSE_SMTP_HOST` | — | set to enable email; see **Email** above |
| `MUSE_SMTP_PORT` | `587` | |
| `MUSE_SMTP_USER` / `_PASS` | — | omit for an unauthenticated relay |
| `MUSE_SMTP_FROM` | `MUSE_SMTP_USER` | envelope sender |
| `MUSE_SMTP_TLS` | `starttls` | `starttls`, `ssl`, or anything else for none |
| `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 (own, or all for admins) |
| `GET /api/review` | downloads held back for a decision |
| `POST /api/review/{id}` | `approve` files it, `reject` deletes it |
| `GET /api/discover` | related artists, seeded from what you've imported |
| `GET /api/recap` | your top artists, genres and decades |
| `GET /api/me` | who you are, your role and quota usage |
| `PATCH /api/me` | your own email address and notification toggle |
| `GET /api/health` | slskd reachability, mount writability |
| `GET /api/admin/users` | list users with usage |
| `POST /api/admin/users` | invite, and email them if SMTP is set up |
| `POST /api/admin/users/{u}/resend` | send the invitation again |
| `PATCH /api/admin/users/{u}` | role, quotas, disable |
| `DELETE /api/admin/users/{u}` | remove (jobs retained) |
| `GET /api/admin/system` | CPU, memory, disk, slskd, library totals |
| `GET /api/admin/activity` | daily volume, per-user and per-artist breakdowns |
Everything except `/healthz` requires a session; `/api/admin/*` requires the
admin role.
## 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`.