Requesting songs one at a time does not scale past a handful, so this adds a paste-a-list import. It accepts "Artist - Title", en dashes, "Title by Artist", numbered lists, bare titles, and Deezer album/playlist URLs, which get expanded into their tracks. The queue is the load-bearing part. Every request previously spawned its own asyncio task, so a 50-track paste would have launched 50 concurrent Soulseek searches -- each 10-40s of polling and real load on slskd -- and drowned the client. Work now goes through an asyncio.Queue drained by MUSE_WORKERS consumers, default 3. Requests return immediately and the batch drains at a civil rate. The queue is in memory, so jobs left 'queued' or 'searching' are re-queued at startup rather than stranded by a restart. Ranking needed two fixes found by testing against the real API: Import was calling deezer_search(limit=1), which fetches two rows, so the ranker was choosing between a demo and a rehearsal take while the studio version -- six times the popularity -- sat unseen further down. It now pulls a real candidate pool. More seriously, the Deezer ranker had no title-relevance term at all: only artist match, popularity and penalties. A different song by the right artist therefore competed on equal footing, and "Oasis - Live Forever" resolved to "Cloudburst" as soon as a variant penalty tipped the balance. Title overlap now dominates, and the variant penalty is small enough only to break ties between versions of the same song. Derivative terms match on word boundaries rather than substrings, so a bare "live" catches "Come As You Are (Live)" without penalising "Deliver Me" or "Olive", and a term is ignored when the user typed it -- "oasis live forever" still works. Verified against live slskd: six tracks pasted, six resolved to the correct song, five imported with correct album/year/track and link count 2, in about two minutes. The sixth returned an empty Soulseek search and is retryable from the UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
8.1 KiB
Markdown
212 lines
8.1 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.
|
||
|
||
## 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=<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=` | 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`.
|