# 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. ## 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= - 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// ()/ - .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`.