aryansingh 269f13524d 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.
269f13524d · 2026-08-04 03:05:45 -05:00
1 Commit

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

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:

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:
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.

S
Description
Recovered from Google Drive GitHub backup
Readme
315 KiB
0 Stars 1 Watchers 0 Forks
Languages
Python 51.8%
TypeScript 44%
CSS 3%
Dockerfile 0.7%
Shell 0.4%
Other 0.1%