""" 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// ()/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 from urllib.parse import urlencode import httpx from fastapi import FastAPI, HTTPException, Query, Request from fastapi.responses import (FileResponse, HTMLResponse, 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 ─────────────────────────── # # OAuth against GitHub and/or Google, 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. # # WHO MAY LOG IN is two rules, checked in this order: # 1. MUSE_ALLOWED_USERS — the bootstrap allowlist. Always admitted. This is # how the very first admin gets in when the users table is empty, and it # is the recovery path if someone disables themselves. # 2. A row in `users` that is not disabled — how everyone invited later gets # in, without an env change or a redeploy. # Anyone matching neither is rejected and the attempt is logged. AUTH_MODE = os.getenv("MUSE_AUTH", "github").strip().lower() ENABLED_PROVIDERS = [p for p in (x.strip() for x in AUTH_MODE.split(",")) if p and p != "off"] # 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("/") ALLOWED_USERS = {u.strip().lower() for u in os.getenv("MUSE_ALLOWED_USERS", "").split(",") if u.strip()} 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" # One callback URL for every provider — the provider is carried in the state # COOKIE, not the query string, so adding Google needs no change to the GitHub # OAuth app that is already registered against /auth/callback. PROVIDERS: dict[str, dict] = { "github": { "label": "GitHub", "authorize": "https://github.com/login/oauth/authorize", "token": "https://github.com/login/oauth/access_token", "scope": "read:user", "client_id": os.getenv("MUSE_GITHUB_CLIENT_ID", "").strip(), "client_secret": os.getenv("MUSE_GITHUB_CLIENT_SECRET", "").strip(), "id_env": "MUSE_GITHUB_CLIENT_ID", "secret_env": "MUSE_GITHUB_CLIENT_SECRET", }, "google": { "label": "Google", "authorize": "https://accounts.google.com/o/oauth2/v2/auth", "token": "https://oauth2.googleapis.com/token", "scope": "openid email profile", "client_id": os.getenv("MUSE_GOOGLE_CLIENT_ID", "").strip(), "client_secret": os.getenv("MUSE_GOOGLE_CLIENT_SECRET", "").strip(), "id_env": "MUSE_GOOGLE_CLIENT_ID", "secret_env": "MUSE_GOOGLE_CLIENT_SECRET", }, } GH_API = "https://api.github.com/user" GOOGLE_API = "https://openidconnect.googleapis.com/v1/userinfo" # Paths reachable without a session. Deliberately tiny: /healthz reports a bare # ok so the container healthcheck works without leaking configuration. PUBLIC_PATHS = {"/healthz", "/auth/login", "/auth/callback", "/auth/logout"} ROLES = ("admin", "user", "guest") 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 not ENABLED_PROVIDERS: raise RuntimeError(f"MUSE_AUTH must list providers or be 'off', got {AUTH_MODE!r}") unknown = [p for p in ENABLED_PROVIDERS if p not in PROVIDERS] if unknown: raise RuntimeError( f"MUSE_AUTH names unknown provider(s) {unknown}. " f"Valid: {', '.join(PROVIDERS)}, or 'off'.") missing: list[str] = [] for p in ENABLED_PROVIDERS: cfg = PROVIDERS[p] if not cfg["client_id"]: missing.append(cfg["id_env"]) if not cfg["client_secret"]: missing.append(cfg["secret_env"]) if not BASE_URL: missing.append("MUSE_BASE_URL") # The bootstrap allowlist is only mandatory while nobody exists yet — # otherwise a working multi-user install would be held hostage by an env # var it has outgrown. But an EMPTY database and no allowlist means nobody # can ever log in, which is a misconfiguration worth refusing to start on. if not ALLOWED_USERS and not _any_users(): missing.append("MUSE_ALLOWED_USERS") if missing: raise RuntimeError( "Auth is enabled but " + ", ".join(sorted(set(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.") log.info("auth: providers=%s bootstrap_allowlist=%d", ",".join(ENABLED_PROVIDERS), len(ALLOWED_USERS)) def _any_users() -> bool: try: with db() as c: return c.execute("SELECT 1 FROM users LIMIT 1").fetchone() is not None except sqlite3.Error: return False 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 may_login(username: str, row: sqlite3.Row | None) -> bool: """The single authorization rule, used at login AND on every request. Bootstrap allowlist first, then a live, non-disabled user row. Deliberately one function: an authorization check that runs only at login is not a revocation mechanism, it is a delay — disabling someone would leave their session valid for up to MUSE_SESSION_DAYS. """ if username.lower() in ALLOWED_USERS: return True return bool(row) and not row["disabled"] def current_user(request: Request) -> sqlite3.Row | None: """The signed-in user's row, or None. Re-validated on every request.""" 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 u = c.execute("SELECT * FROM users WHERE username=?", (row["username"],)).fetchone() if not may_login(row["username"], u): return None if u is None: # Allowlisted but no row — an admin deleted the bootstrap account while # its session was still live. The allowlist is exactly the recovery # path for that, so put the row back rather than half-authenticating # them into a state where every role check fails. u = upsert_user(row["username"]) log.info("auth: restored deleted allowlisted user %s", row["username"]) return u def session_user(request: Request) -> str | None: u = current_user(request) return u["username"] if u else None def user_role(request: Request) -> str: u = current_user(request) if u: return u["role"] # With auth off there is nobody to be, so everyone is an admin — otherwise # the admin panel would be unreachable on a LAN-only install. return "admin" if AUTH_MODE == "off" else "guest" def require_admin(request: Request) -> str: if AUTH_MODE == "off": return "local" u = current_user(request) if not u: raise HTTPException(401, "unauthenticated") if u["role"] != "admin": log.warning("admin: DENIED %s (role=%s) -> %s", u["username"], u["role"], request.url.path) raise HTTPException(403, "admin only") return u["username"] SAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') NOISE = re.compile(r"[^a-z0-9]+") YEAR = re.compile(r"^\d{4}$") # Most filesystems cap a path component at 255 bytes. Track titles can be far # longer than that once a stranger has appended their own notes, and the # resulting ENAMETOOLONG would fail the import after the download had finished. MAX_COMPONENT = 150 def safe(name: str) -> str: """Filesystem-safe path component. Trailing dots/spaces break on some FS.""" return SAFE.sub("_", name).strip(". ")[:MAX_COMPONENT].strip() or "Unknown" def safe_cover(url) -> str | None: """Artwork URL, stored and later rendered as an . The CSP already stops a foreign host loading, so this is not an XSS gate — it stops the field being used as free storage. `data:` is permitted by img-src, so without a check any client could park megabytes of base64 in the jobs table on every request. """ u = str(url or "").strip() return u if u.startswith("https://") and len(u) <= 300 else None def safe_year(year) -> str | None: """A year, or nothing. This lands in a directory name, so it is checked against a whitelist rather than merely scrubbed.""" y = str(year or "").strip() return y if YEAR.match(y) else None 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"), ("confidence", "REAL"), ("match_why", "TEXT"), ("reviewed_by", "TEXT")): if col not in cols: c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}") ucols = {r["name"] for r in c.execute("PRAGMA table_info(users)").fetchall()} for col, decl in (("storage_quota", "INTEGER NOT NULL DEFAULT 0"), ("invited_by", "TEXT"), ("email", "TEXT"), ("notify", "INTEGER NOT NULL DEFAULT 1")): if col not in ucols: c.execute(f"ALTER TABLE users 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)") c.execute("CREATE INDEX IF NOT EXISTS jobs_by_created ON jobs (created_at)") def upsert_user(username: str, avatar: str = "", display: str = "", provider: str = "github") -> sqlite3.Row: """Record the sign-in. The first user to ever log in becomes admin — no bootstrap ceremony, no default password to forget. Everyone after that is a plain user until an admin says otherwise; role is never touched on subsequent logins, or every sign-in would silently undo a demotion. """ 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'), provider=excluded.provider, " " avatar=COALESCE(NULLIF(excluded.avatar,''), users.avatar), " " display=COALESCE(NULLIF(excluded.display,''), users.display)", (username, provider, "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(timeout: float = 30.0) -> httpx.AsyncClient: """30s suits searches and transfer polling, which are legitimately slow. Status probes pass something much shorter: they run on a UI poll, and a probe that outlasts its own poll interval stacks requests and leaves the admin panel on skeletons for the whole timeout — precisely when slskd is down and you opened the panel to find out why. """ return httpx.AsyncClient( base_url=SLSKD_URL, headers={"X-API-Key": SLSKD_API_KEY}, timeout=httpx.Timeout(timeout), ) # slskd is on the LAN, so a reachable instance answers in milliseconds. Long # enough to absorb a slow moment, short enough to stay under the 5s UI poll. PROBE_TIMEOUT = 4.0 def errstr(e: Exception) -> str: """httpx's timeout and connection errors often str() to an empty string, which would render as 'slskd unreachable — ' with nothing after it.""" return (str(e) or type(e).__name__)[:120] 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 confidence(filename: str, artist: str, title: str, album: str | None = None) -> tuple[float, list[str]]: """How sure are we this file is the song that was asked for, 0..1. Deliberately separate from score(). score() decides *which* candidate to fetch and is right to weigh peer speed and queue length; none of that says anything about whether the file is the correct recording. Mixing the two would let a fast peer raise the confidence of a wrong match. Returns the score and the human-readable reasons behind it, because a number alone gives the reviewer nothing to act on. """ base = filename.replace("\\", "/").rsplit("/", 1)[-1] stem = norm(base.rsplit(".", 1)[0]) path = norm(filename.replace("\\", "/")) na, nt = norm(artist), norm(title) why: list[str] = [] # Title is the load-bearing signal: a wrong title is a wrong song, whereas # a missing artist is routine in files organised into per-artist folders. t_ratio = difflib.SequenceMatcher(None, nt, stem).ratio() if nt and nt in stem: t_score, t_ratio = 1.0, max(t_ratio, 1.0) why.append("title matches") elif t_ratio >= 0.75: t_score = t_ratio why.append(f"title close ({t_ratio:.0%})") else: t_score = t_ratio why.append(f"title differs ({t_ratio:.0%})") # Artist may legitimately live in the path rather than the filename. if na and na in stem: a_score = 1.0 why.append("artist in filename") elif na and na in path: a_score = 0.9 why.append("artist in folder") elif na and set(na.split()) & set(path.split()): a_score = 0.5 why.append("artist partly matches") else: a_score = 0.0 why.append("artist not found in path") # 0.75/0.25 rather than an even split. A file named exactly right but # sitting in an unorganised folder is the commonest honest case there is, # and it has to land in the review band, not below it — 0.65 would have # binned it alongside genuinely wrong songs. c = 0.75 * t_score + 0.25 * a_score if album and norm(album) and norm(album) in path: c = min(1.0, c + 0.05) why.append("album in path") # A live or remix version of the right song is still the wrong file when # the request was for the studio recording, and that is exactly the case a # reviewer needs to see rather than have silently imported. So the penalty # caps confidence below AUTO instead of merely scaling it: "a human must # decide" is the whole meaning of the flag, and a perfect artist+title # match should not be able to auto-import past it. variants = [w for w in ("live", "remix", "cover", "karaoke", "instrumental", "acoustic", "demo", "edit", "radio edit") if re.search(rf"\b{w}\b", stem) and w not in nt] if variants: c = min(c * 0.8, AUTO_CONFIDENCE - 0.1) why.append(f"looks like a {variants[0]} version, which was not requested") return round(max(0.0, min(1.0, c)), 3), why # >= AUTO imports without asking. Below REVIEW the match is bad enough that a # human almost certainly rejects it, but it is still held rather than binned: # the file is already downloaded and only the reviewer knows if it is right. AUTO_CONFIDENCE = float(os.getenv("MUSE_AUTO_CONFIDENCE", "0.95")) REVIEW_CONFIDENCE = float(os.getenv("MUSE_REVIEW_CONFIDENCE", "0.70")) 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, album: str | None = None): """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 # Confidence is recorded now, while the candidate list is still in # hand, but it gates the IMPORT rather than the download. Holding the # transfer instead would mean the peer is often gone by the time # someone reviews it; this way the file is already on disk, approving # is instant, and rejecting is a delete. conf, why = confidence(f["filename"], artist, title, album) set_state(job_id, "downloading", f"from {peer['username']} (score {best_score:.0f})", username=peer["username"], remote_file=f["filename"], size=f["size"], confidence=conf, match_why="; ".join(why)) # ─────────────────────────── email ─────────────────────────── # # Optional and off unless SMTP_HOST is set. A homelab tool that cannot send # mail should still import music, so every failure here is logged and # swallowed — a notification is never worth failing an import over. SMTP_HOST = os.getenv("MUSE_SMTP_HOST", "").strip() SMTP_PORT = int(os.getenv("MUSE_SMTP_PORT", "587")) SMTP_USER = os.getenv("MUSE_SMTP_USER", "").strip() SMTP_PASS = os.getenv("MUSE_SMTP_PASS", "") SMTP_FROM = os.getenv("MUSE_SMTP_FROM", "").strip() or SMTP_USER SMTP_TLS = os.getenv("MUSE_SMTP_TLS", "starttls").strip().lower() MAIL_ON = bool(SMTP_HOST and SMTP_FROM) def _send_sync(to: str, subject: str, body: str): import smtplib from email.message import EmailMessage msg = EmailMessage() msg["From"], msg["To"], msg["Subject"] = SMTP_FROM, to, subject msg.set_content(body) if SMTP_TLS == "ssl": s = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=20) else: s = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) with s: if SMTP_TLS == "starttls": s.starttls() if SMTP_USER: s.login(SMTP_USER, SMTP_PASS) s.send_message(msg) async def send_mail(to: str, subject: str, body: str): """Fire-and-forget. smtplib is blocking, so it runs off the event loop — otherwise a slow relay would stall the import worker for every job.""" if not (MAIL_ON and to): return try: await asyncio.to_thread(_send_sync, to, subject, body) log.info("mail: sent %r to %s", subject, to) except Exception as e: log.warning("mail: sending to %s failed: %s", to, errstr(e)) def recipient(username: str | None) -> str | None: """Where to write to, and whether they want to hear from us at all.""" if not username: return None with db() as c: r = c.execute("SELECT email, notify, provider FROM users WHERE username=?", (username,)).fetchone() if not r or not r["notify"]: return None # A Google identity IS an email address, so there is nothing to configure # for those accounts unless they want mail somewhere else. if r["email"]: return r["email"] return username if r["provider"] == "google" and "@" in username else None async def notify_imported(job, dest): to = recipient(job["requested_by"]) if not to: return album = job["album"] or "Singles" if job["year"]: album += f" ({job['year']})" await send_mail( to, f"muse: {job['title']} is in your library", f"{job['artist']} — {job['title']}\n{album}\n\n" f"Filed as: {dest}\n\nIt should appear in Navidrome on the next scan.\n") async def notify_review(job): to = recipient(job["requested_by"]) if not to: return await send_mail( to, f"muse: {job['title']} needs a look", f"{job['artist']} — {job['title']}\n\n" f"The best match found was only {(job['confidence'] or 0):.0%} confident, " f"so it has not been added to your library yet.\n\n" f"Why: {job['match_why'] or 'unknown'}\n" f"File: {job['remote_file']}\n\n" f"Approve or reject it under Review in muse.\n") # ─────────────────────────── 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')}" year = safe_year(job["year"]) if year: album += f" ({year})" dest_dir = LIBRARY_DIR / safe(job["artist"]) / album num = f"{int(job['track']):02d} - " if job["track"] else "" dest = dest_dir / f"{num}{safe(job['title'])}{src.suffix.lower()}" # Belt and braces. Every component above is either safe()'d or matched # against a whitelist, but that is a property of five call sites that must # each stay correct; this is one check that cannot be forgotten. It exists # because `year` was for a while appended AFTER safe() ran, so a request # with year="../../../../tmp/x" wrote outside the library. root = LIBRARY_DIR.resolve() if not Path(os.path.normpath(dest)).resolve().is_relative_to(root): raise ValueError(f"refusing to write outside the library: {dest}") dest_dir.mkdir(parents=True, exist_ok=True) 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"], job["album"]) 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 # Record the real size, not the size the peer advertised — # storage quotas are computed from this column. try: actual = src.stat().st_size except OSError: actual = job["size"] conf = job["confidence"] if conf is not None and conf < AUTO_CONFIDENCE: # Hold it. The file stays in downloads, untagged and # unlinked, until someone approves or rejects it. set_state(job["id"], "review", f"{conf:.0%} confident — {job['match_why'] or ''}", size=actual) await notify_review(job) continue dest = tag_and_file(job, src) set_state(job["id"], "imported", str(dest), imported_path=str(dest), size=actual) await notify_imported(job, 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 first: the preflight consults the users table to decide whether # the bootstrap allowlist is still mandatory. init_db() auth_preflight() # raises and stops the container if misconfigured # 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 ", 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