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,75 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
# Publishes ghcr.io/<owner>/muse. The package inherits this repo's visibility
|
||||||
|
# on first publish, so it lands PRIVATE — pulling it needs a token with
|
||||||
|
# read:packages (see README, "Pulling on a server").
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
# Docs-only edits shouldn't burn a build or move :latest.
|
||||||
|
paths-ignore:
|
||||||
|
- "**.md"
|
||||||
|
- ".gitignore"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# A newer push makes an in-flight build pointless.
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write # required to push to GHCR
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Sanity-check the app before publishing
|
||||||
|
run: |
|
||||||
|
python3 -m pip install --quiet -r requirements.txt
|
||||||
|
python3 -c "import app; print('app imports OK')"
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
# Automatically provided; no PAT needed for pushing from CI.
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Tags and labels
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,format=short
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
# amd64 only: the only host that runs this is x86_64. Adding arm64
|
||||||
|
# means QEMU emulation and a 3-5x slower build for nothing.
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
echo "### Published" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
config/
|
||||||
|
*.db
|
||||||
|
.venv/
|
||||||
|
.DS_Store
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# Slim, no build toolchain needed — every dependency ships wheels for
|
||||||
|
# linux/amd64 on py3.12.
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
COPY static ./static
|
||||||
|
|
||||||
|
# Runs as 1000:1000 via the compose `user:` key, matching every other
|
||||||
|
# container that touches /data. Nothing here needs root.
|
||||||
|
EXPOSE 5040
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=60s --timeout=10s --start-period=15s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5040/api/health',timeout=8).status==200 else 1)"
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5040", "--no-access-log"]
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
"""
|
||||||
|
muse — request a song, get it in Navidrome.
|
||||||
|
|
||||||
|
The gap this fills: Lidarr thinks in MusicBrainz *releases*, so the smallest
|
||||||
|
thing it can want is an album. Soulseek trades individual files. Nothing in
|
||||||
|
between existed, so requesting one song meant either hoovering a whole
|
||||||
|
discography through Lidarr or hand-filing downloads out of slskd's UI.
|
||||||
|
|
||||||
|
The trick that makes this simple: the user PICKS a specific track before
|
||||||
|
anything is downloaded. That means we hold ground-truth metadata — artist,
|
||||||
|
album, year, track number — before the file exists. Taggers like beets have to
|
||||||
|
infer all of it afterwards from whatever a stranger named the file; we never
|
||||||
|
guess. We tag what was asked for.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
/api/search Deezer track search -> the user picks one
|
||||||
|
/api/request /track/{id} for exact metadata, then slskd search ->
|
||||||
|
score candidates -> enqueue best
|
||||||
|
worker poll slskd -> on completion: tag, then hardlink into
|
||||||
|
/data/media/music/<Artist>/<Album> (<Year>)/NN - Title.ext
|
||||||
|
|
||||||
|
Hardlink, not copy: the download stays in slskd's completed dir so it keeps
|
||||||
|
being shared, while the library gets its own name for the same bytes. One
|
||||||
|
file on disk, two names — the same trick the *arrs use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import difflib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
log = logging.getLogger("muse")
|
||||||
|
logging.basicConfig(
|
||||||
|
level=os.getenv("MUSE_LOG_LEVEL", "INFO"),
|
||||||
|
format="[%(levelname)s|%(name)s] %(asctime)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
||||||
|
)
|
||||||
|
|
||||||
|
SLSKD_URL = os.getenv("SLSKD_URL", "http://192.168.1.87:5030").rstrip("/")
|
||||||
|
SLSKD_API_KEY = os.getenv("SLSKD_API_KEY", "")
|
||||||
|
DOWNLOAD_DIR = Path(os.getenv("MUSE_DOWNLOAD_DIR", "/data/torrents/soulseek/complete"))
|
||||||
|
LIBRARY_DIR = Path(os.getenv("MUSE_LIBRARY_DIR", "/data/media/music"))
|
||||||
|
DB_PATH = Path(os.getenv("MUSE_DB", "/config/muse.db"))
|
||||||
|
|
||||||
|
# Ordered best-first. A candidate's extension must appear here or it is
|
||||||
|
# rejected outright — this is what keeps .m4a rips and stray .cue files out.
|
||||||
|
FORMATS = [f.strip() for f in os.getenv("MUSE_FORMATS", "flac,mp3").split(",") if f.strip()]
|
||||||
|
|
||||||
|
# Below this, the remote filename doesn't plausibly contain the track title.
|
||||||
|
# Soulseek filenames are chaotic ("08-radiohead-creep_(accoustic)-ent.mp3"),
|
||||||
|
# so this compares against a normalised form, not the raw string.
|
||||||
|
MIN_MATCH = float(os.getenv("MUSE_MIN_MATCH", "0.45"))
|
||||||
|
|
||||||
|
# Search uses Deezer's public API: no key, no OAuth, and vastly better
|
||||||
|
# relevance than MusicBrainz for the way people actually type.
|
||||||
|
#
|
||||||
|
# MusicBrainz was tried first and rejected on measurements, not taste. It is a
|
||||||
|
# superb database but a poor search engine: "radiohead creep" returned eight
|
||||||
|
# covers and mashups before the Radiohead recording, and "bohemian rhapsody"
|
||||||
|
# never surfaced Queen at all across 90 results — because its text index
|
||||||
|
# matches "radiohead" inside a cover's *title* as readily as in the artist
|
||||||
|
# field. Client-side re-ranking can only reorder what came back, so it could
|
||||||
|
# not fix a result set that lacked the right row. Deezer returns
|
||||||
|
# "Creep / Radiohead / Pablo Honey" first for the same query.
|
||||||
|
#
|
||||||
|
# Deezer search omits track number and release date, so those are fetched from
|
||||||
|
# /track/{id} at request time — one extra call per request, not per result.
|
||||||
|
DEEZER = "https://api.deezer.com"
|
||||||
|
|
||||||
|
# Derivative versions to push down when the user did not ask for them.
|
||||||
|
DERIVATIVE = ("cover", "karaoke", "tribute", "remix", "instrumental",
|
||||||
|
"originally performed", "made popular by", "in the style of")
|
||||||
|
|
||||||
|
SAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
|
NOISE = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
def safe(name: str) -> str:
|
||||||
|
"""Filesystem-safe path component. Trailing dots/spaces break on some FS."""
|
||||||
|
return SAFE.sub("_", name).strip(". ") or "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def norm(s: str) -> str:
|
||||||
|
return NOISE.sub(" ", s.lower()).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────── storage ───────────────────────────
|
||||||
|
|
||||||
|
def db():
|
||||||
|
c = sqlite3.connect(DB_PATH, timeout=30)
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with db() as c:
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
state TEXT NOT NULL, -- searching|queued|downloading|imported|failed
|
||||||
|
detail TEXT,
|
||||||
|
artist TEXT, title TEXT, album TEXT,
|
||||||
|
year TEXT, track INTEGER,
|
||||||
|
recording_mbid TEXT, release_mbid TEXT,
|
||||||
|
username TEXT, remote_file TEXT, size INTEGER,
|
||||||
|
imported_path TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def set_state(job_id: str, state: str, detail: str = "", **cols):
|
||||||
|
sets = ", ".join(f"{k}=?" for k in cols)
|
||||||
|
sql = f"UPDATE jobs SET state=?, detail=?{', ' + sets if sets else ''} WHERE id=?"
|
||||||
|
with db() as c:
|
||||||
|
c.execute(sql, [state, detail, *cols.values(), job_id])
|
||||||
|
log.info("job %s -> %s %s", job_id[:8], state, detail)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────── slskd ───────────────────────────
|
||||||
|
|
||||||
|
def slskd_client() -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
base_url=SLSKD_URL,
|
||||||
|
headers={"X-API-Key": SLSKD_API_KEY},
|
||||||
|
timeout=httpx.Timeout(30.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ext_of(remote_path: str) -> str:
|
||||||
|
# Soulseek paths are Windows-style; the API's `extension` field comes back
|
||||||
|
# empty in practice, so derive it ourselves.
|
||||||
|
return remote_path.rsplit(".", 1)[-1].lower() if "." in remote_path else ""
|
||||||
|
|
||||||
|
|
||||||
|
def score(candidate: dict, peer: dict, want: str) -> float | None:
|
||||||
|
"""Rank one remote file. None = reject.
|
||||||
|
|
||||||
|
Ordering intent: correct format first, then actually-gettable (a peer with
|
||||||
|
a free slot and a short queue beats a marginally better file behind 200
|
||||||
|
people), then filename plausibility, then bitrate.
|
||||||
|
"""
|
||||||
|
ext = ext_of(candidate["filename"])
|
||||||
|
if ext not in FORMATS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base = candidate["filename"].replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
ratio = difflib.SequenceMatcher(None, norm(base), want).ratio()
|
||||||
|
# A short title inside a long scene filename scores badly on raw ratio, so
|
||||||
|
# give credit for simple containment too.
|
||||||
|
if want in norm(base):
|
||||||
|
ratio = max(ratio, 0.9)
|
||||||
|
if ratio < MIN_MATCH:
|
||||||
|
return None
|
||||||
|
|
||||||
|
s = 0.0
|
||||||
|
s += (len(FORMATS) - FORMATS.index(ext)) * 100 # format preference
|
||||||
|
s += 60 if peer.get("hasFreeUploadSlot") else 0
|
||||||
|
s += max(0, 40 - peer.get("queueLength", 0)) # short queues
|
||||||
|
s += min(peer.get("uploadSpeed", 0) / 50_000, 20) # capped speed bonus
|
||||||
|
s += ratio * 50
|
||||||
|
s += min(candidate.get("bitRate") or 0, 320) / 32 # 0-10
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
async def pick_and_enqueue(job_id: str, artist: str, title: str):
|
||||||
|
"""Search slskd for one track and queue the best candidate."""
|
||||||
|
want = norm(f"{artist} {title}")
|
||||||
|
search_id = str(uuid.uuid4())
|
||||||
|
async with slskd_client() as sc:
|
||||||
|
try:
|
||||||
|
r = await sc.post("/api/v0/searches",
|
||||||
|
json={"id": search_id, "searchText": f"{artist} {title}"})
|
||||||
|
r.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
set_state(job_id, "failed", f"slskd search failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Poll until slskd calls it done, or we run out of patience. Soulseek
|
||||||
|
# searches trickle in; results at t=5s are much worse than at t=15s.
|
||||||
|
results = {}
|
||||||
|
for _ in range(20):
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
try:
|
||||||
|
rr = await sc.get(f"/api/v0/searches/{search_id}",
|
||||||
|
params={"includeResponses": "true"})
|
||||||
|
rr.raise_for_status()
|
||||||
|
results = rr.json()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if str(results.get("state", "")).startswith("Completed"):
|
||||||
|
break
|
||||||
|
|
||||||
|
best, best_score = None, -1.0
|
||||||
|
for peer in results.get("responses", []):
|
||||||
|
for f in peer.get("files", []):
|
||||||
|
if f.get("isLocked"):
|
||||||
|
continue
|
||||||
|
sc_ = score(f, peer, want)
|
||||||
|
if sc_ is not None and sc_ > best_score:
|
||||||
|
best, best_score = (peer, f), sc_
|
||||||
|
|
||||||
|
# Tidy up so slskd's search history doesn't grow without bound.
|
||||||
|
try:
|
||||||
|
await sc.delete(f"/api/v0/searches/{search_id}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not best:
|
||||||
|
set_state(job_id, "failed",
|
||||||
|
f"no {'/'.join(FORMATS)} candidate matched (searched {results.get('fileCount', 0)} files)")
|
||||||
|
return
|
||||||
|
|
||||||
|
peer, f = best
|
||||||
|
try:
|
||||||
|
er = await sc.post(f"/api/v0/transfers/downloads/{peer['username']}",
|
||||||
|
json=[{"filename": f["filename"], "size": f["size"]}])
|
||||||
|
er.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
set_state(job_id, "failed", f"enqueue failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
set_state(job_id, "downloading",
|
||||||
|
f"from {peer['username']} (score {best_score:.0f})",
|
||||||
|
username=peer["username"], remote_file=f["filename"], size=f["size"])
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────── import ───────────────────────────
|
||||||
|
|
||||||
|
def tag_and_file(job: sqlite3.Row, src: Path) -> Path:
|
||||||
|
"""Write known-good tags, then hardlink into the library."""
|
||||||
|
from mutagen import File as MFile
|
||||||
|
from mutagen.easyid3 import EasyID3
|
||||||
|
from mutagen.flac import FLAC
|
||||||
|
from mutagen.mp3 import MP3
|
||||||
|
|
||||||
|
try:
|
||||||
|
if src.suffix.lower() == ".flac":
|
||||||
|
audio = FLAC(src)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
audio = EasyID3(src)
|
||||||
|
except Exception:
|
||||||
|
m = MP3(src)
|
||||||
|
m.add_tags()
|
||||||
|
m.save()
|
||||||
|
audio = EasyID3(src)
|
||||||
|
|
||||||
|
audio["title"] = job["title"]
|
||||||
|
audio["artist"] = job["artist"]
|
||||||
|
audio["albumartist"] = job["artist"]
|
||||||
|
if job["album"]:
|
||||||
|
audio["album"] = job["album"]
|
||||||
|
if job["year"]:
|
||||||
|
audio["date"] = str(job["year"])
|
||||||
|
if job["track"]:
|
||||||
|
audio["tracknumber"] = str(job["track"])
|
||||||
|
# Deliberately NOT writing the Deezer id into musicbrainz_trackid --
|
||||||
|
# that field means a specific thing and a wrong value there would
|
||||||
|
# mislead Picard, beets, or anything else that later reads this file.
|
||||||
|
audio.save()
|
||||||
|
except Exception as e:
|
||||||
|
# A tagging failure must not strand the file — Navidrome can still
|
||||||
|
# read whatever tags it shipped with.
|
||||||
|
log.warning("tagging failed for %s: %s", src.name, e)
|
||||||
|
|
||||||
|
album = f"{safe(job['album'] or 'Singles')}"
|
||||||
|
if job["year"]:
|
||||||
|
album += f" ({job['year']})"
|
||||||
|
dest_dir = LIBRARY_DIR / safe(job["artist"]) / album
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
num = f"{int(job['track']):02d} - " if job["track"] else ""
|
||||||
|
dest = dest_dir / f"{num}{safe(job['title'])}{src.suffix.lower()}"
|
||||||
|
|
||||||
|
if dest.exists():
|
||||||
|
return dest
|
||||||
|
try:
|
||||||
|
os.link(src, dest) # same filesystem -> free, and keeps sharing
|
||||||
|
except OSError:
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(src, dest) # cross-device fallback
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def find_downloaded(remote_file: str) -> Path | None:
|
||||||
|
"""Locate the completed file. slskd nests it under the remote's parent
|
||||||
|
directory name, so match on basename rather than guessing the path."""
|
||||||
|
base = remote_file.replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
for p in DOWNLOAD_DIR.rglob("*"):
|
||||||
|
if p.is_file() and p.name == base:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def worker():
|
||||||
|
"""Poll slskd for in-flight downloads and import the finished ones."""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
try:
|
||||||
|
with db() as c:
|
||||||
|
jobs = c.execute(
|
||||||
|
"SELECT * FROM jobs WHERE state='downloading'").fetchall()
|
||||||
|
if not jobs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
async with slskd_client() as sc:
|
||||||
|
r = await sc.get("/api/v0/transfers/downloads")
|
||||||
|
r.raise_for_status()
|
||||||
|
payload = r.json()
|
||||||
|
|
||||||
|
# Flatten slskd's user -> directories -> files nesting.
|
||||||
|
states: dict[str, str] = {}
|
||||||
|
for user in payload:
|
||||||
|
for d in user.get("directories", []):
|
||||||
|
for f in d.get("files", []):
|
||||||
|
states[f.get("filename", "")] = str(f.get("state", ""))
|
||||||
|
|
||||||
|
for job in jobs:
|
||||||
|
st = states.get(job["remote_file"], "")
|
||||||
|
if "Completed, Succeeded" in st:
|
||||||
|
src = find_downloaded(job["remote_file"])
|
||||||
|
if not src:
|
||||||
|
continue # still being moved out of incomplete
|
||||||
|
dest = tag_and_file(job, src)
|
||||||
|
set_state(job["id"], "imported", str(dest), imported_path=str(dest))
|
||||||
|
elif "Completed" in st and "Succeeded" not in st:
|
||||||
|
set_state(job["id"], "failed", f"transfer {st}")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("worker: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────── api ───────────────────────────
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
init_db()
|
||||||
|
task = asyncio.create_task(worker())
|
||||||
|
log.info("muse up — slskd=%s library=%s formats=%s",
|
||||||
|
SLSKD_URL, LIBRARY_DIR, ",".join(FORMATS))
|
||||||
|
yield
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="muse", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestBody(BaseModel):
|
||||||
|
artist: str
|
||||||
|
title: str
|
||||||
|
album: str | None = None
|
||||||
|
year: str | None = None
|
||||||
|
track: int | None = None
|
||||||
|
deezer_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/search")
|
||||||
|
async def search(q: str, limit: int = 25):
|
||||||
|
"""Track search. Returns individual songs, not albums — the whole point."""
|
||||||
|
if not q.strip():
|
||||||
|
return {"results": []}
|
||||||
|
async with httpx.AsyncClient(timeout=20) as c:
|
||||||
|
r = await c.get(f"{DEEZER}/search", params={"q": q, "limit": limit * 2})
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise HTTPException(502, f"deezer returned {r.status_code}")
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
qn = norm(q)
|
||||||
|
scored = []
|
||||||
|
for t in data.get("data", []):
|
||||||
|
title = t.get("title") or ""
|
||||||
|
artist = (t.get("artist") or {}).get("name") or ""
|
||||||
|
album = (t.get("album") or {}).get("title") or ""
|
||||||
|
|
||||||
|
s = float(t.get("rank") or 0) / 10_000 # Deezer popularity, 0-~100
|
||||||
|
# An artist match is a far stronger signal than a title match: it is
|
||||||
|
# what separates the real track from a covers compilation.
|
||||||
|
if norm(artist) and norm(artist) in qn:
|
||||||
|
s += 300
|
||||||
|
for w in DERIVATIVE:
|
||||||
|
if w in norm(title) and w not in qn:
|
||||||
|
s -= 150
|
||||||
|
scored.append((s, {
|
||||||
|
"deezer_id": t.get("id"),
|
||||||
|
"title": title,
|
||||||
|
"artist": artist,
|
||||||
|
"album": album,
|
||||||
|
"length": (t.get("duration") or 0) * 1000,
|
||||||
|
"cover": (t.get("album") or {}).get("cover_medium"),
|
||||||
|
# filled in at request time from /track/{id}
|
||||||
|
"year": None, "track": None,
|
||||||
|
}))
|
||||||
|
|
||||||
|
seen, out = set(), []
|
||||||
|
for _, item in sorted(scored, key=lambda x: -x[0]):
|
||||||
|
key = (norm(item["artist"]), norm(item["title"]))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(item)
|
||||||
|
return {"results": out[:limit]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/request")
|
||||||
|
async def request_track(body: RequestBody):
|
||||||
|
artist, title = body.artist, body.title
|
||||||
|
album, year, track = body.album, body.year, body.track
|
||||||
|
|
||||||
|
# Deezer's search payload has no track number or release date, and both go
|
||||||
|
# into the filename and tags. One lookup here beats one per search result.
|
||||||
|
if body.deezer_id:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
r = await c.get(f"{DEEZER}/track/{body.deezer_id}")
|
||||||
|
if r.status_code == 200:
|
||||||
|
d = r.json()
|
||||||
|
track = d.get("track_position") or track
|
||||||
|
rd = d.get("release_date") or (d.get("album") or {}).get("release_date")
|
||||||
|
year = (rd or "")[:4] or year
|
||||||
|
album = (d.get("album") or {}).get("title") or album
|
||||||
|
artist = (d.get("artist") or {}).get("name") or artist
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("deezer track lookup failed: %s", e)
|
||||||
|
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
with db() as c:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO jobs (id,state,detail,artist,title,album,year,track,"
|
||||||
|
"recording_mbid,release_mbid) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(job_id, "searching", "", artist, title, album,
|
||||||
|
year, track, str(body.deezer_id or ""), None))
|
||||||
|
asyncio.create_task(pick_and_enqueue(job_id, artist, title))
|
||||||
|
return {"id": job_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/jobs")
|
||||||
|
async def jobs(limit: int = 50):
|
||||||
|
with db() as c:
|
||||||
|
rows = c.execute(
|
||||||
|
"SELECT * FROM jobs ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||||
|
(limit,)).fetchall()
|
||||||
|
return {"jobs": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
ok, detail = True, {}
|
||||||
|
async with slskd_client() as sc:
|
||||||
|
try:
|
||||||
|
r = await sc.get("/api/v0/application")
|
||||||
|
detail["slskd"] = r.status_code
|
||||||
|
ok = ok and r.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
detail["slskd"] = str(e)
|
||||||
|
ok = False
|
||||||
|
detail["library_writable"] = os.access(LIBRARY_DIR, os.W_OK)
|
||||||
|
detail["downloads_readable"] = os.access(DOWNLOAD_DIR, os.R_OK)
|
||||||
|
return {"ok": ok and detail["library_writable"], **detail}
|
||||||
|
|
||||||
|
|
||||||
|
STATIC = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index():
|
||||||
|
return FileResponse(STATIC / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
httpx==0.28.1
|
||||||
|
mutagen==1.47.0
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>muse</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0f1115; --panel:#171a21; --line:#252a34; --fg:#e7e9ee;
|
||||||
|
--dim:#8b93a3; --accent:#5b8cff; --ok:#3fb950; --err:#f85149; --warn:#d29922;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme:light){
|
||||||
|
:root{--bg:#f6f7f9;--panel:#fff;--line:#e2e5ea;--fg:#12151a;--dim:#5f6672;}
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--fg);
|
||||||
|
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
||||||
|
header{position:sticky;top:0;z-index:5;background:var(--bg);
|
||||||
|
border-bottom:1px solid var(--line);padding:14px 18px}
|
||||||
|
.wrap{max-width:900px;margin:0 auto}
|
||||||
|
h1{margin:0 0 10px;font-size:17px;letter-spacing:.2px}
|
||||||
|
h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:8px}
|
||||||
|
form{display:flex;gap:8px}
|
||||||
|
input{flex:1;padding:11px 13px;border-radius:9px;border:1px solid var(--line);
|
||||||
|
background:var(--panel);color:var(--fg);font-size:15px}
|
||||||
|
input:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
||||||
|
button{padding:11px 18px;border-radius:9px;border:0;background:var(--accent);
|
||||||
|
color:#fff;font-weight:600;cursor:pointer;font-size:15px}
|
||||||
|
button:disabled{opacity:.5;cursor:default}
|
||||||
|
main{padding:18px}
|
||||||
|
.row{display:flex;gap:12px;align-items:center;padding:10px;border-radius:10px;
|
||||||
|
border:1px solid var(--line);background:var(--panel);margin-bottom:8px}
|
||||||
|
.row img{width:48px;height:48px;border-radius:6px;object-fit:cover;
|
||||||
|
background:var(--line);flex:none}
|
||||||
|
.meta{flex:1;min-width:0}
|
||||||
|
.t{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.s{color:var(--dim);font-size:13px;white-space:nowrap;overflow:hidden;
|
||||||
|
text-overflow:ellipsis}
|
||||||
|
.get{background:transparent;border:1px solid var(--accent);color:var(--accent);
|
||||||
|
padding:7px 14px;font-size:14px}
|
||||||
|
.get.done{border-color:var(--ok);color:var(--ok)}
|
||||||
|
h2{font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);
|
||||||
|
margin:26px 0 10px}
|
||||||
|
.pill{font-size:12px;padding:2px 9px;border-radius:99px;border:1px solid var(--line);
|
||||||
|
color:var(--dim);white-space:nowrap}
|
||||||
|
.pill.imported{color:var(--ok);border-color:var(--ok)}
|
||||||
|
.pill.failed{color:var(--err);border-color:var(--err)}
|
||||||
|
.pill.downloading,.pill.searching,.pill.queued{color:var(--warn);border-color:var(--warn)}
|
||||||
|
.empty{color:var(--dim);padding:22px;text-align:center}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><div class="wrap">
|
||||||
|
<h1>muse <small>search a song → it lands in Navidrome</small></h1>
|
||||||
|
<form id="f">
|
||||||
|
<input id="q" placeholder="artist, song title…" autocomplete="off" autofocus>
|
||||||
|
<button id="go">Search</button>
|
||||||
|
</form>
|
||||||
|
</div></header>
|
||||||
|
|
||||||
|
<main class="wrap">
|
||||||
|
<div id="results"></div>
|
||||||
|
<h2>Requests</h2>
|
||||||
|
<div id="jobs"><div class="empty">nothing requested yet</div></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
const esc = s => (s??'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
|
||||||
|
$('#f').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = $('#q').value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
$('#go').disabled = true;
|
||||||
|
$('#results').innerHTML = '<div class="empty">searching MusicBrainz…</div>';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/search?q=' + encodeURIComponent(q));
|
||||||
|
const { results } = await r.json();
|
||||||
|
render(results);
|
||||||
|
} catch (err) {
|
||||||
|
$('#results').innerHTML = '<div class="empty">search failed: ' + esc(err.message) + '</div>';
|
||||||
|
}
|
||||||
|
$('#go').disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function render(list) {
|
||||||
|
if (!list || !list.length) {
|
||||||
|
$('#results').innerHTML = '<div class="empty">no matches</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#results').innerHTML = list.map((x, i) => `
|
||||||
|
<div class="row">
|
||||||
|
${x.cover ? `<img src="${esc(x.cover)}" loading="lazy" onerror="this.style.visibility='hidden'">`
|
||||||
|
: '<img>'}
|
||||||
|
<div class="meta">
|
||||||
|
<div class="t">${esc(x.title)}</div>
|
||||||
|
<div class="s">${esc(x.artist)}${x.album ? ' · ' + esc(x.album) : ''}${x.year ? ' · ' + esc(x.year) : ''}</div>
|
||||||
|
</div>
|
||||||
|
<button class="get" data-i="${i}">Get</button>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
$('#results').querySelectorAll('.get').forEach(b => {
|
||||||
|
b.onclick = async () => {
|
||||||
|
const x = list[+b.dataset.i];
|
||||||
|
b.disabled = true; b.textContent = '…';
|
||||||
|
await fetch('/api/request', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(x)
|
||||||
|
});
|
||||||
|
b.textContent = 'Queued'; b.classList.add('done');
|
||||||
|
poll();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const { jobs } = await (await fetch('/api/jobs')).json();
|
||||||
|
$('#jobs').innerHTML = jobs.length ? jobs.map(j => `
|
||||||
|
<div class="row">
|
||||||
|
<div class="meta">
|
||||||
|
<div class="t">${esc(j.title)}</div>
|
||||||
|
<div class="s">${esc(j.artist)}${j.detail ? ' · ' + esc(j.detail) : ''}</div>
|
||||||
|
</div>
|
||||||
|
<span class="pill ${esc(j.state)}">${esc(j.state)}</span>
|
||||||
|
</div>`).join('') : '<div class="empty">nothing requested yet</div>';
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
setInterval(poll, 5000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user