The old page was hand-written HTML/CSS/JS. That was fine for a search box and
a list, but the roadmap here is multi-user, an admin dashboard with usage
graphs, and an import-review queue — all of which get painful in vanilla DOM
code. Moves to Next.js 16 / React 19 / Tailwind 4 / Framer Motion.
Built as a STATIC EXPORT served by FastAPI. Running `next start` alongside
uvicorn would add a Node runtime, a second process to supervise, and a second
thing that can be down while the other is up. There is no use for SSR — every
view is behind auth and renders from live JSON — so the deployment stays one
container, one process, one healthcheck. Image is 60MB; Node only exists in
the build stage.
Search now carries what a music UI needs:
- year and genre, grafted on from /album/{id} concurrently, deduplicated by
album and cached forever since a release date is immutable
- 30-second previews, so you can hear a track before committing a Soulseek
search to it. Not a library player — that is Navidrome's job.
- autocomplete over songs/albums/artists, debounced and aborted per keystroke
so a slow response to "rad" cannot land after "radiohead"
- album and artist pages, with `Get all` to queue a release in one request
- the accent colour is sampled from the artwork, so the palette follows the
music. Deezer's CDN sends ACAO:*, which is what makes canvas sampling work
with no image proxy.
/api/album/{id} deliberately calls Deezer twice: the track list embedded in
the album response has no track_position, and using the list index instead
misnumbers every multi-disc release — verified against the White Album, where
disc 2 track 1 would have been filed as 18.
Schema is multi-user ready ahead of the feature: users table with roles and
quotas, requested_by on jobs, and the first login becomes admin. Auth is still
the single-user GitHub allowlist.
CSP survives all of this intact. Next emits inline hydration scripts, which
script-src 'self' blocks; rather than per-request nonces, their SHA-256 is
computed at startup from the built page — a static export never varies between
requests, so hashes need no maintenance and no HTML rewriting. The one
concession is style-src-attr 'unsafe-inline', because Framer Motion writes its
pre-hydration state into style="" and without it every element flashes in.
That permits style attributes only. img-src/media-src now name Deezer's CDN
explicitly instead of allowing https: wholesale, closing an exfiltration
channel that survived connect-src 'self'. /_next/* sits behind the session
too — there is no local login page to style, so the bundle need not be public.
Verified: unauthenticated /→302 and every /api/*→401 including the four new
endpoints, forged state→400, forged cookie→401, misconfigured auth refuses to
start, and the production image hydrates with zero CSP violations.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1249 lines
52 KiB
Python
1249 lines
52 KiB
Python
"""
|
||
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 base64
|
||
import difflib
|
||
import hashlib
|
||
import logging
|
||
import os
|
||
import re
|
||
import secrets
|
||
import sqlite3
|
||
import uuid
|
||
from contextlib import asynccontextmanager
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
from fastapi import FastAPI, HTTPException, Request
|
||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||
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"))
|
||
|
||
# How many Soulseek searches may run at once.
|
||
#
|
||
# This is a hard cap, not a suggestion. Each search takes 10-40s of polling
|
||
# and real load on slskd and the Soulseek server, so firing one task per
|
||
# request — which is what an earlier version did — means pasting a 50-track
|
||
# list launches 50 concurrent searches and drowns the client. Everything goes
|
||
# through a bounded queue instead: requests return instantly, work drains at a
|
||
# civil rate.
|
||
WORKERS = int(os.getenv("MUSE_WORKERS", "3"))
|
||
|
||
# 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.
|
||
# Matched on WORD BOUNDARIES, not as substrings: a bare "live" is needed to
|
||
# catch "Come As You Are (Live)", but substring matching would also penalise
|
||
# "Deliver Me" and "Olive". Same trap for "demo" vs "Demolition".
|
||
#
|
||
# A term only counts against a result when the user did NOT type it, so asking
|
||
# for "oasis live forever" or "nirvana unplugged demo" still works.
|
||
DERIVATIVE = ("cover", "covers", "karaoke", "tribute", "remix", "remixed",
|
||
"instrumental", "acapella", "originally performed",
|
||
"made popular by", "in the style of", "demo", "rehearsal",
|
||
"live", "unplugged", "re recorded", "rerecorded",
|
||
"sped up", "slowed", "reverb", "8d", "nightcore")
|
||
DERIVATIVE_RE = re.compile(r"\b(" + "|".join(DERIVATIVE) + r")\b")
|
||
|
||
# ─────────────────────────── auth ───────────────────────────
|
||
#
|
||
# Single-user GitHub OAuth, built so this can face the internet.
|
||
#
|
||
# FAILS CLOSED. Auth is on unless MUSE_AUTH is explicitly set to "off", and if
|
||
# it is on with incomplete config the app refuses to start. The failure mode
|
||
# worth engineering against is not a broken login page — it is a misconfigured
|
||
# deploy quietly serving an unauthenticated download client to the internet.
|
||
# You should have to *mean* it to run without auth.
|
||
#
|
||
# Sessions are server-side: the cookie carries a 256-bit random token and
|
||
# nothing else, and the database stores only its SHA-256. There is no signed
|
||
# payload to get wrong, a stolen database yields no usable cookies, and logout
|
||
# and revocation actually revoke.
|
||
AUTH_MODE = os.getenv("MUSE_AUTH", "github").strip().lower()
|
||
GH_CLIENT_ID = os.getenv("MUSE_GITHUB_CLIENT_ID", "").strip()
|
||
GH_CLIENT_SECRET = os.getenv("MUSE_GITHUB_CLIENT_SECRET", "").strip()
|
||
ALLOWED_USERS = {u.strip().lower()
|
||
for u in os.getenv("MUSE_ALLOWED_USERS", "").split(",") if u.strip()}
|
||
# Absolute external URL, e.g. https://muse.aryan-singh.dev. Used to build the
|
||
# OAuth callback. Taken from config rather than from X-Forwarded-* headers on
|
||
# purpose: those are attacker-controllable and this is a redirect target.
|
||
BASE_URL = os.getenv("MUSE_BASE_URL", "").rstrip("/")
|
||
COOKIE_SECURE = os.getenv("MUSE_COOKIE_SECURE", "true").lower() != "false"
|
||
SESSION_DAYS = int(os.getenv("MUSE_SESSION_DAYS", "7"))
|
||
|
||
SESSION_COOKIE = "muse_session"
|
||
STATE_COOKIE = "muse_oauth_state"
|
||
GH_AUTH = "https://github.com/login/oauth/authorize"
|
||
GH_TOKEN = "https://github.com/login/oauth/access_token"
|
||
GH_API = "https://api.github.com/user"
|
||
|
||
# Paths reachable without a session. Deliberately tiny: /healthz reports a bare
|
||
# ok so the container healthcheck works without leaking configuration, and the
|
||
# static assets must load on the login page itself.
|
||
PUBLIC_PATHS = {"/healthz", "/auth/login", "/auth/callback", "/auth/logout"}
|
||
|
||
|
||
def auth_preflight():
|
||
if AUTH_MODE == "off":
|
||
log.warning("=" * 62)
|
||
log.warning("MUSE_AUTH=off — every endpoint is UNAUTHENTICATED.")
|
||
log.warning("Never expose this to the internet in this state.")
|
||
log.warning("=" * 62)
|
||
return
|
||
if AUTH_MODE != "github":
|
||
raise RuntimeError(f"MUSE_AUTH must be 'github' or 'off', got {AUTH_MODE!r}")
|
||
missing = [n for n, v in (
|
||
("MUSE_GITHUB_CLIENT_ID", GH_CLIENT_ID),
|
||
("MUSE_GITHUB_CLIENT_SECRET", GH_CLIENT_SECRET),
|
||
("MUSE_ALLOWED_USERS", ALLOWED_USERS),
|
||
("MUSE_BASE_URL", BASE_URL)) if not v]
|
||
if missing:
|
||
raise RuntimeError(
|
||
"GitHub auth is enabled but " + ", ".join(missing) + " not set. "
|
||
"Refusing to start: set them, or set MUSE_AUTH=off to run without "
|
||
"authentication (LAN only).")
|
||
if not BASE_URL.startswith("https://") and COOKIE_SECURE:
|
||
log.warning("MUSE_BASE_URL is not https but cookies are Secure — the "
|
||
"browser will refuse to send them. Set MUSE_COOKIE_SECURE=false "
|
||
"only if you are testing over plain http on a LAN.")
|
||
|
||
|
||
def _hash(tok: str) -> str:
|
||
return hashlib.sha256(tok.encode()).hexdigest()
|
||
|
||
|
||
def new_session(username: str) -> str:
|
||
token = secrets.token_urlsafe(32)
|
||
exp = datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)
|
||
with db() as c:
|
||
c.execute("INSERT INTO sessions (token_hash,username,expires_at) VALUES (?,?,?)",
|
||
(_hash(token), username, exp.isoformat()))
|
||
# Opportunistic sweep; no scheduler needed for a handful of rows.
|
||
c.execute("DELETE FROM sessions WHERE expires_at < ?",
|
||
(datetime.now(timezone.utc).isoformat(),))
|
||
return token
|
||
|
||
|
||
def session_user(request: Request) -> str | None:
|
||
tok = request.cookies.get(SESSION_COOKIE)
|
||
if not tok:
|
||
return None
|
||
with db() as c:
|
||
row = c.execute(
|
||
"SELECT username, expires_at FROM sessions WHERE token_hash=?",
|
||
(_hash(tok),)).fetchone()
|
||
if not row:
|
||
return None
|
||
try:
|
||
if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc):
|
||
return None
|
||
except ValueError:
|
||
return None
|
||
# Re-check the allowlist on every request, so removing someone from
|
||
# MUSE_ALLOWED_USERS locks them out immediately instead of whenever their
|
||
# session happens to expire. Same for the per-user disable flag: a
|
||
# revocation that only took effect on expiry would be a revocation in name
|
||
# only.
|
||
if row["username"].lower() not in ALLOWED_USERS:
|
||
return None
|
||
with db() as c:
|
||
u = c.execute("SELECT disabled FROM users WHERE username=?",
|
||
(row["username"],)).fetchone()
|
||
return None if u and u["disabled"] else row["username"]
|
||
|
||
|
||
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'))
|
||
)
|
||
""")
|
||
c.execute("""
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
token_hash TEXT PRIMARY KEY, -- sha256 of the cookie value
|
||
username TEXT NOT NULL,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
expires_at TEXT NOT NULL
|
||
)
|
||
""")
|
||
# Users exist as rows even while the allowlist is a single name in an
|
||
# env var. Roles and quotas are read from here from the start, so
|
||
# opening this up to other people later is an INSERT and a config
|
||
# change rather than a schema migration against live job history.
|
||
c.execute("""
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
username TEXT PRIMARY KEY COLLATE NOCASE,
|
||
provider TEXT NOT NULL DEFAULT 'github',
|
||
role TEXT NOT NULL DEFAULT 'user', -- admin|user|guest
|
||
display TEXT,
|
||
avatar TEXT,
|
||
quota INTEGER NOT NULL DEFAULT 0, -- requests/month, 0 = unlimited
|
||
disabled INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
last_seen TEXT
|
||
)
|
||
""")
|
||
# Added after the first release, so existing databases need it grafted
|
||
# on rather than recreated -- job history is the whole point of the file.
|
||
cols = {r["name"] for r in c.execute("PRAGMA table_info(jobs)").fetchall()}
|
||
for col, decl in (("requested_by", "TEXT"),
|
||
("cover", "TEXT"),
|
||
("genre", "TEXT"),
|
||
("duration", "INTEGER")):
|
||
if col not in cols:
|
||
c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}")
|
||
c.execute("CREATE INDEX IF NOT EXISTS jobs_by_user ON jobs (requested_by)")
|
||
c.execute("CREATE INDEX IF NOT EXISTS jobs_by_state ON jobs (state)")
|
||
|
||
|
||
def upsert_user(username: str, avatar: str = "", display: str = "") -> sqlite3.Row:
|
||
"""Record the sign-in. The first user to ever log in becomes admin --
|
||
there is no bootstrap ceremony and no default password to forget."""
|
||
with db() as c:
|
||
first = c.execute("SELECT COUNT(*) n FROM users").fetchone()["n"] == 0
|
||
c.execute(
|
||
"INSERT INTO users (username,provider,role,display,avatar,last_seen) "
|
||
"VALUES (?,?,?,?,?,datetime('now')) "
|
||
"ON CONFLICT(username) DO UPDATE SET "
|
||
" last_seen=datetime('now'), avatar=excluded.avatar, "
|
||
" display=COALESCE(NULLIF(excluded.display,''), users.display)",
|
||
(username, "github", "admin" if first else "user", display, avatar))
|
||
return c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||
|
||
|
||
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
|
||
|
||
|
||
# ─────────────────────────── search queue ───────────────────────────
|
||
|
||
work_q: "asyncio.Queue[str]" = asyncio.Queue()
|
||
|
||
|
||
async def search_worker(n: int):
|
||
"""Drain the search queue, one job at a time, WORKERS of these running."""
|
||
while True:
|
||
job_id = await work_q.get()
|
||
try:
|
||
with db() as c:
|
||
job = c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||
if job and job["state"] in ("queued", "searching"):
|
||
set_state(job_id, "searching", "")
|
||
await pick_and_enqueue(job_id, job["artist"], job["title"])
|
||
except Exception as e:
|
||
log.warning("worker %d: %s", n, e)
|
||
try:
|
||
set_state(job_id, "failed", str(e)[:200])
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
work_q.task_done()
|
||
|
||
|
||
async def import_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):
|
||
auth_preflight() # raises and stops the container if misconfigured
|
||
init_db()
|
||
|
||
# The queue lives in memory, so a restart mid-import would otherwise
|
||
# strand every unstarted job in 'queued' forever with nothing to pick it
|
||
# up. Anything not yet handed to slskd goes back on the queue.
|
||
with db() as c:
|
||
orphans = [r["id"] for r in c.execute(
|
||
"SELECT id FROM jobs WHERE state IN ('queued','searching')").fetchall()]
|
||
for jid in orphans:
|
||
work_q.put_nowait(jid)
|
||
if orphans:
|
||
log.info("requeued %d job(s) left over from the last run", len(orphans))
|
||
|
||
tasks = [asyncio.create_task(import_worker())]
|
||
tasks += [asyncio.create_task(search_worker(i)) for i in range(WORKERS)]
|
||
log.info("muse up — slskd=%s library=%s formats=%s workers=%d",
|
||
SLSKD_URL, LIBRARY_DIR, ",".join(FORMATS), WORKERS)
|
||
yield
|
||
for t in tasks:
|
||
t.cancel()
|
||
|
||
|
||
# ─────────────────────── static front end / CSP ───────────────────────
|
||
#
|
||
# The UI is a Next.js static export: `next build` writes plain files into
|
||
# web/out and this process serves them, so the deployment stays one container,
|
||
# one process, one healthcheck, with no Node runtime in the image.
|
||
#
|
||
# That export contains inline <script> blocks (React's hydration payload),
|
||
# which `script-src 'self'` blocks outright. The usual fix is a per-request
|
||
# nonce, but a STATIC export has a better option: the scripts never change
|
||
# between requests, so their SHA-256 can go straight into the policy. Hashing
|
||
# them at startup means the CSP needs no per-request work, no HTML rewriting,
|
||
# and no maintenance when the bundle changes -- rebuild and the hashes follow.
|
||
WEB = Path(__file__).parent / "web" / "out"
|
||
|
||
_INLINE_SCRIPT = re.compile(rb"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>", re.S)
|
||
|
||
|
||
def script_hashes() -> list[str]:
|
||
out: set[str] = set()
|
||
for page in ("index.html", "404.html"):
|
||
f = WEB / page
|
||
if not f.exists():
|
||
continue
|
||
for body in _INLINE_SCRIPT.findall(f.read_bytes()):
|
||
digest = base64.b64encode(hashlib.sha256(body).digest()).decode()
|
||
out.add(f"'sha256-{digest}'")
|
||
return sorted(out)
|
||
|
||
|
||
SCRIPT_HASHES = " ".join(script_hashes())
|
||
|
||
# Framer Motion writes its pre-hydration `initial` state into style="" on the
|
||
# server-rendered markup, so those attributes have to be allowed or every
|
||
# animated element flashes in at full opacity before hydration corrects it.
|
||
#
|
||
# style-src-attr is the narrow lever for that: it permits style ATTRIBUTES
|
||
# while style-src 'self' still blocks injected <style> blocks and remote
|
||
# stylesheets. It is not a free pass either -- CSS-based exfiltration goes
|
||
# through url(), and img-src below already limits where those can point.
|
||
CSP = (
|
||
"default-src 'self'; "
|
||
f"script-src 'self' {SCRIPT_HASHES}; "
|
||
"style-src 'self'; style-src-attr 'unsafe-inline'; "
|
||
"img-src 'self' data: https://*.dzcdn.net https://*.githubusercontent.com; "
|
||
"media-src 'self' https://*.dzcdn.net; "
|
||
"connect-src 'self'; font-src 'self'; object-src 'none'; "
|
||
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||
)
|
||
|
||
|
||
app = FastAPI(
|
||
title="muse",
|
||
lifespan=lifespan,
|
||
# No interactive docs on an internet-facing app: they enumerate every
|
||
# endpoint and schema for anyone who reaches the login page.
|
||
docs_url=None, redoc_url=None, openapi_url=None,
|
||
)
|
||
|
||
|
||
@app.middleware("http")
|
||
async def guard(request: Request, call_next):
|
||
"""Require a session for everything except PUBLIC_PATHS, and stamp
|
||
security headers on every response."""
|
||
# /_next/* is NOT public. Unlike the old hand-written page there is no
|
||
# local login screen to style — an unauthenticated visitor is redirected
|
||
# straight to GitHub and never receives our HTML — so the bundle can sit
|
||
# behind the session too, and not hand out a map of the app to anyone who
|
||
# finds the URL. It loads fine after the callback, cookie in hand.
|
||
path = request.url.path
|
||
if AUTH_MODE == "off" or path in PUBLIC_PATHS:
|
||
resp = await call_next(request)
|
||
elif session_user(request):
|
||
resp = await call_next(request)
|
||
elif path.startswith("/api/"):
|
||
# An API caller gets a status code, not an HTML redirect it can't use.
|
||
resp = JSONResponse({"error": "unauthenticated"}, status_code=401)
|
||
else:
|
||
resp = RedirectResponse("/auth/login", status_code=302)
|
||
|
||
# See CSP above. img-src and media-src name Deezer's CDN explicitly rather
|
||
# than allowing https: wholesale — under a blanket https: any injected
|
||
# markup could beacon to an arbitrary host by requesting an image from it,
|
||
# which is a working exfiltration channel even with connect-src locked to
|
||
# 'self'. Artwork comes from dzcdn.net and avatars from githubusercontent.
|
||
resp.headers["Content-Security-Policy"] = CSP
|
||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||
resp.headers["X-Frame-Options"] = "DENY"
|
||
resp.headers["Referrer-Policy"] = "no-referrer"
|
||
resp.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||
if COOKIE_SECURE:
|
||
resp.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||
return resp
|
||
|
||
|
||
def _set_cookie(resp, name, value, max_age):
|
||
resp.set_cookie(name, value, max_age=max_age, httponly=True,
|
||
secure=COOKIE_SECURE, samesite="lax", path="/")
|
||
|
||
|
||
@app.get("/healthz")
|
||
async def healthz():
|
||
"""Unauthenticated on purpose, for the container healthcheck. Returns
|
||
nothing but a literal ok — no versions, no config, no counts."""
|
||
return {"ok": True}
|
||
|
||
|
||
@app.get("/auth/login")
|
||
async def auth_login():
|
||
if AUTH_MODE == "off":
|
||
return RedirectResponse("/", status_code=302)
|
||
state = secrets.token_urlsafe(24)
|
||
url = (f"{GH_AUTH}?client_id={GH_CLIENT_ID}"
|
||
f"&redirect_uri={BASE_URL}/auth/callback"
|
||
f"&scope=read:user&state={state}&allow_signup=false")
|
||
resp = RedirectResponse(url, status_code=302)
|
||
# CSRF defence: the state we generated is echoed back by GitHub and must
|
||
# match this cookie, so a callback the user did not initiate is rejected.
|
||
_set_cookie(resp, STATE_COOKIE, state, 600)
|
||
return resp
|
||
|
||
|
||
@app.get("/auth/callback")
|
||
async def auth_callback(request: Request, code: str = "", state: str = ""):
|
||
if AUTH_MODE == "off":
|
||
return RedirectResponse("/", status_code=302)
|
||
|
||
expected = request.cookies.get(STATE_COOKIE) or ""
|
||
if not code or not state or not secrets.compare_digest(state, expected):
|
||
log.warning("auth: rejected callback with bad or missing state")
|
||
return JSONResponse({"error": "invalid state"}, status_code=400)
|
||
|
||
async with httpx.AsyncClient(timeout=20) as c:
|
||
t = await c.post(GH_TOKEN, headers={"Accept": "application/json"},
|
||
data={"client_id": GH_CLIENT_ID,
|
||
"client_secret": GH_CLIENT_SECRET,
|
||
"code": code,
|
||
"redirect_uri": f"{BASE_URL}/auth/callback"})
|
||
token = (t.json() or {}).get("access_token") if t.status_code == 200 else None
|
||
if not token:
|
||
log.warning("auth: token exchange failed (%s)", t.status_code)
|
||
return JSONResponse({"error": "token exchange failed"}, status_code=401)
|
||
|
||
u = await c.get(GH_API, headers={"Authorization": f"Bearer {token}",
|
||
"Accept": "application/vnd.github+json"})
|
||
if u.status_code != 200:
|
||
return JSONResponse({"error": "github user lookup failed"}, status_code=401)
|
||
profile = u.json() or {}
|
||
login = profile.get("login") or ""
|
||
|
||
if login.lower() not in ALLOWED_USERS:
|
||
# Log the attempt: on an internet-facing box this is the signal that
|
||
# someone found the login page.
|
||
log.warning("auth: DENIED github user %r (not in MUSE_ALLOWED_USERS)", login)
|
||
return JSONResponse({"error": "not authorised"}, status_code=403)
|
||
|
||
row = upsert_user(login, profile.get("avatar_url") or "",
|
||
profile.get("name") or "")
|
||
if row and row["disabled"]:
|
||
log.warning("auth: DENIED %r (account disabled)", login)
|
||
return JSONResponse({"error": "account disabled"}, status_code=403)
|
||
|
||
log.info("auth: signed in %s (%s)", login, row["role"] if row else "user")
|
||
resp = RedirectResponse("/", status_code=302)
|
||
_set_cookie(resp, SESSION_COOKIE, new_session(login), SESSION_DAYS * 86400)
|
||
resp.delete_cookie(STATE_COOKIE, path="/")
|
||
return resp
|
||
|
||
|
||
@app.get("/auth/logout")
|
||
async def auth_logout(request: Request):
|
||
tok = request.cookies.get(SESSION_COOKIE)
|
||
if tok:
|
||
with db() as c:
|
||
c.execute("DELETE FROM sessions WHERE token_hash=?", (_hash(tok),))
|
||
resp = RedirectResponse("/auth/login", status_code=302)
|
||
resp.delete_cookie(SESSION_COOKIE, path="/")
|
||
return resp
|
||
|
||
|
||
@app.get("/api/me")
|
||
async def me(request: Request):
|
||
who = session_user(request)
|
||
if not who:
|
||
return {"user": None, "auth": AUTH_MODE}
|
||
with db() as c:
|
||
u = c.execute("SELECT username,role,display,avatar,quota FROM users "
|
||
"WHERE username=?", (who,)).fetchone()
|
||
return {"user": who, "auth": AUTH_MODE, **(dict(u) if u else {"role": "user"})}
|
||
|
||
|
||
class RequestBody(BaseModel):
|
||
artist: str
|
||
title: str
|
||
album: str | None = None
|
||
year: str | None = None
|
||
track: int | None = None
|
||
deezer_id: int | None = None
|
||
cover: str | None = None
|
||
genre: str | None = None
|
||
length: int | None = None
|
||
|
||
|
||
# Album metadata (release year, genre, label) is not in the track search
|
||
# payload, so it costs one extra call per distinct album. Cached forever on
|
||
# purpose: a release date and a genre are immutable, so there is nothing to
|
||
# invalidate, and repeat searches for the same artist stop hitting Deezer at
|
||
# all. Trimmed only to stop it growing without bound on a long-lived process.
|
||
_album_cache: dict[int, dict] = {}
|
||
_album_lock = asyncio.Semaphore(6) # Deezer rate-limits ~50 req/5s per IP
|
||
|
||
|
||
async def album_meta(album_id) -> dict:
|
||
if not album_id:
|
||
return {}
|
||
key = int(album_id)
|
||
if key in _album_cache:
|
||
return _album_cache[key]
|
||
async with _album_lock:
|
||
if key in _album_cache: # filled while we waited for the slot
|
||
return _album_cache[key]
|
||
try:
|
||
async with httpx.AsyncClient(timeout=15) as c:
|
||
r = await c.get(f"{DEEZER}/album/{key}")
|
||
d = r.json() if r.status_code == 200 else {}
|
||
except Exception as e:
|
||
log.debug("album %s lookup failed: %s", key, e)
|
||
return {}
|
||
genres = [g.get("name") for g in (d.get("genres") or {}).get("data", []) if g.get("name")]
|
||
meta = {
|
||
"year": (d.get("release_date") or "")[:4] or None,
|
||
"genre": genres[0] if genres else None,
|
||
"genres": genres,
|
||
"label": d.get("label"),
|
||
"nb_tracks": d.get("nb_tracks"),
|
||
"record_type": d.get("record_type"),
|
||
}
|
||
if len(_album_cache) > 5000:
|
||
for k in list(_album_cache)[:1000]:
|
||
_album_cache.pop(k, None)
|
||
_album_cache[key] = meta
|
||
return meta
|
||
|
||
|
||
def track_row(t: dict) -> dict:
|
||
"""One Deezer track object -> the shape the UI and the job table use."""
|
||
artist = t.get("artist") or {}
|
||
album = t.get("album") or {}
|
||
return {
|
||
"deezer_id": t.get("id"),
|
||
"title": t.get("title") or "",
|
||
"artist": artist.get("name") or "",
|
||
"artist_id": artist.get("id"),
|
||
"artist_pic": artist.get("picture_medium"),
|
||
"album": album.get("title") or "",
|
||
"album_id": album.get("id"),
|
||
"cover": album.get("cover_medium"),
|
||
"cover_big": album.get("cover_big") or album.get("cover_medium"),
|
||
"length": (t.get("duration") or 0) * 1000,
|
||
# 30-second clip from Deezer's CDN. This is what lets you confirm you
|
||
# picked the right recording BEFORE committing a Soulseek search to it
|
||
# -- the single most useful thing in the payload for avoiding a wrong
|
||
# import, and it costs nothing extra.
|
||
"preview": t.get("preview") or None,
|
||
"explicit": bool(t.get("explicit_lyrics")),
|
||
"isrc": t.get("isrc"),
|
||
"rank": t.get("rank") or 0,
|
||
"year": None, "genre": None, "track": None,
|
||
}
|
||
|
||
|
||
async def add_album_meta(rows: list[dict]) -> list[dict]:
|
||
"""Fill year and genre on a result list, concurrently and de-duplicated."""
|
||
ids = {r["album_id"] for r in rows if r.get("album_id")}
|
||
if not ids:
|
||
return rows
|
||
metas = dict(zip(ids, await asyncio.gather(*(album_meta(i) for i in ids))))
|
||
for r in rows:
|
||
m = metas.get(r.get("album_id")) or {}
|
||
r["year"] = r["year"] or m.get("year")
|
||
r["genre"] = r["genre"] or m.get("genre")
|
||
return rows
|
||
|
||
|
||
async def deezer_search(q: str, limit: int = 25) -> list[dict]:
|
||
"""Ranked Deezer track search. Shared by /api/search and /api/import."""
|
||
if not q.strip():
|
||
return []
|
||
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 ""
|
||
|
||
s = float(t.get("rank") or 0) / 10_000 # Deezer popularity, 0-~100
|
||
|
||
# An artist match separates the real track from a covers compilation.
|
||
at = set(norm(artist).split())
|
||
if norm(artist) and norm(artist) in qn:
|
||
s += 300
|
||
|
||
# Title relevance. This has to dominate everything else: without it a
|
||
# completely different song by the right artist competes on equal
|
||
# footing, and "Oasis - Live Forever" resolved to "Cloudburst" because
|
||
# a variant penalty was enough to tip between two unrelated tracks.
|
||
# Whatever query words the artist name did not explain should show up
|
||
# in the title.
|
||
rest = set(qn.split()) - at
|
||
if rest:
|
||
s += 400 * len(rest & set(norm(title).split())) / len(rest)
|
||
for w in set(DERIVATIVE_RE.findall(norm(title))):
|
||
if not re.search(rf"\b{re.escape(w)}\b", qn):
|
||
s -= 150
|
||
# Catch-all for variants the keyword list will never cover in full
|
||
# ("Devonshire Mix", "Radio Edit", "2011 Version"): a plain title beats
|
||
# a parenthesised one. Deliberately small -- it should break ties
|
||
# between versions of the SAME song, never outweigh title relevance
|
||
# above and pick a different song.
|
||
if ("(" in title or "[" in title) and "(" not in q:
|
||
s -= 40
|
||
scored.append((s, track_row(t)))
|
||
|
||
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 out[:limit]
|
||
|
||
|
||
@app.get("/api/search")
|
||
async def search(q: str, limit: int = 25):
|
||
"""Track search. Returns individual songs, not albums — the whole point.
|
||
|
||
Year and genre are grafted on afterwards because Deezer's track search
|
||
omits both. Enrichment happens only here, not in deezer_search itself, so
|
||
the bulk-import path stays one HTTP call per line.
|
||
"""
|
||
return {"results": await add_album_meta(await deezer_search(q, limit))}
|
||
|
||
|
||
@app.get("/api/suggest")
|
||
async def suggest(q: str):
|
||
"""Autocomplete. Deliberately does NOT enrich or re-rank: this fires on
|
||
every few keystrokes, so it has to be one upstream call and nothing else.
|
||
Deezer's own autocomplete ordering is good enough for a dropdown, and the
|
||
real ranking still happens when the search is actually submitted."""
|
||
q = q.strip()
|
||
if len(q) < 2:
|
||
return {"tracks": [], "albums": [], "artists": []}
|
||
try:
|
||
async with httpx.AsyncClient(timeout=8) as c:
|
||
r = await c.get(f"{DEEZER}/search/autocomplete",
|
||
params={"q": q, "limit": 5})
|
||
d = r.json() if r.status_code == 200 else {}
|
||
except Exception:
|
||
return {"tracks": [], "albums": [], "artists": []}
|
||
|
||
def rows(key):
|
||
return (d.get(key) or {}).get("data", []) or []
|
||
|
||
return {
|
||
"tracks": [{"deezer_id": t.get("id"), "title": t.get("title"),
|
||
"artist": (t.get("artist") or {}).get("name"),
|
||
"cover": (t.get("album") or {}).get("cover_small"),
|
||
"preview": t.get("preview")}
|
||
for t in rows("tracks")[:5]],
|
||
"albums": [{"album_id": a.get("id"), "title": a.get("title"),
|
||
"artist": (a.get("artist") or {}).get("name"),
|
||
"cover": a.get("cover_small"),
|
||
"nb_tracks": a.get("nb_tracks")}
|
||
for a in rows("albums")[:3]],
|
||
"artists": [{"artist_id": a.get("id"), "name": a.get("name"),
|
||
"cover": a.get("picture_small"),
|
||
"nb_album": a.get("nb_album")}
|
||
for a in rows("artists")[:3]],
|
||
}
|
||
|
||
|
||
@app.get("/api/album/{album_id}")
|
||
async def album_detail(album_id: int):
|
||
"""Full track list for an album, so 'Get all' can queue it in one go."""
|
||
# Two calls, not one. /album/{id} embeds a track list, but those nested
|
||
# objects have no track_position -- and taking the list index instead would
|
||
# misnumber every multi-disc release, writing "13 - ..." for disc 2 track 1.
|
||
# /album/{id}/tracks is the only source that carries track_position and
|
||
# disk_number, and those numbers end up in the filename and the tags.
|
||
try:
|
||
async with httpx.AsyncClient(timeout=20) as c:
|
||
r, tr = await asyncio.gather(
|
||
c.get(f"{DEEZER}/album/{album_id}"),
|
||
c.get(f"{DEEZER}/album/{album_id}/tracks", params={"limit": 300}))
|
||
except Exception as e:
|
||
raise HTTPException(502, f"deezer: {e}")
|
||
if r.status_code != 200:
|
||
raise HTTPException(404, "album not found")
|
||
d = r.json()
|
||
|
||
artist = (d.get("artist") or {}).get("name") or ""
|
||
year = (d.get("release_date") or "")[:4] or None
|
||
genres = [g.get("name") for g in (d.get("genres") or {}).get("data", []) if g.get("name")]
|
||
raw = (tr.json().get("data", []) if tr.status_code == 200
|
||
else (d.get("tracks") or {}).get("data", []))
|
||
tracks = []
|
||
for t in raw:
|
||
row = track_row(t)
|
||
# These nested track objects carry no album of their own, so inherit
|
||
# what the parent already told us rather than looking it up again.
|
||
row.update(album=d.get("title") or "", album_id=d.get("id"),
|
||
cover=d.get("cover_medium"), cover_big=d.get("cover_big"),
|
||
artist=row["artist"] or artist, year=year,
|
||
genre=genres[0] if genres else None,
|
||
track=t.get("track_position"),
|
||
disc=t.get("disk_number"))
|
||
tracks.append(row)
|
||
|
||
return {"album": {"album_id": d.get("id"), "title": d.get("title"),
|
||
"artist": artist, "artist_id": (d.get("artist") or {}).get("id"),
|
||
"cover_big": d.get("cover_big"), "cover": d.get("cover_medium"),
|
||
"year": year, "genres": genres, "label": d.get("label"),
|
||
"nb_tracks": d.get("nb_tracks"),
|
||
"record_type": d.get("record_type"),
|
||
"duration": d.get("duration")},
|
||
"tracks": tracks}
|
||
|
||
|
||
@app.get("/api/artist/{artist_id}")
|
||
async def artist_detail(artist_id: int):
|
||
"""Artist header plus their top tracks and albums."""
|
||
try:
|
||
async with httpx.AsyncClient(timeout=20) as c:
|
||
info, top, albums = await asyncio.gather(
|
||
c.get(f"{DEEZER}/artist/{artist_id}"),
|
||
c.get(f"{DEEZER}/artist/{artist_id}/top", params={"limit": 15}),
|
||
c.get(f"{DEEZER}/artist/{artist_id}/albums", params={"limit": 24}))
|
||
except Exception as e:
|
||
raise HTTPException(502, f"deezer: {e}")
|
||
if info.status_code != 200:
|
||
raise HTTPException(404, "artist not found")
|
||
a = info.json()
|
||
tracks = [track_row(t) for t in (top.json().get("data", []) if top.status_code == 200 else [])]
|
||
await add_album_meta(tracks)
|
||
return {
|
||
"artist": {"artist_id": a.get("id"), "name": a.get("name"),
|
||
"picture": a.get("picture_big"), "nb_fan": a.get("nb_fan"),
|
||
"nb_album": a.get("nb_album")},
|
||
"tracks": tracks,
|
||
"albums": [{"album_id": x.get("id"), "title": x.get("title"),
|
||
"cover": x.get("cover_medium"), "cover_big": x.get("cover_big"),
|
||
"year": (x.get("release_date") or "")[:4] or None,
|
||
"record_type": x.get("record_type"),
|
||
"nb_tracks": x.get("nb_tracks")}
|
||
for x in (albums.json().get("data", []) if albums.status_code == 200 else [])],
|
||
}
|
||
|
||
|
||
async def enqueue_job(artist: str, title: str, album=None, year=None,
|
||
track=None, deezer_id=None, requested_by=None,
|
||
cover=None, genre=None, duration=None) -> str:
|
||
"""Create a job and hand it to the search queue."""
|
||
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,requested_by,cover,genre,duration) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(job_id, "queued", "", artist, title, album, year, track,
|
||
str(deezer_id or ""), None, requested_by, cover, genre, duration))
|
||
await work_q.put(job_id)
|
||
return job_id
|
||
|
||
|
||
async def enrich(deezer_id) -> dict:
|
||
"""Deezer's search payload has no track number or release date, and both
|
||
go into the filename and tags. One lookup per request, not per result."""
|
||
if not deezer_id:
|
||
return {}
|
||
try:
|
||
async with httpx.AsyncClient(timeout=15) as c:
|
||
r = await c.get(f"{DEEZER}/track/{deezer_id}")
|
||
if r.status_code != 200:
|
||
return {}
|
||
d = r.json()
|
||
rd = d.get("release_date") or (d.get("album") or {}).get("release_date")
|
||
return {
|
||
"track": d.get("track_position"),
|
||
"year": (rd or "")[:4] or None,
|
||
"album": (d.get("album") or {}).get("title"),
|
||
"artist": (d.get("artist") or {}).get("name"),
|
||
}
|
||
except Exception as e:
|
||
log.warning("deezer track lookup failed: %s", e)
|
||
return {}
|
||
|
||
|
||
@app.post("/api/request")
|
||
async def request_track(body: RequestBody, request: Request):
|
||
e = await enrich(body.deezer_id)
|
||
return {"id": await enqueue_job(
|
||
e.get("artist") or body.artist, body.title,
|
||
e.get("album") or body.album, e.get("year") or body.year,
|
||
e.get("track") or body.track, body.deezer_id,
|
||
requested_by=session_user(request), cover=body.cover,
|
||
genre=body.genre, duration=body.length)}
|
||
|
||
|
||
class BatchBody(BaseModel):
|
||
tracks: list[RequestBody]
|
||
|
||
|
||
@app.post("/api/request/batch")
|
||
async def request_batch(body: BatchBody, request: Request):
|
||
"""Queue a whole album at once.
|
||
|
||
Unlike /api/request this skips the per-track /track/{id} enrichment: the
|
||
caller got these rows from /api/album/{id}, which already carries the
|
||
album, year and track position for every one of them. Re-fetching would be
|
||
nb_tracks extra calls to learn what we were just told.
|
||
"""
|
||
who = session_user(request)
|
||
ids = [await enqueue_job(t.artist, t.title, t.album, t.year, t.track,
|
||
t.deezer_id, requested_by=who, cover=t.cover,
|
||
genre=t.genre, duration=t.length)
|
||
for t in body.tracks]
|
||
log.info("batch: queued %d track(s) for %s", len(ids), who or "anon")
|
||
return {"queued": len(ids), "ids": ids, "pending": work_q.qsize()}
|
||
|
||
|
||
class ImportBody(BaseModel):
|
||
text: str
|
||
|
||
|
||
# "Artist - Title", "Artist – Title" (en dash), "Title by Artist". Anything
|
||
# that doesn't split just goes to Deezer whole — its search copes fine with
|
||
# free text, and guessing wrong is worse than not guessing.
|
||
SPLIT = re.compile(r"\s+[-–—|]\s+|\s+by\s+", re.I)
|
||
DEEZER_URL = re.compile(r"deezer\.com/(?:[a-z]{2}/)?(album|playlist)/(\d+)")
|
||
|
||
|
||
async def expand_url(kind: str, ident: str) -> list[str]:
|
||
"""Turn a Deezer album/playlist URL into 'Artist - Title' lines."""
|
||
out = []
|
||
try:
|
||
async with httpx.AsyncClient(timeout=25) as c:
|
||
r = await c.get(f"{DEEZER}/{kind}/{ident}")
|
||
if r.status_code != 200:
|
||
return []
|
||
for t in (r.json().get("tracks") or {}).get("data", []):
|
||
a = (t.get("artist") or {}).get("name") or ""
|
||
out.append(f"{a} - {t.get('title')}" if a else t.get("title", ""))
|
||
except Exception as e:
|
||
log.warning("expanding %s/%s failed: %s", kind, ident, e)
|
||
return [x for x in out if x]
|
||
|
||
|
||
@app.post("/api/import")
|
||
async def import_list(body: ImportBody, request: Request):
|
||
"""Paste a list, get it all queued.
|
||
|
||
Each line is resolved against Deezer and the top-ranked hit is taken
|
||
automatically — there is no picker in bulk mode. Resolved artist/album are
|
||
recorded on every job so a wrong auto-pick is visible in the list rather
|
||
than silently landing the wrong song in the library.
|
||
"""
|
||
lines, queued, skipped = [], [], []
|
||
for raw in body.text.splitlines():
|
||
line = raw.strip().lstrip("-•*0123456789. )").strip()
|
||
if not line:
|
||
continue
|
||
m = DEEZER_URL.search(line)
|
||
lines.extend(await expand_url(*m.groups()) if m else [line])
|
||
|
||
for line in lines:
|
||
# Search the line as typed; the ranker already weights an artist-name
|
||
# match heavily, so "Artist - Title" needs no special handling.
|
||
try:
|
||
# Ask for a real candidate pool, not one row. deezer_search ranks
|
||
# what it is given, so limit=1 fetched two results and handed back
|
||
# the better of a demo and a rehearsal take while the studio
|
||
# version -- 6x the popularity -- sat further down the list unseen.
|
||
hits = await deezer_search(SPLIT.sub(" ", line), limit=10)
|
||
except Exception as e:
|
||
skipped.append({"line": line, "why": str(e)[:120]})
|
||
continue
|
||
if not hits:
|
||
skipped.append({"line": line, "why": "no match on Deezer"})
|
||
continue
|
||
h = hits[0]
|
||
e = await enrich(h["deezer_id"])
|
||
jid = await enqueue_job(
|
||
e.get("artist") or h["artist"], h["title"],
|
||
e.get("album") or h["album"], e.get("year"), e.get("track"),
|
||
h["deezer_id"], requested_by=session_user(request),
|
||
cover=h.get("cover"), duration=h.get("length"))
|
||
queued.append({"id": jid, "line": line, "cover": h.get("cover"),
|
||
"matched": f"{h['artist']} — {h['title']}"})
|
||
|
||
log.info("import: %d queued, %d skipped", len(queued), len(skipped))
|
||
return {"queued": queued, "skipped": skipped, "pending": work_q.qsize()}
|
||
|
||
|
||
@app.get("/api/jobs")
|
||
async def jobs(limit: int = 200):
|
||
with db() as c:
|
||
rows = c.execute(
|
||
"SELECT * FROM jobs ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||
(limit,)).fetchall()
|
||
counts = {r["state"]: r["n"] for r in c.execute(
|
||
"SELECT state, COUNT(*) n FROM jobs GROUP BY state").fetchall()}
|
||
return {"jobs": [dict(r) for r in rows], "counts": counts,
|
||
"pending": work_q.qsize()}
|
||
|
||
|
||
@app.post("/api/retry/{job_id}")
|
||
async def retry(job_id: str):
|
||
"""Put a failed job back on the queue. Soulseek failures are usually the
|
||
peer going offline, not the request being wrong, so retrying later often
|
||
just works."""
|
||
with db() as c:
|
||
row = c.execute("SELECT id FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||
if not row:
|
||
raise HTTPException(404, "no such job")
|
||
set_state(job_id, "queued", "")
|
||
await work_q.put(job_id)
|
||
return {"ok": True}
|
||
|
||
|
||
@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}
|
||
|
||
|
||
@app.get("/")
|
||
async def index():
|
||
f = WEB / "index.html"
|
||
if not f.exists():
|
||
raise HTTPException(500, "UI not built — run `npm run build` in web/")
|
||
return FileResponse(f)
|
||
|
||
|
||
# Next.js emits its immutable, content-hashed chunks under /_next/static.
|
||
#
|
||
# Guarded rather than mounted unconditionally so that importing this module
|
||
# does not require build artifacts: CI typechecks the API before the UI has
|
||
# been built, and tests should not have to run npm first. A deploy that really
|
||
# is missing its UI says so at startup and again on the first request, rather
|
||
# than silently serving 404s.
|
||
if (WEB / "_next").is_dir():
|
||
app.mount("/_next", StaticFiles(directory=WEB / "_next"), name="next")
|
||
else:
|
||
log.warning("web/out/_next is missing — the UI was not built. "
|
||
"API endpoints work; the web page will not.")
|