# 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. ## 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_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 | | `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`.