Files
muse/app.py
T
Aryan SinghandClaude Opus 5 c781193ec9 Count the peers actually tried, not the attempt counter
A job carried over from before the candidate ladder has attempts=0 while
carrying a real failure in `tried`, so the summary rendered as "tried 0 peers:
1x no bytes in 20m" -- a count of zero next to the evidence contradicting it.
The live instance produced exactly that when the stall check finally cleared
its long-stuck job.

Counts distinct usernames in `tried` instead. The two agree for anything this
version enqueued; the list is the one that is right in both cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:44:36 -05:00

2933 lines
125 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 collections
import difflib
import hashlib
import json
import logging
import os
import re
import secrets
import sqlite3
import unicodedata
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, Response)
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"))
# The title alone must clear this before the artist is even consulted. Without
# a floor of its own, a file by the right artist scored well enough on the
# combined ratio to be fetched no matter which of their songs it actually was.
TITLE_FLOOR = float(os.getenv("MUSE_TITLE_FLOOR", "0.5"))
# How many ranked candidates to keep per job. When a transfer fails, the next
# one is tried instead of failing the request -- "File not shared." is a
# per-file condition, so candidate two usually just works.
CANDIDATES = int(os.getenv("MUSE_CANDIDATES", "8"))
MAX_ATTEMPTS = int(os.getenv("MUSE_MAX_ATTEMPTS", "5"))
# A download that has moved no bytes for this long is not going to. The peer
# queued it and vanished, or slskd lost the transfer; either way the job used
# to sit in `downloading` forever behind a cheerful pulsing dot.
STALL_MINUTES = float(os.getenv("MUSE_STALL_MINUTES", "20"))
# Query variants to try before giving up. Each costs a real Soulseek search,
# so this escalates only when a search underdelivers -- see search_queries().
MAX_QUERIES = int(os.getenv("MUSE_MAX_QUERIES", "4"))
MIN_CANDIDATES = int(os.getenv("MUSE_MIN_CANDIDATES", "3"))
# Issuing a search is a POST that occasionally blips. Nine production failures
# were one slskd hiccup during a bulk import, every queued job dying inside the
# same second, each one permanently.
SEARCH_RETRIES = int(os.getenv("MUSE_SEARCH_RETRIES", "3"))
# Soulseek results trickle in: what you have at t=5s is much worse than t=15s.
SEARCH_POLLS = int(os.getenv("MUSE_SEARCH_POLLS", "20"))
# 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",
"/login.css"}
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,
provider: str | None = 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.
An invite is bound to the provider it was issued for. Invite
friend@gmail.com for Google and Google is the only way in for that
identity; there is no second, weaker door onto the same account. `provider`
is None on the per-request re-check, where the question is only whether the
account is still live.
"""
if username.lower() in ALLOWED_USERS:
return True
if not row or row["disabled"]:
return False
return provider is None or row["provider"] == provider
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}$")
# Apostrophes vanish rather than becoming a separator, so "Don't" normalises
# onto a filename's "Dont". Everything else non-alphanumeric becomes a space,
# so "R&B" stays two tokens instead of collapsing to the unrelated word "rb".
QUOTES = re.compile("['\u2018\u2019\u02bc\u0060\"\u201c\u201d]")
# Letters NFKD leaves alone because they are atomic rather than base + accent.
FOLD = str.maketrans({"\u00f8": "o", "\u00e6": "ae", "\u0153": "oe",
"\u00f0": "d", "\u00fe": "th", "\u0142": "l",
"\u00df": "ss", "\u0111": "d", "\u0131": "i"})
# A leading track number: "02 - ", "02.", "A1 ". Anchored and bounded so it
# cannot eat a title that merely opens with a digit ("1979", "99 Problems").
TRACKNO = re.compile(r"^\s*(?:[a-d]?\d{1,3})\s*(?:[-._)\]]\s*)+", re.I)
# Technical release tags. Deliberately NOT "live", "remix" or "acoustic":
# confidence() reads those to decide whether this is the recording that was
# asked for, and stripping them here would blind it.
JUNK = re.compile(
r"[\[({]\s*(?:\d{2,4}\s*k(?:bps)?|v\d|web|cd\s*\d*|vinyl|flac|mp3|"
r"lossless|hi-?res|\d{2,3}\s*bit|\d{2}\s*khz)[^\])}]*[\])}]"
r"|\b(?:\d{3}\s*kbps|web-?dl|cdrip|vbr|cbr)\b", re.I)
# 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 <img src>.
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:
"""Lowercase, fold accents away, and reduce to alphanumeric tokens.
The accent fold is load-bearing. Deezer returns "Beyonc\u00e9", "Bj\u00f6rk" and
"Sigur R\u00f3s"; the strangers who named the files on Soulseek wrote
"Beyonce", "Bjork", "Sigur Ros". Without it the accented character is not
in [a-z0-9] and became a *space*, so "beyonce" was compared against
"beyonc " -- a containment miss and a mediocre ratio, on a track that was
sitting right there in the results.
"""
s = unicodedata.normalize("NFKD", str(s).lower().translate(FOLD))
s = "".join(c for c in s if not unicodedata.combining(c))
return NOISE.sub(" ", QUOTES.sub("", s)).strip()
def bare_name(remote_path: str) -> str:
"""A remote filename with extension, track number and release tags gone.
All of that is decoration a peer added that says nothing about which
recording the file holds. Leaving it in drags the similarity ratio down on
files that are in fact exactly right.
"""
base = remote_path.replace("\\", "/").rsplit("/", 1)[-1]
if "." in base:
base = base.rsplit(".", 1)[0]
return JUNK.sub(" ", TRACKNO.sub("", base)).strip()
def similarity(want: str, got: str) -> float:
"""Best of a sequence ratio and a token-coverage ratio, 0..1.
difflib alone is order-sensitive: "creep radiohead" against "radiohead
creep" scores far below what a human would call a match, and Soulseek
filenames put the fields in whatever order the ripper felt like. The token
measure is order-free, so taking the better of the two absorbs reordering
and interleaved junk without having to loosen MIN_MATCH.
"""
if not want or not got:
return 0.0
seq = difflib.SequenceMatcher(None, want, got).ratio()
a, b = set(want.split()), set(got.split())
return max(seq, len(a & b) / len(a) if a else 0.0)
def match_ratio(remote_path: str, artist: str, title: str) -> float:
"""How well a remote path matches the track asked for, 0..1.
Title and artist are measured separately and the title has to stand on its
own. A single ratio over the blob "artist title" let a strong artist match
carry a weak title -- the same failure the Deezer ranking keeps a dedicated
term for, where `Oasis - Live Forever` once resolved to `Cloudburst`
because the artist agreed and nothing else had to.
The artist is looked for in the whole path rather than the filename: a file
at `Radiohead/Pablo Honey/02 Creep.flac` names its artist in the folder,
which is the commonest well-organised case there is.
"""
stem = norm(bare_name(remote_path))
path = norm(remote_path.replace("\\", "/"))
nt, na = norm(title), norm(artist)
t = 1.0 if (nt and nt in stem) else similarity(nt, stem)
if t < TITLE_FLOOR:
return 0.0 # wrong song; a matching artist must not rescue it
a = 1.0 if (na and na in path) else similarity(na, path)
return 0.75 * t + 0.25 * a
# ─────────────────────────── 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, -- queued|searching|downloading|review|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"),
# The candidate ladder. Keeping the runners-up on the
# row is what lets a rejected transfer fall through to
# the next peer minutes later, in a different task,
# after the search result set is long gone.
("candidates", "TEXT"),
("tried", "TEXT"),
("attempts", "INTEGER NOT NULL DEFAULT 0"),
("started_at", "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 is NOT refreshed from the login. It is part of the
# identity an admin invited, and may_login now enforces it — so
# overwriting it here would let a successful login silently
# rewrite the very rule that permitted it.
" 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.
"""
stem = norm(bare_name(filename))
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 = similarity(nt, stem)
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, artist: str, title: str) -> float | None:
"""Rank one remote file. None = reject.
Ordering intent: actually-gettable first, then correct format, then
filename plausibility, then bitrate.
Availability leads deliberately. It used to sit *below* format -- 100
points per format rank against 60 for a free upload slot -- which meant a
FLAC behind two hundred queued people always beat an MP3 available right
now. Whether the peer can serve you is the single most predictive signal
for whether the job ever finishes, so it outranks the preference for a
better file. Format is the tiebreak within an availability tier.
"""
ext = ext_of(candidate["filename"])
if ext not in FORMATS:
return None
ratio = match_ratio(candidate["filename"], artist, title)
if ratio < MIN_MATCH:
return None
s = 0.0
s += 220 if peer.get("hasFreeUploadSlot") else 0
# Unsaturated: the old `max(0, 40 - queueLength)` scored a 40-deep queue
# and a 5000-deep one identically, which is the difference between waiting
# a minute and never being served at all.
s += 120 / (1 + peer.get("queueLength", 0) / 8)
s += min(peer.get("uploadSpeed", 0) / 50_000, 20)
s += (len(FORMATS) - FORMATS.index(ext)) * 60 # format preference
s += ratio * 90
s += min(candidate.get("bitRate") or 0, 320) / 32 # 0-10
return s
PAREN = re.compile(r"[\[(\{][^\])}]*[\])}]")
FEAT = re.compile(r"\b(?:feat|ft|featuring)\b\.?.*$", re.I)
ARTICLE = re.compile(r"^(?:the|a|an|los|las|les)\s+", re.I)
class SearchUnavailable(Exception):
"""slskd could not accept a search. Transient, not a bad request."""
def search_queries(artist: str, title: str) -> list[str]:
"""Query variants to try, loosest last.
The Soulseek server filters searches containing certain artist names. That
is measured, not theorised: on this network "Michael Jackson Billie Jean"
and bare "Michael Jackson" both return zero responses, while "Billie Jean"
returns 1083 files. Sending only `artist title` -- all this used to do --
means every request for a filtered artist dies reporting "searched 0
files", and that is exactly the roster of names in the failed jobs:
Michael Jackson, The Weeknd, Kanye West, Frank Ocean.
So the ladder ends at the title alone, and match_ratio() then recognises
the artist from the folder path instead of from the query. The middle
rungs handle the ordinary case where Deezer's metadata is simply more
decorated than the filename a stranger typed.
"""
out: list[str] = []
seen: set[str] = set()
def add(*parts: str) -> None:
q = " ".join(p for p in parts if p).strip()
# Dedupe on the normalised form, so "Creep (Remastered)" and "Creep"
# are recognised as one query once the punctuation is gone.
k = norm(q)
if q and k and k not in seen:
seen.add(k)
out.append(q)
plain_t = PAREN.sub(" ", title).strip() or title
lean_t = FEAT.sub(" ", plain_t).strip() or plain_t
head_t = lean_t.split("/")[0].strip() or lean_t # "A / B" -> "A"
lean_a = ARTICLE.sub("", FEAT.sub(" ", artist).strip()).strip()
add(artist, title) # what was sent before, and still best when it works
add(artist, lean_t) # decoration and featuring credits gone
add(lean_a, head_t) # leading article gone, split title
add(head_t) # title alone -- this is the rung that beats the filter
add(lean_t)
return out[:MAX_QUERIES]
async def run_search(sc: httpx.AsyncClient, text: str) -> dict:
"""Issue one Soulseek search and poll it to completion.
The search is deleted only once it has actually finished. Deleting one
still InProgress makes slskd throw an optimistic-concurrency error inside
its own SearchService and lose the record; the old code deleted
unconditionally after its 40s budget, so every slow search corrupted a row.
"""
search_id = str(uuid.uuid4())
for attempt in range(SEARCH_RETRIES):
try:
r = await sc.post("/api/v0/searches",
json={"id": search_id, "searchText": text})
r.raise_for_status()
break
except Exception as e:
if attempt == SEARCH_RETRIES - 1:
raise SearchUnavailable(errstr(e)) from e
await asyncio.sleep(2 * (attempt + 1))
results: dict = {}
finished = False
for _ in range(SEARCH_POLLS):
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 as e:
# Swallowed silently before, which made a broken slskd look
# identical to a track nobody happens to share.
log.debug("poll %r: %s", text, errstr(e))
continue
if str(results.get("state", "")).startswith("Completed"):
finished = True
break
if finished:
try:
await sc.delete(f"/api/v0/searches/{search_id}")
except Exception as e:
log.debug("cleanup %r: %s", text, errstr(e))
else:
log.info("search %r unfinished after %ds, leaving it for slskd to close",
text, SEARCH_POLLS * 2)
return results
def spread_by_peer(ranked: list[tuple[float, dict, dict]]) -> list[dict]:
"""Order candidates so consecutive attempts land on different peers.
Straight score order routinely stacks three files from one well-stocked
user at the top; if that user is offline all three attempts burn on the
same dead connection. Peers arrive best-first, so round-robining them keeps
the strongest candidate first and still guarantees variety behind it.
"""
by_peer: dict[str, list[dict]] = {}
for s, peer, f in ranked:
by_peer.setdefault(peer["username"], []).append({
"username": peer["username"],
"filename": f["filename"],
"size": f.get("size") or 0,
"score": round(s, 1),
})
queues = list(by_peer.values())
out: list[dict] = []
while queues:
for q in list(queues):
out.append(q.pop(0))
if not q:
queues.remove(q)
return out
def no_candidate_detail(seen: int, stats: "collections.Counter",
queries: list[str]) -> str:
"""Say which filter emptied the results, not merely that they are empty.
One message used to cover four unrelated situations, and 28 of 29 real
occurrences turned out to be the search itself coming back empty -- which
is now stated outright instead of being reported as a matching failure.
"""
if seen == 0:
tried = ", ".join(repr(q) for q in queries)
word = "y" if len(queries) == 1 else "ies"
return f"Soulseek returned nothing for {len(queries)} quer{word}: {tried}"
bits = []
if stats["format"]:
bits.append(f"{stats['format']} not {'/'.join(FORMATS)}")
if stats["weak"]:
bits.append(f"{stats['weak']} did not match the title")
if stats["locked"]:
bits.append(f"{stats['locked']} locked")
return f"{seen} files found, none usable: " + ", ".join(bits)
def exhausted_detail(tried: list[dict], attempts: int) -> str:
"""Summarise the whole ladder instead of overwriting it with the last error.
Counts `tried`, not `attempts`. They agree for anything this version
enqueued, but a row carried over from before the ladder existed has
attempts=0 with a real failure recorded against it, which rendered as the
nonsense "tried 0 peers: 1x no bytes in 20m". The list is the evidence.
"""
if not tried:
return "no candidate could be enqueued"
n_peers = len({t.get("username") for t in tried if t.get("username")}) or len(tried)
reasons = collections.Counter(t["reason"].split(":")[0] for t in tried)
plural = "" if n_peers == 1 else "s"
joined = ", ".join(f"{n}x {r}" for r, n in reasons.most_common(3))
return f"tried {n_peers} peer{plural}: {joined}"
async def try_next_candidate(job_id: str, artist: str, title: str,
album: str | None = None) -> bool:
"""Enqueue the next-best candidate. False once the ladder is exhausted.
This is the difference between a request that survives one bad peer and one
that does not. "Transfer rejected: File not shared." is a *per-file*
condition -- the peer's share index is stale -- so candidate two almost
always works, and slskd's 500 on an unreachable peer is the same shape.
"""
with db() as c:
row = c.execute(
"SELECT candidates, tried, attempts FROM jobs WHERE id=?",
(job_id,)).fetchone()
if not row:
return False
cands = json.loads(row["candidates"] or "[]")
tried = json.loads(row["tried"] or "[]")
attempts = row["attempts"] or 0
while cands and attempts < MAX_ATTEMPTS:
cand = cands.pop(0)
attempts += 1
try:
async with slskd_client() as sc:
er = await sc.post(
f"/api/v0/transfers/downloads/{cand['username']}",
json=[{"filename": cand["filename"],
"size": cand["size"]}])
er.raise_for_status()
except Exception as e:
tried.append({"username": cand["username"],
"filename": cand["filename"],
"reason": f"could not reach peer: {errstr(e)}"})
continue
conf, why = confidence(cand["filename"], artist, title, album)
with db() as c:
c.execute(
"UPDATE jobs SET candidates=?, tried=?, attempts=? WHERE id=?",
(json.dumps(cands), json.dumps(tried), attempts, job_id))
extra = f", attempt {attempts}" if attempts > 1 else ""
set_state(job_id, "downloading",
f"from {cand['username']} (score {cand['score']:.0f}{extra})",
username=cand["username"], remote_file=cand["filename"],
size=cand["size"], confidence=conf, match_why="; ".join(why),
started_at=datetime.now(timezone.utc).isoformat())
return True
with db() as c:
c.execute("UPDATE jobs SET candidates=?, tried=?, attempts=? WHERE id=?",
(json.dumps(cands), json.dumps(tried), attempts, job_id))
set_state(job_id, "failed", exhausted_detail(tried, attempts))
return False
async def pick_and_enqueue(job_id: str, artist: str, title: str,
album: str | None = None):
"""Search Soulseek for one track and queue the best candidate.
Builds a ranked candidate *list* rather than a single winner, so a peer
that rejects, times out or cannot be reached costs one attempt instead of
the entire request.
"""
queries = search_queries(artist, title)
pool: dict[tuple[str, str], tuple[float, dict, dict]] = {}
seen = 0
stats: "collections.Counter" = collections.Counter()
async with slskd_client() as sc:
for i, q in enumerate(queries):
try:
results = await run_search(sc, q)
except SearchUnavailable as e:
set_state(job_id, "failed",
f"slskd would not accept a search: {e}")
return
for peer in results.get("responses", []):
for f in peer.get("files", []):
seen += 1
if f.get("isLocked"):
stats["locked"] += 1
continue
if ext_of(f["filename"]) not in FORMATS:
stats["format"] += 1
continue
sc_ = score(f, peer, artist, title)
if sc_ is None:
stats["weak"] += 1
continue
key = (peer["username"], f["filename"])
if key not in pool or pool[key][0] < sc_:
pool[key] = (sc_, peer, f)
log.info("job %s q%d/%d %r -> %d responses, %d files, %d candidates",
job_id[:8], i + 1, len(queries), q,
len(results.get("responses", [])),
results.get("fileCount", 0), len(pool))
# Escalate only on underdelivery: the common case stays one search,
# which is what keeps a 50-track paste civil to slskd.
if len(pool) >= MIN_CANDIDATES:
break
if not pool:
set_state(job_id, "failed", no_candidate_detail(seen, stats, queries))
return
ranked = sorted(pool.values(), key=lambda r: -r[0])
cands = spread_by_peer(ranked)[:CANDIDATES]
with db() as c:
c.execute("UPDATE jobs SET candidates=?, tried=?, attempts=0 WHERE id=?",
(json.dumps(cands), "[]", job_id))
await try_next_candidate(job_id, artist, title, album)
# ─────────────────────────── 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_invited(row: sqlite3.Row, by: str, to: str):
"""Tell someone they've been invited.
There is no token in this link and none is needed. The row already names
the exact identity that may sign in, and Google will not hand us an
unverified address — so possession of the email is not what authorises
anyone, and a leaked link grants nothing. It is a courtesy, not a
credential, which is also why it can be resent freely.
"""
label = PROVIDERS.get(row["provider"], {}).get("label", row["provider"])
where = BASE_URL or "your muse instance"
await send_mail(
to, f"{by} invited you to muse",
f"You've been given access to muse — request a song, and it turns up\n"
f"in the music library, correctly tagged.\n\n"
f" {where}\n\n"
f"Sign in with {label}, using the account for {row['username']}.\n"
f"That address is what authorises you, so it has to be that one.\n\n"
f"Playback lives in Navidrome, not here — muse only decides what gets\n"
f"added to the library.\n")
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 downloaded_index() -> dict[str, Path]:
"""Every completed file, by basename.
slskd nests a download under the remote's parent directory name, so the
basename is the only reliable key. Built once per poll cycle and shared by
every job in it: the previous version walked the whole tree separately for
each in-flight job, every ten seconds, which on a large downloads
directory is a lot of stat() calls to answer the same question.
"""
out: dict[str, Path] = {}
try:
for p in DOWNLOAD_DIR.rglob("*"):
if p.is_file():
out.setdefault(p.name, p)
except OSError as e:
log.warning("cannot walk %s: %s", DOWNLOAD_DIR, errstr(e))
return out
# ─────────────────────────── 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()
def transfer_index(payload: list) -> dict[str, dict]:
"""Flatten slskd's user -> directories -> files nesting.
Keeps the whole file object, not just its state. `exception` is where the
literal "Transfer rejected: File not shared." lives, and discarding it was
why a failed job could only ever say "transfer Completed, Rejected" with no
reason a person could act on.
"""
out: dict[str, dict] = {}
for user in payload:
for d in user.get("directories", []):
for f in d.get("files", []):
out[f.get("filename", "")] = f
return out
def stalled_for(job: sqlite3.Row) -> float:
"""Minutes since this transfer last had anything to show for itself.
Falls back to created_at, which is what makes this work on rows written
before started_at existed. Those are `downloading` with no clock at all,
so returning zero for them would exempt precisely the jobs that have been
stuck the longest -- the ones this check exists to catch.
"""
keys = job.keys()
started = ((job["started_at"] if "started_at" in keys else None)
or (job["created_at"] if "created_at" in keys else None))
if not started:
return 0.0
try:
t = datetime.fromisoformat(started)
except ValueError:
return 0.0
if t.tzinfo is None:
t = t.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - t).total_seconds() / 60.0
async def abandon(job: sqlite3.Row, reason: str) -> None:
"""Give up on the current peer and fall through to the next candidate."""
with db() as c:
row = c.execute("SELECT tried FROM jobs WHERE id=?",
(job["id"],)).fetchone()
tried = json.loads((row["tried"] if row else None) or "[]")
tried.append({"username": job["username"], "filename": job["remote_file"],
"reason": reason})
with db() as c:
c.execute("UPDATE jobs SET tried=? WHERE id=?",
(json.dumps(tried), job["id"]))
log.info("job %s giving up on %s: %s", job["id"][:8], job["username"], reason)
await try_next_candidate(job["id"], job["artist"], job["title"], job["album"])
async def cancel_transfer(username: str, filename: str) -> None:
"""Ask slskd to drop a transfer we have stopped waiting for."""
try:
async with slskd_client(timeout=PROBE_TIMEOUT) as sc:
await sc.delete(f"/api/v0/transfers/downloads/{username}",
params={"filename": filename})
except Exception as e:
log.debug("cancel %s/%s: %s", username, filename, errstr(e))
async def import_worker():
"""Poll slskd for in-flight downloads and import the finished ones."""
missing: collections.Counter = collections.Counter()
while True:
await asyncio.sleep(10)
try:
with db() as c:
jobs = c.execute(
"SELECT * FROM jobs WHERE state='downloading'").fetchall()
if not jobs:
missing.clear()
continue
async with slskd_client() as sc:
r = await sc.get("/api/v0/transfers/downloads")
r.raise_for_status()
index = transfer_index(r.json())
# One directory walk per cycle, shared by every job. This used to be
# a full recursive rglob per stuck job, every ten seconds, forever.
done_files: dict[str, Path] | None = None
for job in jobs:
t = index.get(job["remote_file"])
st = str((t or {}).get("state", ""))
if "Completed, Succeeded" in st:
if done_files is None:
done_files = downloaded_index()
src = done_files.get(
job["remote_file"].replace("\\", "/").rsplit("/", 1)[-1])
if not src:
# Normally just still being moved out of incomplete, but
# unbounded before: a misconfigured DOWNLOAD_DIR meant
# polling until the heat death of the process.
if stalled_for(job) > STALL_MINUTES:
set_state(job["id"], "failed",
"downloaded but the file never appeared in "
f"{DOWNLOAD_DIR} -- check MUSE_DOWNLOAD_DIR")
continue
try:
actual = src.stat().st_size
except OSError:
actual = job["size"]
conf = job["confidence"]
if conf is not None and conf < AUTO_CONFIDENCE:
band = ("almost certainly wrong"
if conf < REVIEW_CONFIDENCE else "worth a look")
set_state(job["id"], "review",
f"{conf:.0%} confident, {band} -- "
f"{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:
# Every terminal non-success used to end the request here.
# "File not shared." is per-file and per-peer, so the only
# sane response is to try the next candidate.
why = (t or {}).get("exception") or st
await abandon(job, str(why)[:120])
elif t is None:
# Absent from slskd entirely: it restarted, or the transfer
# was cleared. Tolerate a couple of polls in case it is a
# race, then stop waiting for something nobody is doing.
missing[job["id"]] += 1
if missing[job["id"]] >= 3:
del missing[job["id"]]
await abandon(job, "slskd forgot the transfer")
else:
missing.pop(job["id"], None)
# Queued behind a wall, or connected and sending nothing.
# There was no clock on this at all, which is how a job sat
# in `downloading` for days behind a cheerful pulsing dot.
if not (t or {}).get("bytesTransferred") \
and stalled_for(job) > STALL_MINUTES:
place = (t or {}).get("placeInQueue")
where = f" at #{place} in the queue" if place else ""
await cancel_transfer(job["username"], job["remote_file"])
await abandon(job, f"no bytes in {STALL_MINUTES:.0f}m{where}")
except Exception as e:
log.warning("import worker: %s", errstr(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 <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}
# The sign-in page is the first screen anyone sees, and for a while it was the
# only one built out of hardcoded hex with no relation to the rest of the app.
# It cannot use the Next bundle -- /_next/* sits behind the session on purpose,
# so an unauthenticated visitor never receives it -- so it gets one small
# stylesheet of its own, served from a public path. `style-src 'self'` in the
# CSP permits exactly that; an inline <style> block would be refused.
LOGIN_CSS = """
:root{
--bg:#0a0b0f; --surface:#121319; --surface-2:#181a22;
--fg:#f4f6fb; --dim:#a0a8bb; --mute:#6d7488;
--edge:rgb(255 255 255/.09); --edge-hi:rgb(255 255 255/.16);
--accent:#0a84ff; --accent-ink:#fff;
--e2:0 2px 6px rgb(0 0 0/.34), 0 12px 32px rgb(0 0 0/.26);
color-scheme:light dark;
}
@media (prefers-color-scheme:light){
:root{
--bg:#f6f7f9; --surface:#fff; --surface-2:#f2f3f6;
--fg:#0d1117; --dim:#596273; --mute:#858da0;
--edge:rgb(10 14 25/.1); --edge-hi:rgb(10 14 25/.18);
--accent:#0071e3;
--e2:0 2px 6px rgb(20 30 60/.07), 0 12px 32px rgb(20 30 60/.08);
}
}
*{box-sizing:border-box}
body{
margin:0; min-height:100dvh; display:grid; place-items:center; padding:24px;
background:var(--bg); color:var(--fg);
font:15px/1.55 -apple-system,BlinkMacSystemFont,"SF Pro Text",ui-sans-serif,
system-ui,"Segoe UI",Roboto,sans-serif;
letter-spacing:-.008em; -webkit-font-smoothing:antialiased;
}
.card{
width:100%; max-width:352px; padding:32px 28px; text-align:center;
background:var(--surface); border:1px solid var(--edge);
border-radius:18px; box-shadow:var(--e2);
}
.mark{
width:44px; height:44px; margin:0 auto 18px; display:grid; place-items:center;
background:var(--accent); border-radius:12px; color:var(--accent-ink);
}
h1{
margin:0; font-size:23px; font-weight:700; letter-spacing:-.024em; line-height:1.18;
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display",ui-sans-serif,system-ui,sans-serif;
}
p.sub{margin:6px 0 24px; font-size:13px; color:var(--dim)}
a.btn{
display:flex; align-items:center; justify-content:center; gap:9px;
margin:8px 0; padding:11px 18px; border-radius:10px; text-decoration:none;
background:var(--surface-2); border:1px solid var(--edge-hi);
color:var(--fg); font-size:13px; font-weight:600;
transition:border-color .15s, background .15s, transform .1s;
}
a.btn:hover{border-color:var(--accent); background:var(--bg)}
a.btn:active{transform:scale(.985)}
a.btn:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
p.foot{margin:22px 0 0; font-size:11px; color:var(--mute)}
"""
CHOOSER = """<!doctype html><html lang=en><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<meta name=theme-color media="(prefers-color-scheme: light)" content="#f6f7f9">
<meta name=theme-color media="(prefers-color-scheme: dark)" content="#0a0b0f">
<title>Sign in &middot; muse</title>
<link rel=stylesheet href="/login.css"></head>
<body><main class=card>
<div class=mark><svg width=22 height=22 viewBox="0 0 24 24" fill=none
stroke=currentColor stroke-width=2 stroke-linecap=round stroke-linejoin=round
aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/></svg></div>
<h1>muse</h1>
<p class=sub>Request a song, get it in your library.</p>
{buttons}
<p class=foot>You&rsquo;ll be sent to your provider to sign in.</p>
</main></body></html>"""
_BTN = ('<a class=btn href="/auth/login?provider={p}">Continue with {label}</a>')
@app.get("/login.css")
async def login_css():
"""The sign-in page's stylesheet. Public, because it is needed by someone
who by definition has no session yet."""
return Response(LOGIN_CSS, media_type="text/css",
headers={"Cache-Control": "public, max-age=3600"})
@app.get("/auth/login")
async def auth_login(provider: str = ""):
"""Start OAuth. With several providers enabled and none named, offer a
choice — a tiny self-contained page that uses only style attributes, which
the CSP already permits, so it needs no stylesheet and no extra hash."""
if AUTH_MODE == "off":
return RedirectResponse("/", status_code=302)
if not provider:
if len(ENABLED_PROVIDERS) == 1:
provider = ENABLED_PROVIDERS[0]
else:
buttons = "".join(
_BTN.format(p=p, label=PROVIDERS[p]["label"]) for p in ENABLED_PROVIDERS)
return HTMLResponse(CHOOSER.replace("{buttons}", buttons))
if provider not in ENABLED_PROVIDERS:
raise HTTPException(404, "unknown provider")
cfg = PROVIDERS[provider]
nonce = secrets.token_urlsafe(24)
params = {
"client_id": cfg["client_id"],
"redirect_uri": f"{BASE_URL}/auth/callback",
"scope": cfg["scope"],
"state": nonce,
"response_type": "code",
}
if provider == "github":
params["allow_signup"] = "false"
if provider == "google":
# Force the account chooser; otherwise Google silently reuses whichever
# account the browser last used, which on a shared machine signs you in
# as someone else with no visible prompt.
params["prompt"] = "select_account"
resp = RedirectResponse(f"{cfg['authorize']}?{urlencode(params)}", status_code=302)
# CSRF defence: the nonce is echoed back by the provider and must match
# this cookie, so a callback the user did not initiate is rejected. The
# provider name rides along in the COOKIE, never the query string, so the
# callback cannot be steered to a different provider by the redirect.
_set_cookie(resp, STATE_COOKIE, f"{provider}|{nonce}", 600)
return resp
async def fetch_identity(provider: str, token: str) -> tuple[str, str, str]:
"""(username, display, avatar) for a provider access token."""
async with httpx.AsyncClient(timeout=20) as c:
if provider == "github":
r = await c.get(GH_API, headers={"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json"})
if r.status_code != 200:
raise HTTPException(401, "github user lookup failed")
p = r.json() or {}
return p.get("login") or "", p.get("name") or "", p.get("avatar_url") or ""
r = await c.get(GOOGLE_API, headers={"Authorization": f"Bearer {token}"})
if r.status_code != 200:
raise HTTPException(401, "google user lookup failed")
p = r.json() or {}
# Unverified Google emails must never become an identity: anyone can
# put someone else's address on an account they control, and the
# allowlist is keyed on exactly this string.
if not p.get("email") or not p.get("email_verified"):
raise HTTPException(403, "google account has no verified email")
return p["email"], p.get("name") or "", p.get("picture") or ""
@app.get("/auth/callback")
async def auth_callback(request: Request, code: str = "", state: str = ""):
if AUTH_MODE == "off":
return RedirectResponse("/", status_code=302)
cookie = request.cookies.get(STATE_COOKIE) or ""
provider, _, nonce = cookie.partition("|")
if (not code or not state or not nonce
or provider not in ENABLED_PROVIDERS
or not secrets.compare_digest(state, nonce)):
log.warning("auth: rejected callback with bad or missing state")
return JSONResponse({"error": "invalid state"}, status_code=400)
cfg = PROVIDERS[provider]
async with httpx.AsyncClient(timeout=20) as c:
t = await c.post(cfg["token"], headers={"Accept": "application/json"},
data={"client_id": cfg["client_id"],
"client_secret": cfg["client_secret"],
"code": code,
"grant_type": "authorization_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: %s token exchange failed (%s)", provider, t.status_code)
return JSONResponse({"error": "token exchange failed"}, status_code=401)
username, display, avatar = await fetch_identity(provider, token)
if not username:
return JSONResponse({"error": "no username from provider"}, status_code=401)
with db() as c:
existing = c.execute("SELECT * FROM users WHERE username=?",
(username,)).fetchone()
if not may_login(username, existing, provider):
# Log the attempt: on an internet-facing box this is the signal that
# someone found the login page.
if not existing:
why = "not invited"
elif existing["disabled"]:
why = "disabled"
else:
why = f"invited for {existing['provider']}, tried {provider}"
log.warning("auth: DENIED %s user %r (%s)", provider, username, why)
return JSONResponse({"error": "not authorised"}, status_code=403)
row = upsert_user(username, avatar, display, provider)
log.info("auth: signed in %s via %s (%s)", username, provider, row["role"])
resp = RedirectResponse("/", status_code=302)
_set_cookie(resp, SESSION_COOKIE, new_session(username), 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
# ─────────────────────────── quotas ───────────────────────────
#
# Two limits, both per user, both 0 = unlimited:
# quota requests per CALENDAR month (resets on the 1st)
# storage_quota bytes of imported audio attributable to this user
#
# Calendar month rather than a rolling 30-day window on purpose: "you have 12
# left until the 1st" is something a person can reason about, where a rolling
# window silently drips capacity back at times nobody can predict.
def month_start() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-01 00:00:00")
def usage_for(username: str | None) -> dict:
"""Requests this month and bytes imported, for one user."""
if not username:
return {"requests": 0, "storage": 0}
with db() as c:
n = c.execute(
"SELECT COUNT(*) n FROM jobs WHERE requested_by=? AND created_at >= ?",
(username, month_start())).fetchone()["n"]
b = c.execute(
"SELECT COALESCE(SUM(size),0) b FROM jobs "
"WHERE requested_by=? AND state='imported'", (username,)).fetchone()["b"]
return {"requests": n, "storage": b}
def check_quota(request: Request, adding: int = 1):
"""Raise 429 if this request would exceed the caller's monthly allowance.
Admins are exempt — someone has to be able to fix the library when a quota
is what is in the way.
"""
u = current_user(request)
if not u or u["role"] == "admin":
return
used = usage_for(u["username"])
if u["quota"] and used["requests"] + adding > u["quota"]:
raise HTTPException(429, (
f"Monthly request quota reached ({used['requests']}/{u['quota']}). "
f"Resets on the 1st."))
if u["storage_quota"] and used["storage"] >= u["storage_quota"]:
raise HTTPException(429, (
f"Storage quota reached ({used['storage'] // 2**30} GB of "
f"{u['storage_quota'] // 2**30} GB)."))
@app.get("/api/me")
async def me(request: Request):
u = current_user(request)
if not u:
return {"user": None, "auth": AUTH_MODE,
"role": "admin" if AUTH_MODE == "off" else None,
"providers": ENABLED_PROVIDERS}
return {
"user": u["username"], "auth": AUTH_MODE, "role": u["role"],
"display": u["display"], "avatar": u["avatar"], "provider": u["provider"],
"quota": u["quota"], "storage_quota": u["storage_quota"],
"usage": usage_for(u["username"]),
"providers": ENABLED_PROVIDERS,
"email": u["email"], "notify": bool(u["notify"]),
"mail_configured": MAIL_ON,
}
class SettingsBody(BaseModel):
email: str | None = None
notify: bool | None = None
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s.]+\.[^@\s]+$")
@app.patch("/api/me")
async def update_me(body: SettingsBody, request: Request):
"""Your own notification settings. Not an admin route on purpose — an
address you cannot change yourself is a support ticket."""
me = session_user(request)
if not me:
raise HTTPException(401, "unauthenticated")
sets, vals = [], []
if body.email is not None:
e = body.email.strip()
if e and not EMAIL_RE.match(e):
raise HTTPException(400, "that does not look like an email address")
sets.append("email=?"); vals.append(e or None)
if body.notify is not None:
sets.append("notify=?"); vals.append(1 if body.notify else 0)
if not sets:
raise HTTPException(400, "nothing to change")
with db() as c:
c.execute(f"UPDATE users SET {', '.join(sets)} WHERE username=?",
[*vals, me])
return {"ok": True, "mail_configured": MAIL_ON}
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})
# Deezer's WAF answers 403 to anything that looks like an injection
# attempt -- "'; DROP TABLE" and friends. That is a statement about the
# query, not an upstream outage, so it belongs on the empty-results
# path; a 502 would tell the user the service is broken when it is not.
if r.status_code == 403:
log.info("deezer refused the query %r", q[:60])
return []
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 = Query(25, ge=1, le=50)):
"""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())
# The single choke point for all three request paths, so normalise here.
# year reaches a directory name, and artist/title/album reach filenames.
year = safe_year(year)
cover = safe_cover(cover)
artist, title = artist[:MAX_COMPONENT], title[:MAX_COMPONENT]
album = album[:MAX_COMPONENT] if album else album
genre = genre[:80] if genre else genre
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):
check_quota(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.
"""
# Checked against the whole batch, not one track: queueing a 30-track album
# when 5 requests remain should be refused up front, not accepted and then
# blocked halfway through.
check_quota(request, adding=len(body.tracks))
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+)")
# Generous for a human pasting an album or a year-end list, small enough that
# one request cannot turn into thousands of upstream calls.
MAX_IMPORT_LINES = 500
MAX_IMPORT_TRACKS = 1000
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 = [], [], []
# Capped before expansion, not after. Each URL line costs an upstream call,
# and the quota check below cannot run until expansion has finished — so
# without a cap here a paste of 10,000 album URLs is 10,000 requests to
# Deezer from this IP before anyone's quota is consulted.
raw_lines = body.text.splitlines()
if len(raw_lines) > MAX_IMPORT_LINES:
raise HTTPException(413, f"paste at most {MAX_IMPORT_LINES} lines at a time "
f"(got {len(raw_lines)})")
for raw in raw_lines:
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])
if len(lines) > MAX_IMPORT_TRACKS:
raise HTTPException(413, f"that expands to more than "
f"{MAX_IMPORT_TRACKS} tracks")
# After expansion — a single playlist URL can be 200 tracks, and the point
# of the check is the real number, not the number of lines pasted.
check_quota(request, adding=len(lines))
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": errstr(e)})
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()}
class ReviewBody(BaseModel):
action: str # approve | reject
@app.get("/api/review")
async def review_queue(request: Request):
"""Downloads held back because the match was not convincing enough.
Scoped like /api/jobs: your own unless you are an admin, because a held
file is a record of what someone asked for.
"""
me = session_user(request)
admin = user_role(request) == "admin"
with db() as c:
if admin:
rows = c.execute(
"SELECT * FROM jobs WHERE state='review' ORDER BY created_at DESC"
).fetchall()
else:
rows = c.execute(
"SELECT * FROM jobs WHERE state='review' AND requested_by IS ? "
"ORDER BY created_at DESC", (me,)).fetchall()
return {"items": [{
"id": r["id"], "artist": r["artist"], "title": r["title"],
"album": r["album"], "year": r["year"], "cover": r["cover"],
"confidence": r["confidence"], "why": r["match_why"],
"file": (r["remote_file"] or "").replace("\\", "/").rsplit("/", 1)[-1],
"path": r["remote_file"], "size": r["size"],
"requested_by": r["requested_by"], "created_at": r["created_at"],
} for r in rows], "thresholds": {"auto": AUTO_CONFIDENCE,
"review": REVIEW_CONFIDENCE}}
@app.post("/api/review/{job_id}")
async def review_decide(job_id: str, body: ReviewBody, request: Request):
me = session_user(request)
admin = user_role(request) == "admin"
if body.action not in ("approve", "reject"):
raise HTTPException(400, "action must be approve or reject")
with db() as c:
job = c.execute("SELECT * FROM jobs WHERE id=? AND state='review'",
(job_id,)).fetchone()
# 404 rather than 403 for someone else's job: a distinct error would
# confirm the id exists, same as /api/retry.
if not job or (not admin and job["requested_by"] != me):
raise HTTPException(404, "no such review item")
base = (job["remote_file"] or "").replace("\\", "/").rsplit("/", 1)[-1]
src = downloaded_index().get(base)
if body.action == "reject":
if src:
try:
src.unlink()
except OSError as e:
log.warning("review: could not delete %s: %s", src, e)
set_state(job_id, "failed", "rejected in review", reviewed_by=me)
return {"ok": True, "state": "failed"}
if not src:
raise HTTPException(409, "the downloaded file is no longer on disk")
dest = tag_and_file(job, src)
set_state(job_id, "imported", str(dest), imported_path=str(dest),
reviewed_by=me)
log.info("review: %s approved %s", me, job_id[:8])
return {"ok": True, "state": "imported", "path": str(dest)}
@app.get("/api/discover")
async def discover(request: Request):
"""What to listen to next, derived from what you have actually asked for.
Deliberately built on muse's own request history plus Deezer's related
artists, not on listening data — Navidrome owns play counts and history,
and duplicating them here would mean two disagreeing sources of truth.
"""
me = session_user(request)
with db() as c:
top = c.execute(
"SELECT artist, COUNT(*) n FROM jobs "
"WHERE requested_by IS ? AND state='imported' "
"GROUP BY artist COLLATE NOCASE ORDER BY n DESC LIMIT 5",
(me,)).fetchall()
if not top:
return {"seeds": [], "artists": [], "tracks": []}
async def related(name: str) -> list[dict]:
try:
async with httpx.AsyncClient(timeout=15) as c:
s = await c.get(f"{DEEZER}/search/artist",
params={"q": name, "limit": 1})
hits = (s.json() or {}).get("data") or []
if not hits:
return []
r = await c.get(f"{DEEZER}/artist/{hits[0]['id']}/related",
params={"limit": 6})
return (r.json() or {}).get("data") or []
except Exception as e:
log.info("discover: related for %r failed: %s", name, errstr(e))
return []
seeds = [r["artist"] for r in top]
groups = await asyncio.gather(*(related(s) for s in seeds))
have = {norm(r["artist"]) for r in top}
seen, artists = set(), []
for g in groups:
for a in g:
k = norm(a.get("name") or "")
if not k or k in have or k in seen:
continue
seen.add(k)
artists.append({"id": a.get("id"), "name": a.get("name"),
"picture": a.get("picture_medium"),
"fans": a.get("nb_fan")})
artists.sort(key=lambda a: -(a["fans"] or 0))
return {"seeds": seeds, "artists": artists[:18]}
@app.get("/api/recap")
async def recap(request: Request, days: int = Query(365, ge=7, le=3650)):
"""Your year in muse — from request history, which muse does own."""
me = session_user(request)
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat(" ")[:19]
with db() as c:
base = ("FROM jobs WHERE requested_by IS ? AND created_at >= ? "
"AND state='imported'")
total = c.execute(f"SELECT COUNT(*) n, COALESCE(SUM(size),0) b {base}",
(me, since)).fetchone()
artists = c.execute(
f"SELECT artist, COUNT(*) n {base} GROUP BY artist COLLATE NOCASE "
"ORDER BY n DESC LIMIT 10", (me, since)).fetchall()
genres = c.execute(
f"SELECT genre, COUNT(*) n {base} AND genre IS NOT NULL AND genre<>'' "
"GROUP BY genre ORDER BY n DESC LIMIT 8", (me, since)).fetchall()
decades = c.execute(
f"SELECT substr(year,1,3) d, COUNT(*) n {base} "
"AND year GLOB '[0-9][0-9][0-9][0-9]' GROUP BY d ORDER BY d",
(me, since)).fetchall()
busiest = c.execute(
f"SELECT substr(created_at,1,10) d, COUNT(*) n {base} "
"GROUP BY d ORDER BY n DESC LIMIT 1", (me, since)).fetchone()
return {
"days": days,
"tracks": total["n"], "bytes": total["b"],
"artists": [{"name": r["artist"], "n": r["n"]} for r in artists],
"genres": [{"name": r["genre"], "n": r["n"]} for r in genres],
"decades": [{"decade": f"{r['d']}0s", "n": r["n"]} for r in decades],
"busiest_day": ({"date": busiest["d"], "n": busiest["n"]}
if busiest and busiest["n"] else None),
}
def job_dict(r: sqlite3.Row) -> dict:
"""A job as the UI wants it.
`candidates` is deliberately dropped: it is the untried remainder of the
ladder, several KB of peer names and paths per job, and the jobs list is
polled every four seconds. `tried` is sent parsed, because it is the answer
to "why did this fail" and the UI renders it as a list.
"""
d = dict(r)
d.pop("candidates", None)
try:
d["tried"] = json.loads(d.get("tried") or "[]")
except (TypeError, ValueError):
d["tried"] = []
d["attempts"] = d.get("attempts") or 0
d["remaining"] = max(0, MAX_ATTEMPTS - d["attempts"])
return d
@app.get("/api/jobs")
async def jobs(request: Request, limit: int = Query(200, ge=1, le=1000),
mine: bool = False):
"""Own requests by default; admins see everyone's.
Scoped rather than global because on a shared instance one person's
request history is a list of what they listen to, which is nobody else's
business by default.
"""
u = current_user(request)
everyone = (AUTH_MODE == "off") or (u is not None and u["role"] == "admin" and not mine)
who = None if everyone else (u["username"] if u else None)
where, args = ("", []) if everyone else ("WHERE requested_by IS ?", [who])
with db() as c:
rows = c.execute(
f"SELECT * FROM jobs {where} ORDER BY created_at DESC, rowid DESC LIMIT ?",
[*args, limit]).fetchall()
counts = {r["state"]: r["n"] for r in c.execute(
f"SELECT state, COUNT(*) n FROM jobs {where} GROUP BY state",
args).fetchall()}
return {"jobs": [job_dict(r) for r in rows], "counts": counts,
"pending": work_q.qsize(), "scope": "all" if everyone else "mine"}
@app.post("/api/retry/{job_id}")
async def retry(job_id: str, request: Request):
"""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."""
u = current_user(request)
with db() as c:
row = c.execute("SELECT id, state, requested_by FROM jobs WHERE id=?",
(job_id,)).fetchone()
if not row:
raise HTTPException(404, "no such job")
# Same 404 rather than 403 for someone else's job: a distinct error would
# confirm the id exists, which is a small oracle over other people's
# requests.
if AUTH_MODE != "off" and u and u["role"] != "admin" \
and row["requested_by"] != u["username"]:
raise HTTPException(404, "no such job")
# Only failed jobs. Requeueing one in `review` used to strand its
# downloaded file on disk with no row left pointing at it, and requeueing
# one already `imported` would re-download a track that is in the library.
if row["state"] != "failed":
raise HTTPException(409, f"only failed jobs can be retried "
f"(this one is {row['state']})")
# Clear the previous attempt, or the ladder resumes mid-way through a
# candidate list gathered for a search that has long since expired.
with db() as c:
c.execute("UPDATE jobs SET username=NULL, remote_file=NULL, size=NULL,"
" confidence=NULL, match_why=NULL, candidates=NULL,"
" tried=NULL, attempts=0, started_at=NULL WHERE id=?",
(job_id,))
set_state(job_id, "queued", "")
await work_q.put(job_id)
return {"ok": True}
# ─────────────────────────── admin ───────────────────────────
class NewUser(BaseModel):
username: str
provider: str = "github"
role: str = "user"
quota: int = 0
storage_quota_gb: float = 0
# Where to send the invitation. Omit for a Google invite — the username
# already is the address. Only needed for GitHub, where a login is not one.
email: str | None = None
notify: bool = True
class PatchUser(BaseModel):
role: str | None = None
quota: int | None = None
storage_quota_gb: float | None = None
disabled: bool | None = None
# So a typo in an invited address is fixable without deleting the account
# and losing its request history.
email: str | None = None
def user_dict(r: sqlite3.Row) -> dict:
u = usage_for(r["username"])
return {
"username": r["username"], "provider": r["provider"], "role": r["role"],
"display": r["display"], "avatar": r["avatar"],
"quota": r["quota"], "storage_quota": r["storage_quota"],
"disabled": bool(r["disabled"]), "created_at": r["created_at"],
"last_seen": r["last_seen"], "invited_by": r["invited_by"],
"usage": u, "email": r["email"], "notify": bool(r["notify"]),
# Surfaced so the UI doesn't have to know the allowlist rule: these are
# the accounts that cannot actually be locked out by disabling them.
"bootstrap": r["username"].lower() in ALLOWED_USERS,
}
@app.get("/api/admin/users")
async def admin_users(request: Request):
require_admin(request)
with db() as c:
rows = c.execute("SELECT * FROM users ORDER BY role='admin' DESC, username").fetchall()
return {"users": [user_dict(r) for r in rows], "roles": list(ROLES)}
@app.post("/api/admin/users")
async def admin_add_user(body: NewUser, request: Request):
"""Invite someone.
The row IS the authorisation: once it exists, that identity can sign in
with the provider it was invited for, and only that one. The email is a
notification on top, sent when there is an address and SMTP is configured.
"""
who = require_admin(request)
name = body.username.strip()
if not name:
raise HTTPException(400, "username required")
if body.role not in ROLES:
raise HTTPException(400, f"role must be one of {', '.join(ROLES)}")
if body.provider not in PROVIDERS:
raise HTTPException(400, f"provider must be one of {', '.join(PROVIDERS)}")
if body.provider not in ENABLED_PROVIDERS:
raise HTTPException(
400, f"{body.provider} is not enabled on this instance "
f"(MUSE_AUTH={AUTH_MODE})")
# A Google identity is its own address, so require it to look like one --
# otherwise the invite silently authorises a username Google can never
# return, and the person just gets "not authorised" with no explanation.
if body.provider == "google" and not EMAIL_RE.match(name):
raise HTTPException(400, "a Google invite must be the full email address")
email = (body.email or "").strip() or (name if body.provider == "google" else "")
if email and not EMAIL_RE.match(email):
raise HTTPException(400, "that does not look like an email address")
with db() as c:
if c.execute("SELECT 1 FROM users WHERE username=?", (name,)).fetchone():
raise HTTPException(409, "that user already exists")
c.execute(
"INSERT INTO users (username,provider,role,quota,storage_quota,"
"invited_by,email,notify) VALUES (?,?,?,?,?,?,?,?)",
(name, body.provider, body.role, max(0, body.quota),
int(max(0.0, body.storage_quota_gb) * 2**30), who,
email or None, 1 if body.notify else 0))
r = c.execute("SELECT * FROM users WHERE username=?", (name,)).fetchone()
log.info("admin: %s invited %s as %s (%s)", who, name, body.role, body.provider)
sent = bool(MAIL_ON and email)
if sent:
await notify_invited(r, who, email)
return {**user_dict(r), "invite_sent": sent,
"invite_why": None if sent else
("no SMTP configured on this instance" if not MAIL_ON
else "no email address for this account")}
@app.post("/api/admin/users/{username}/resend")
async def admin_resend_invite(username: str, request: Request):
"""Send the invitation again. Safe to call repeatedly — the link carries
no token, so resending neither extends nor grants anything."""
who = require_admin(request)
with db() as c:
r = c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
if not r:
raise HTTPException(404, "no such user")
if not MAIL_ON:
raise HTTPException(409, "no SMTP configured on this instance")
if not r["email"]:
raise HTTPException(409, "no email address on file for that account")
await notify_invited(r, who, r["email"])
return {"ok": True, "sent_to": r["email"]}
@app.patch("/api/admin/users/{username}")
async def admin_patch_user(username: str, body: PatchUser, request: Request):
who = require_admin(request)
with db() as c:
r = c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
if not r:
raise HTTPException(404, "no such user")
# An install with no admin cannot be administered, and nothing in the
# UI can undo it. Refuse to remove the last one — whether by demotion
# or by disabling them.
losing_admin = r["role"] == "admin" and (
(body.role is not None and body.role != "admin") or body.disabled is True)
if losing_admin:
others = c.execute(
"SELECT COUNT(*) n FROM users "
"WHERE role='admin' AND disabled=0 AND username<>?",
(username,)).fetchone()["n"]
if others == 0:
raise HTTPException(
409, "this is the only active admin — promote someone else first")
sets, vals = [], []
if body.role is not None:
if body.role not in ROLES:
raise HTTPException(400, f"role must be one of {', '.join(ROLES)}")
sets.append("role=?"); vals.append(body.role)
if body.quota is not None:
sets.append("quota=?"); vals.append(max(0, body.quota))
if body.storage_quota_gb is not None:
sets.append("storage_quota=?")
vals.append(int(max(0.0, body.storage_quota_gb) * 2**30))
if body.disabled is not None:
sets.append("disabled=?"); vals.append(1 if body.disabled else 0)
if body.email is not None:
e = body.email.strip()
if e and not EMAIL_RE.match(e):
raise HTTPException(400, "that does not look like an email address")
sets.append("email=?"); vals.append(e or None)
if not sets:
raise HTTPException(400, "nothing to change")
c.execute(f"UPDATE users SET {', '.join(sets)} WHERE username=?",
[*vals, username])
# Disabling must take effect now. session_user re-checks on every
# request anyway, but dropping the rows makes it unambiguous.
if body.disabled:
c.execute("DELETE FROM sessions WHERE username=?", (username,))
r = c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
log.info("admin: %s updated %s (%s)", who, username, ", ".join(sets))
return user_dict(r)
@app.delete("/api/admin/users/{username}")
async def admin_delete_user(username: str, request: Request):
who = require_admin(request)
with db() as c:
r = c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
if not r:
raise HTTPException(404, "no such user")
if r["role"] == "admin":
others = c.execute(
"SELECT COUNT(*) n FROM users "
"WHERE role='admin' AND disabled=0 AND username<>?",
(username,)).fetchone()["n"]
if others == 0:
raise HTTPException(409, "this is the only active admin")
c.execute("DELETE FROM sessions WHERE username=?", (username,))
c.execute("DELETE FROM users WHERE username=?", (username,))
# Their jobs are deliberately kept: the history is a record of what is in
# the library and why, and deleting a person should not rewrite it.
log.info("admin: %s deleted user %s (jobs retained)", who, username)
return {"ok": True, "jobs_retained": True}
@app.get("/api/admin/activity")
async def admin_activity(request: Request, days: int = Query(30, ge=1, le=365)):
require_admin(request)
days = max(1, min(days, 365))
since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
with db() as c:
daily = c.execute(
"SELECT substr(created_at,1,10) d, COUNT(*) n, "
" SUM(state='imported') imported, SUM(state='failed') failed "
"FROM jobs WHERE created_at >= ? GROUP BY d ORDER BY d", (since,)).fetchall()
per_user = c.execute(
"SELECT COALESCE(requested_by,'(unattributed)') u, COUNT(*) n, "
" COALESCE(SUM(CASE WHEN state='imported' THEN size END),0) bytes "
"FROM jobs WHERE created_at >= ? GROUP BY u ORDER BY n DESC",
(since,)).fetchall()
top = c.execute(
"SELECT artist, COUNT(*) n FROM jobs WHERE created_at >= ? "
"GROUP BY artist ORDER BY n DESC LIMIT 8", (since,)).fetchall()
# Zero-fill: a bar chart with gaps for quiet days misreads as missing data
# rather than as nothing having happened.
by_day = {r["d"]: r for r in daily}
today = datetime.now(timezone.utc).date()
series = []
for i in range(days - 1, -1, -1):
d = (today - timedelta(days=i)).isoformat()
r = by_day.get(d)
series.append({"date": d, "n": r["n"] if r else 0,
"imported": (r["imported"] or 0) if r else 0,
"failed": (r["failed"] or 0) if r else 0})
return {"days": days, "series": series,
"per_user": [dict(r) for r in per_user],
"top_artists": [dict(r) for r in top]}
def _proc_stat() -> tuple[int, int]:
"""(busy, total) jiffies from /proc/stat."""
with open("/proc/stat") as f:
parts = [int(x) for x in f.readline().split()[1:]]
idle = parts[3] + (parts[4] if len(parts) > 4 else 0)
return sum(parts) - idle, sum(parts)
_cpu_prev: tuple[int, int] | None = None
def system_stats() -> dict:
"""Host CPU/RAM plus disk for the paths that matter.
Reads /proc directly rather than adding psutil: it is ~30 lines, keeps the
image free of a compiled dependency, and /proc in a container reports the
HOST's CPU and memory — which is what you actually want to see for a
homelab box. Everything is individually guarded so a non-Linux dev machine
degrades to nulls instead of 500ing the whole panel.
"""
global _cpu_prev
out: dict = {}
try:
busy, total = _proc_stat()
if _cpu_prev:
db_, dt = busy - _cpu_prev[0], total - _cpu_prev[1]
out["cpu_pct"] = round(100 * db_ / dt, 1) if dt > 0 else None
else:
out["cpu_pct"] = None # first call has no baseline to diff
_cpu_prev = (busy, total)
except Exception:
out["cpu_pct"] = None
try:
mem = {}
with open("/proc/meminfo") as f:
for line in f:
k, _, v = line.partition(":")
mem[k] = int(v.split()[0]) * 1024
total_b = mem.get("MemTotal", 0)
avail = mem.get("MemAvailable", 0)
out["mem_total"] = total_b
out["mem_used"] = total_b - avail
out["mem_pct"] = round(100 * (total_b - avail) / total_b, 1) if total_b else None
except Exception:
out["mem_total"] = out["mem_used"] = out["mem_pct"] = None
try:
out["load"] = [round(x, 2) for x in os.getloadavg()]
out["cpus"] = os.cpu_count()
except Exception:
out["load"] = None
disks = {}
for label, p in (("library", LIBRARY_DIR), ("downloads", DOWNLOAD_DIR)):
try:
s = os.statvfs(p)
total_b = s.f_blocks * s.f_frsize
free = s.f_bavail * s.f_frsize
disks[label] = {"total": total_b, "used": total_b - free, "free": free,
"pct": round(100 * (total_b - free) / total_b, 1) if total_b else None}
except Exception:
disks[label] = None
out["disks"] = disks
return out
@app.get("/api/admin/system")
async def admin_system(request: Request):
require_admin(request)
out = {"system": system_stats(), "workers": WORKERS, "pending": work_q.qsize()}
with db() as c:
out["jobs"] = {r["state"]: r["n"] for r in c.execute(
"SELECT state, COUNT(*) n FROM jobs GROUP BY state").fetchall()}
out["library"] = {
"tracks": c.execute(
"SELECT COUNT(*) n FROM jobs WHERE state='imported'").fetchone()["n"],
"bytes": c.execute(
"SELECT COALESCE(SUM(size),0) b FROM jobs "
"WHERE state='imported'").fetchone()["b"],
}
out["users"] = c.execute("SELECT COUNT(*) n FROM users").fetchone()["n"]
out["sessions"] = c.execute(
"SELECT COUNT(*) n FROM sessions WHERE expires_at > ?",
(datetime.now(timezone.utc).isoformat(),)).fetchone()["n"]
slskd: dict = {"ok": False}
try:
async with slskd_client(PROBE_TIMEOUT) as sc:
r = await sc.get("/api/v0/application")
slskd["ok"] = r.status_code == 200
if r.status_code == 200:
d = r.json()
srv = d.get("server") or {}
slskd["connected"] = bool(srv.get("isConnected"))
slskd["logged_in"] = bool(srv.get("isLoggedIn"))
slskd["version"] = d.get("version")
shares = d.get("shares") or {}
slskd["shared_files"] = (shares.get("directories") or {}).get("fileCount") \
or shares.get("fileCount")
else:
slskd["error"] = f"HTTP {r.status_code}"
except Exception as e:
slskd["error"] = errstr(e)
out["slskd"] = slskd
return out
@app.get("/api/health")
async def health():
ok, detail = True, {}
async with slskd_client(PROBE_TIMEOUT) as sc:
try:
r = await sc.get("/api/v0/application")
detail["slskd"] = r.status_code
ok = ok and r.status_code == 200
# Reachable and logged out is the failure worth naming: every
# search returns nothing and every request fails, but the container
# is up and healthy so nothing else reports a problem. This used to
# be visible only on the admin panel, which meant an ordinary user
# watched their requests fail with no way to learn why.
if r.status_code == 200:
app_state = r.json()
srv = app_state.get("server") or {}
detail["logged_in"] = bool(srv.get("isLoggedIn"))
detail["shared_files"] = (
((app_state.get("user") or {}).get("statistics") or {})
.get("fileCount"))
ok = ok and detail["logged_in"]
except Exception as e:
detail["slskd"] = errstr(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.")