muse: request a single song, get it tagged in your library
Lidarr's smallest unit is a MusicBrainz release, so asking for one track means monitoring a whole discography. Soulseek trades individual files but has no concept of a request. muse bridges the two: search, click, and the song lands in the library correctly tagged. The design point is that the user picks the exact track before anything downloads, so muse holds artist, album, year and track number as ground truth and tags deterministically rather than inferring them afterwards from whatever a stranger named the file. Search is Deezer rather than MusicBrainz, decided on measurement: MusicBrainz put eight covers above the real recording for "radiohead creep" and never surfaced Queen for "bohemian rhapsody" across 90 results, because its index matches a word in a cover's title as readily as in the artist field. Re-ranking cannot rescue a result set missing the right row. Imports are os.link(), so downloads keep seeding from the Soulseek share while the library gets a second name for the same bytes.
This commit is contained in:
commit
269f13524d
7 files changed
+874
No files matched your search
@@ -0,0 +1,153 @@
|
||||
# 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=<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_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`.
|
||||
Reference in new issue
Block a user