"""FeedBack — FastAPI backend serving highway viewer + library.""" import asyncio import bisect import hashlib import json import logging import math import os import secrets import stat import sys import tempfile import shutil from pathlib import Path from typing import Any, ClassVar from logging_setup import configure_logging from env_compat import getenv_compat configure_logging() log = logging.getLogger("feedBack.server") from fastapi import Body, FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException, Query from fastapi.concurrency import run_in_threadpool from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response, StreamingResponse from safepath import safe_join from song import ( anchor_to_wire, arrangement_string_count, base_open_string_midis, compute_smart_names, chord_template_to_wire, chord_to_wire, hand_shape_to_wire, key_to_tonic_pc, load_song, note_to_wire, phrase_to_wire, pitch_from_base, scale_degree_for_pitch, ) from audio import find_wem_files, convert_wem from tunings import tuning_name, DEFAULT_TUNINGS, DEFAULT_REFERENCE_PITCH, apply_reference_pitch import sloppak as sloppak_mod import drums as drums_mod import notation as notation_mod import loosefolder as loosefolder_mod # Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/ # tier classification + response parsing. No network/DB in there — the # throttled transport and the song_enrichment writes live in this module. import mb_match # Metadata extraction lives in a side-effect-free module so ProcessPool # scan workers can import + unpickle _scan_one without re-running this # module's import-time side effects (see lib/scan_worker.py). from scan_worker import _extract_meta_for_file, _relpath, _scan_one import concurrent.futures import contextlib import contextvars import inspect import ipaddress import multiprocessing import re import sqlite3 import threading import time import uuid import warnings import xml.etree.ElementTree as ET import structlog from fastapi import Request app = FastAPI(title="FeedBack") # Plugins that maintain session stores can register a cleanup callback here. # The demo-mode janitor calls every registered hook once per hour so stale # sessions are swept without the core needing to know plugin internals. _DEMO_JANITOR_HOOKS: list = [] _DEMO_JANITOR_HOOKS_LOCK = threading.Lock() _DEMO_JANITOR_STARTED = False _DEMO_JANITOR_STOP = threading.Event() _DEMO_JANITOR_THREAD: threading.Thread | None = None def register_demo_janitor_hook(fn) -> None: """Register a zero-argument callable to be invoked hourly by the demo janitor. Plugins call this from their ``setup(app, context)`` when they want to participate in session cleanup under demo mode. The callable must accept no required arguments. Async (coroutine) functions are rejected: the janitor runs in a plain thread and cannot await coroutines. """ if not callable(fn): raise TypeError( f"register_demo_janitor_hook expects a callable, got {type(fn).__name__!r}" ) # Reject coroutine functions — check both the callable itself and its # __call__ method so objects with an async __call__ (e.g. class instances, # functools.partial wrappers around async functions) are also caught. _call = getattr(fn, "__call__", None) if inspect.iscoroutinefunction(fn) or ( _call is not None and inspect.iscoroutinefunction(_call) ): raise TypeError( "register_demo_janitor_hook does not accept async functions; " "the janitor runs in a plain thread and cannot await coroutines" ) # Validate that the callable accepts zero required arguments so it won't # crash at sweep time (hourly, far from the registration site). try: sig = inspect.signature(fn) except ValueError: # inspect.signature() raises ValueError for built-in C callables whose # signature cannot be determined. Accept them as-is; if they fail at # runtime the janitor will catch and log the exception. pass else: required = [ p for p in sig.parameters.values() if p.default is inspect.Parameter.empty and p.kind not in ( inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD, ) ] if required: raise TypeError( f"register_demo_janitor_hook expects a zero-argument callable; " f"{fn!r} has {len(required)} required parameter(s): " + ", ".join(p.name for p in required) ) with _DEMO_JANITOR_HOOKS_LOCK: _DEMO_JANITOR_HOOKS.append(fn) def _run_janitor_hook(hook) -> None: """Run a single janitor hook inline, swallowing and logging any exception. If the hook returns an awaitable (e.g. a coroutine slipped through the async-function guard), the coroutine is closed immediately to avoid ``RuntimeWarning: coroutine was never awaited`` noise, and a warning is emitted so the plugin author knows to fix their hook. """ try: result = hook() except Exception: log.exception("janitor hook %r raised", hook) return if inspect.iscoroutine(result): # A coroutine slipped through the async-function guard (e.g. via a # wrapper/partial). Close it to suppress "coroutine never awaited", # then warn so the plugin author knows to fix their hook. try: result.close() except Exception: log.exception("error closing coroutine from janitor hook %r", hook) warnings.warn( f"janitor hook {hook!r} returned a coroutine; " "hooks must be plain synchronous callables — " "register_demo_janitor_hook does not accept async functions", RuntimeWarning, stacklevel=1, ) elif inspect.isawaitable(result): # Future/Task: no .close() method; just warn and leave it alone. warnings.warn( f"janitor hook {hook!r} returned an awaitable (Future/Task); " "hooks must be plain synchronous callables", RuntimeWarning, stacklevel=1, ) _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [ ("POST", re.compile(r"^/api/settings$")), ("POST", re.compile(r"^/api/settings/import$")), ("POST", re.compile(r"^/api/settings/reset$")), ("POST", re.compile(r"^/api/rescan$")), ("POST", re.compile(r"^/api/rescan/full$")), ("POST", re.compile(r"^/api/songs/upload$")), ("DELETE", re.compile(r"^/api/song/.+$")), ("POST", re.compile(r"^/api/favorites/toggle$")), ("POST", re.compile(r"^/api/loops$")), ("DELETE", re.compile(r"^/api/loops/[^/]+$")), ("POST", re.compile(r"^/api/audio-effects/mappings$")), ("DELETE", re.compile(r"^/api/audio-effects/mappings/[^/]+$")), ("POST", re.compile(r"^/api/audio-effects/mappings/[^/]+/activate$")), ("DELETE", re.compile(r"^/api/audio-effects/active-mapping$")), ("POST", re.compile(r"^/api/song/.*/meta$")), ("POST", re.compile(r"^/api/song/.*/art/upload$")), ("GET", re.compile(r"^/api/plugins/updates$")), ("POST", re.compile(r"^/api/plugins/[^/]+/update$")), ("POST", re.compile(r"^/api/plugins/editor/save$")), ("POST", re.compile(r"^/api/plugins/editor/build$")), ("POST", re.compile(r"^/api/plugins/editor/upload-art$")), ("POST", re.compile(r"^/api/plugins/editor/upload-audio$")), ("POST", re.compile(r"^/api/plugins/editor/youtube-audio$")), ("POST", re.compile(r"^/api/plugins/editor/import-gp$")), ("POST", re.compile(r"^/api/plugins/editor/import-midi$")), ("POST", re.compile(r"^/api/plugins/lyrics_karaoke/align$")), ("POST", re.compile(r"^/api/plugins/lyrics_karaoke/generate-pitch$")), ("POST", re.compile(r"^/api/plugins/lyrics_karaoke/save-lyrics$")), ("POST", re.compile(r"^/api/plugins/lyrics_sync/align$")), ("POST", re.compile(r"^/api/plugins/lyrics_sync/save$")), ("POST", re.compile(r"^/api/plugins/studio/sessions/[^/]+/extract-drums$")), ("POST", re.compile(r"^/api/diagnostics/export$")), ("GET", re.compile(r"^/api/diagnostics/preview$")), ("GET", re.compile(r"^/api/diagnostics/hardware$")), # Bundled core plugin — video background upload/delete ("POST", re.compile(r"^/api/plugins/highway_3d/files$")), ("DELETE", re.compile(r"^/api/plugins/highway_3d/files$")), # fee[dB]ack v0.3.0 write endpoints — demo mode is read-only, so block the # new profile / XP / stats / playlists / saved mutators too. ("POST", re.compile(r"^/api/profile$")), ("POST", re.compile(r"^/api/profile/avatar$")), ("POST", re.compile(r"^/api/xp/award$")), ("POST", re.compile(r"^/api/stats$")), ("POST", re.compile(r"^/api/playlists$")), ("PATCH", re.compile(r"^/api/playlists/[^/]+$")), ("DELETE", re.compile(r"^/api/playlists/[^/]+$")), ("POST", re.compile(r"^/api/playlists/[^/]+/songs$")), ("DELETE", re.compile(r"^/api/playlists/[^/]+/songs/.+$")), ("POST", re.compile(r"^/api/playlists/[^/]+/reorder$")), ("POST", re.compile(r"^/api/playlists/[^/]+/cover$")), ("DELETE", re.compile(r"^/api/playlists/[^/]+/cover$")), ("POST", re.compile(r"^/api/saved/toggle$")), # Progression (spec 010) write endpoints — demo mode stays read-only. ("POST", re.compile(r"^/api/progression/paths$")), ("POST", re.compile(r"^/api/progression/onboarding$")), ("POST", re.compile(r"^/api/progression/events$")), ("POST", re.compile(r"^/api/shop/buy$")), ("POST", re.compile(r"^/api/shop/equip$")), # Enrichment (P8): review writes mutate the local match cache, and the # search proxy / manual kick relay to MusicBrainz — none of it belongs to # anonymous demo visitors (they'd spend the shared rate limit). ("POST", re.compile(r"^/api/enrichment/review/.+$")), ("POST", re.compile(r"^/api/enrichment/kick$")), ("GET", re.compile(r"^/api/enrichment/search$")), # Context menus (R2): the per-song re-match mutates the cache + spends # rate limit; Get-info exposes filesystem paths. ("POST", re.compile(r"^/api/enrichment/refresh/.+$")), ("GET", re.compile(r"^/api/chart/.+/fileinfo$")), # Gap-fill (R4a) rewrites pack files on disk — never for demo visitors. ("POST", re.compile(r"^/api/song/.+/gap-fill$")), # Art layer (R3): all three mutate server state / touch the network on a # visitor's behalf — the base64 upload writes files, the URL fetch makes the # server request arbitrary images, and the override delete removes files. ("POST", re.compile(r"^/api/song/.+/art/upload$")), ("POST", re.compile(r"^/api/song/.+/art/url$")), ("DELETE", re.compile(r"^/api/art/.+/override$")), # Cover picker (PR-C): read-only, but a cache-miss open spends 1-3 # throttled Cover Art Archive calls — anonymous demo visitors don't get # to spend the shared rate budget (same rule as enrichment search/kick). ("GET", re.compile(r"^/api/song/.+/art/candidates$")), # Artist pages (PR-B): the links GET lazily fetches from MusicBrainz on a # visitor's behalf AND writes the artist_enrichment cache; refresh # re-spends the shared rate limit. The /page route stays open (all-local # read). Same rationale as /api/enrichment/search above. ("GET", re.compile(r"^/api/artist/.+/links$")), ("POST", re.compile(r"^/api/artist/.+/links/refresh$")), ] @app.middleware("http") async def _demo_mode_guard(request: Request, call_next): if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1": path = request.url.path for method, pattern in _DEMO_BLOCKED: if request.method == method and pattern.match(path): return JSONResponse({"error": "demo mode: read-only"}, status_code=403) response = await call_next(request) if request.method == "GET" and path == "/" and "feedBack_demo_session" not in request.cookies: forwarded_proto = (request.headers.get("x-forwarded-proto") or "").split(",")[0].strip() is_secure = request.url.scheme == "https" or forwarded_proto.lower() == "https" response.set_cookie( "feedBack_demo_session", str(uuid.uuid4()), max_age=86400, httponly=True, samesite="lax", secure=is_secure, ) return response return await call_next(request) from asgi_correlation_id import CorrelationIdMiddleware # validator=None accepts any non-empty inbound X-Request-ID value, including # opaque proxy-generated hex strings, not just RFC-4122 UUIDs. app.add_middleware(CorrelationIdMiddleware, validator=None) STATIC_DIR = Path(__file__).parent / "static" try: STATIC_DIR.mkdir(exist_ok=True) except OSError: pass # Read-only in packaged installs # Distinguish "env not set / empty" from "explicitly set". Path("") collapses # to Path(".") so we can't recover that signal after the cast — capture the # raw env-var string up front and let _get_dlc_dir() consult both. This way # `DLC_DIR=.` remains a valid opt-in for cwd while `DLC_DIR=""` (or unset) # falls through to the config.json fallback. _DLC_DIR_ENV = os.environ.get("DLC_DIR", "").strip() DLC_DIR = Path(_DLC_DIR_ENV) if _DLC_DIR_ENV else Path("") CONFIG_DIR = Path(os.environ.get("CONFIG_DIR", str(Path.home() / ".local" / "share" / "feedback"))) # Writable cache directories (use CONFIG_DIR, not STATIC_DIR which may be read-only) ART_CACHE_DIR = CONFIG_DIR / "art_cache" AUDIO_CACHE_DIR = CONFIG_DIR / "audio_cache" SLOPPAK_CACHE_DIR = CONFIG_DIR / "sloppak_cache" def _env_flag(name: str) -> bool: """Parse a conventional boolean env flag (honours legacy SLOPSMITH_* alias).""" return (getenv_compat(name, "") or "").strip().lower() in {"1", "true", "yes", "on"} # Canonical Tuning-filter grouping key (feedBack#867). tuning_name collapses # every non-standard tuning to "Custom Tuning"; for those rows we key on the # raw offsets so distinct customs stay distinct, while named tunings keep # grouping by name (stable across the offsets-column migration). Used by both # the tuning-names listing and the filter WHERE so the contract matches. def _tuning_group_key_sql(alias: str) -> str: """The tuning grouping key (name for named tunings, raw offsets for customs) against an explicit table alias — the grouped filter law (§7.1) evaluates chart-intrinsic predicates inside a member subquery, where bare column names would resolve against the wrong scope.""" return (f"CASE WHEN {alias}.tuning_name = 'Custom Tuning' AND COALESCE({alias}.tuning_offsets, '') != '' " f"THEN {alias}.tuning_offsets ELSE {alias}.tuning_name END") _TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs") # ── SQLite metadata cache ───────────────────────────────────────────────────── def _ensure_smart_names(arrangements: list[dict]) -> list[dict]: """Fill in missing ``smart_name`` fields and sort arrangements by smart order. Applied to every library query result so the client always receives arrangements in priority order: Lead → Alt. Lead [1,2,…] → Bonus Lead [1,2,…] → Rhythm → Alt. Rhythm → Bonus Rhythm → Bass → Alt. Bass → Bonus Bass → other Rows scanned before the smart-naming feature was introduced don't carry a ``smart_name`` key. The background scanner automatically rescans those rows to populate the field from authoritative manifest JSON path flags. In the meantime this function provides a best-effort on-the-fly computation. However, when multiple arrangements share the same name (e.g. two "Combo" tracks in a archive that bundles all path flags as zero), name-based inference cannot distinguish Lead from Rhythm — so we emit ``smart_name: null`` and let the UI fall back to the legacy name until the background rescan corrects the row. Arrangements that already have the field are never modified. """ if not arrangements: return arrangements # Fill in missing smart_name values. if not all("smart_name" in a for a in arrangements): # Detect duplicate raw names across ALL arrangements (not just the # missing subset). A duplicate anywhere means the name-based fallback # may assign the same smart type a scanned row already owns — emit # None for the missing entries and let the legacy name show through # until the background rescan corrects them. # Coerce to str so a malformed cached row with a list/dict name # doesn't blow up the set() conversion (and every query that hits it). all_names = [ a.get("name", "") if isinstance(a.get("name"), str) else str(a.get("name", "")) for a in arrangements ] has_duplicates = len(all_names) != len(set(all_names)) if has_duplicates: for a in arrangements: if "smart_name" not in a: a["smart_name"] = None else: # No duplicates — name-based fallback is safe. from song import Arrangement as _ArrCls arr_objs = [ _ArrCls( name=a.get("name", ""), path_lead=a.get("_path_lead", False), path_rhythm=a.get("_path_rhythm", False), path_bass=a.get("_path_bass", False), bonus_arr=a.get("_bonus_arr", False), represent=a.get("_represent", 0), ) for a in arrangements ] smart = compute_smart_names(arr_objs) for a, sn in zip(arrangements, smart): if "smart_name" not in a: a["smart_name"] = sn # Always sort by smart priority order so the client receives a consistent # list regardless of how the DB row was originally stored. # _arr_smart_sort_key is defined later in this module but resolved at # call-time, so the forward reference is safe. arrangements.sort(key=_arr_smart_sort_key) return arrangements def _sqlite_file_integrity_ok(path: Path) -> bool: """True if `path` is a SQLite database that opens and passes `PRAGMA quick_check`. Used to gate a DB restore so a truncated or corrupt snapshot can never overwrite the live library DB.""" try: with open(path, "rb") as f: if f.read(16) != b"SQLite format 3\x00": # cheap header gate, no full read return False except OSError: return False conn = None try: conn = sqlite3.connect(str(path)) row = conn.execute("PRAGMA quick_check").fetchone() return bool(row) and row[0] == "ok" except sqlite3.Error: return False finally: if conn is not None: conn.close() # quick_check on a non-WAL file makes no sidecars, but a malformed # file can; sweep them so a probe never litters config_dir. for suffix in ("-wal", "-shm"): try: path.with_name(path.name + suffix).unlink() except FileNotFoundError: pass def _apply_pending_db_restore(config_dir: Path) -> None: """Swap in a library DB restored from a settings bundle, if one is staged. A settings import writes the restored snapshot to `web_library.db.restore` rather than over the live DB (the running server holds the old file open, and a stale `-wal`/`-shm` could be replayed onto a fresh main file → corruption). The swap happens here, at startup, BEFORE the connection opens: delete the old DB and its WAL sidecars, then rename the staged snapshot into place. The snapshot is a fully-checkpointed single file (SQLite online-backup API), so it needs no sidecars of its own. Idempotent and a no-op when nothing is staged. The staged file is re-validated here before anything is destroyed: a restore that fails its integrity check is discarded and the live DB is left untouched, so a bad bundle can never brick startup or lose data.""" pending = config_dir / "web_library.db.restore" if not pending.exists(): return if not _sqlite_file_integrity_ok(pending): log.error("pending library DB restore failed its integrity check; " "discarding it and keeping the existing database") try: pending.unlink() except FileNotFoundError: pass return for suffix in ("", "-wal", "-shm"): try: (config_dir / f"web_library.db{suffix}").unlink() except FileNotFoundError: pass os.replace(pending, config_dir / "web_library.db") log.info("applied pending library DB restore from settings import") # ── Keyset (cursor) pagination for the library grid (feedBack#636 item 3) ───── # Forward-only, O(page) deep paging that doesn't grow with OFFSET. Only simple # single-column sorts can keyset cleanly (the compound tuning/year sorts fall # back to OFFSET). Every sort gets a unique `filename` tiebreak so the order is # TOTAL — which also fixes a latent OFFSET skip/dupe across equal-key rows. # (column, collate-clause, primary-direction) — tiebreak is always `filename` ASC. _KEYSET_SORTS = { # artist/artist-desc left OUT deliberately: their ORDER BY carries a # title secondary (so cards within an artist read alphabetically, like # the tree view) which a two-term (value, filename) cursor can't seek # correctly — they page by OFFSET, which is measured-trivial at real # library sizes. Restore them with a composite sort-key column if # 50k-song libraries ever make OFFSET hurt. "title": ("title", "COLLATE NOCASE", "ASC"), "title-desc": ("title", "COLLATE NOCASE", "DESC"), "recent": ("mtime", "", "DESC"), } # Index into a query_page row tuple for each keyset column (see the SELECT in # query_page: filename, title, artist, ... mtime at 9). _KEYSET_ROW_IDX = {"artist": 2, "title": 1, "mtime": 9} def _encode_cursor(values: list) -> str: import base64 return base64.urlsafe_b64encode(json.dumps(values).encode("utf-8")).decode("ascii") def _decode_cursor(cursor: str): """Decode an opaque keyset cursor to [sort_value, filename], or None if it's malformed (a bad cursor degrades to the first page, never 500s).""" import base64 try: out = json.loads(base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8")) except (ValueError, TypeError): return None return out if isinstance(out, list) and len(out) == 2 else None def _effective_keyset_sort(sort: str, direction: str) -> str: """Fold the legacy `dir=desc` toggle into the canonical keyset sort key, so the seek/cursor direction matches the ORDER BY that same toggle produces (without this, `sort=artist&dir=desc` would seek with `>` against a DESC order → gaps/dupes).""" if direction == "desc" and sort in ("artist", "title"): return sort + "-desc" return sort def _keyset_seek(col: str, collate: str, primary_dir: str, cv, fn: str): """(sql, params) for 'rows strictly after (cv, fn)' in the total order ` , filename ASC`, matching SQLite's NULL placement (NULLs sort first in ASC, last in DESC) so keyset is exactly OFFSET- equivalent even for NULL sort keys.""" ce = f"{col} {collate}".strip() if primary_dir == "ASC": # NULLs first if cv is None: return (f"(({col} IS NULL AND filename > ?) OR {col} IS NOT NULL)", [fn]) return (f"({col} IS NOT NULL AND ({ce} > ? OR ({ce} = ? AND filename > ?)))", [cv, cv, fn]) # DESC — NULLs last if cv is None: return (f"({col} IS NULL AND filename > ?)", [fn]) return (f"({col} IS NULL OR ({col} IS NOT NULL AND " f"({ce} < ? OR ({ce} = ? AND filename > ?))))", [cv, cv, fn]) def next_library_cursor(sort: str, last_song: dict | None) -> str | None: """The cursor for the last row of a page, so the next request resumes after it. None when the sort can't keyset or the page was empty.""" if sort not in _KEYSET_SORTS or not last_song: return None col = _KEYSET_SORTS[sort][0] key = "mtime" if col == "mtime" else col if key not in last_song or "filename" not in last_song: return None return _encode_cursor([last_song[key], last_song["filename"]]) # Song-level "mastered" threshold — best accuracy across a song's arrangements # at/above this counts as in your repertoire. One number shared by the green # accuracy badge, the Repertoire meter, the mastery filter/sort, and the P3 # growth-edge recommender (matches the frontend MASTERY_ACCURACY). MASTERY_ACCURACY = 0.9 class MetadataDB: def __init__(self): CONFIG_DIR.mkdir(parents=True, exist_ok=True) _apply_pending_db_restore(CONFIG_DIR) self.db_path = str(CONFIG_DIR / "web_library.db") self.conn = sqlite3.connect(self.db_path, check_same_thread=False) self.conn.execute("PRAGMA journal_mode=WAL") self.conn.execute(""" CREATE TABLE IF NOT EXISTS songs ( filename TEXT PRIMARY KEY, mtime REAL, size INTEGER, title TEXT, artist TEXT, album TEXT, year TEXT, duration REAL, tuning TEXT, arrangements TEXT, has_lyrics INTEGER DEFAULT 0, format TEXT DEFAULT 'archive', stem_count INTEGER DEFAULT 0, stem_ids TEXT DEFAULT '[]', tuning_name TEXT DEFAULT '', tuning_sort_key INTEGER DEFAULT 0, tuning_offsets TEXT DEFAULT '', genre TEXT DEFAULT '', track_number INTEGER, disc INTEGER ) """) # Idempotent migrations for installs that predate each column. for ddl in ( "ALTER TABLE songs ADD COLUMN format TEXT DEFAULT 'archive'", "ALTER TABLE songs ADD COLUMN stem_count INTEGER DEFAULT 0", # feedBack#129: per-stem filter needs the id list, not just count. "ALTER TABLE songs ADD COLUMN stem_ids TEXT DEFAULT '[]'", # feedBack#69 + #22: denormalized canonical tuning name + numeric # sort key (sum of offsets). The existing `tuning` text column # stays — these are caches, repopulated on rescan. "ALTER TABLE songs ADD COLUMN tuning_name TEXT DEFAULT ''", "ALTER TABLE songs ADD COLUMN tuning_sort_key INTEGER DEFAULT 0", # feedBack#867: raw per-string offsets (space-joined ints) so the # v3 client can render target notes and the Tuning filter can keep # distinct custom tunings distinct (tuning_name collapses them all # to "Custom Tuning"). Cache; repopulated on rescan. "ALTER TABLE songs ADD COLUMN tuning_offsets TEXT DEFAULT ''", # Primary genre from the feedpak `genres` list (spec 1.12.0). Cache; # repopulated on rescan. "ALTER TABLE songs ADD COLUMN genre TEXT DEFAULT ''", # Album track order from the feedpak `track`/`disc` fields (spec # 1.12.0). NULL when the pack doesn't author them; the album view # falls back to title order. Cache; repopulated on rescan. "ALTER TABLE songs ADD COLUMN track_number INTEGER", "ALTER TABLE songs ADD COLUMN disc INTEGER", ): try: self.conn.execute(ddl) except sqlite3.OperationalError: pass self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist COLLATE NOCASE)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title COLLATE NOCASE)") # Composite (sort col, filename) indexes cover the grid's ORDER BY + # its unique filename tiebreak — for both the OFFSET scan and keyset # seek (feedBack#636 item 3). idx_songs_artist/title above stay for the # distinct-artist / letter-bar aggregates. self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_artist_fn ON songs(artist COLLATE NOCASE, filename)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_title_fn ON songs(title COLLATE NOCASE, filename)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_mtime_fn ON songs(mtime, filename)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_tuning_name ON songs(tuning_name COLLATE NOCASE)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_genre ON songs(genre COLLATE NOCASE)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_tuning_sort_key ON songs(tuning_sort_key)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_year ON songs(year)") self.conn.execute("CREATE TABLE IF NOT EXISTS favorites (filename TEXT PRIMARY KEY)") # Personal, per-song metadata that must NEVER travel in the shared # feedpak file: a light 1–5 user-difficulty (planning only — distinct # from the authored 1–10 difficulty bands) + freeform notes. Likes are # NOT here — they stay the existing `favorites` heart (Christian's call). # A SEPARATE table (not `songs` columns) so a rescan's # `INSERT OR REPLACE INTO songs` can't wipe it; keyed by the same on-disk # filename as every other personal table. Additive + idempotent. self.conn.execute(""" CREATE TABLE IF NOT EXISTS song_user_meta ( filename TEXT PRIMARY KEY, user_difficulty INTEGER, -- 1..5, NULL = unset notes TEXT, updated_at TEXT ) """) # Free-form personal practice tags ("warm-ups", "riffs to nail") — an # intent practice-set primitive (Play-all-over-a-tag comes later). Tags # are normalized lowercase on write so "Rock"/"rock" don't split. Peer # of song_user_meta; same never-clobber rationale. self.conn.execute(""" CREATE TABLE IF NOT EXISTS song_tags ( filename TEXT NOT NULL, tag TEXT NOT NULL, created_at TEXT, PRIMARY KEY (filename, tag) ) """) self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_tags_tag ON song_tags(tag COLLATE NOCASE)") # Artist-name aliases (P4): "ACDC" → "AC/DC", "the beatles" → "The Beatles". # A CANONICALIZATION OVERRIDE applied AT DISPLAY only — the scanner-derived # `songs.artist` and the feedpak files are never rewritten (a rescan can't # fight the user; one alias row fixes every matching song at once). Keyed by # the raw artist string (COLLATE NOCASE so case variants collapse), so it is # NOT filename-keyed → never touched by delete_missing/delete_song (an alias # outlives the songs that motivated it, ready for re-import). mb_artist_id is # reserved for a future confident MusicBrainz match (unused now). self.conn.execute(""" CREATE TABLE IF NOT EXISTS artist_alias ( raw_name TEXT PRIMARY KEY COLLATE NOCASE, canonical_name TEXT NOT NULL, mb_artist_id TEXT, updated_at TEXT ) """) # ── Multi-chart grouping (P5a) ─────────────────────────────────────── # A "work" is a song that may be charted by several feedpaks; each chart # stays its own `songs` row (unchanged), but they GROUP under a shared # work_key = normalize(artist+title). Two sparse, never-purged-on-rescan # override tables + one MATERIALIZED read-model so the grid can group # server-side without a query-time GROUP BY (which would kill the keyset # seek / A–Z / virtualization — see query_page). # # chart_group_pref: your chosen "keeper" chart per work (sparse; unset ⇒ # auto-pick). Keyed by work_key, NOT filename, so it survives a chart's # rescan; an orphaned preferred (file gone) degrades to auto-pick. self.conn.execute(""" CREATE TABLE IF NOT EXISTS chart_group_pref ( work_key TEXT PRIMARY KEY, preferred_filename TEXT NOT NULL, updated_at TEXT ) """) # chart_group_split: "these aren't the same song" escape hatch — a chart # gets its own unique split_key so it stands alone as a singleton work. # Filename-keyed → purged with the song on delete_song. self.conn.execute(""" CREATE TABLE IF NOT EXISTS chart_group_split ( filename TEXT PRIMARY KEY, split_key TEXT NOT NULL, updated_at TEXT ) """) # work_display: the MATERIALIZED representative-filter read-model, rebuilt # from songs + the two override tables. One row per song: # effective_work_key = split_key if split else work_key # is_group_representative = 1 for the keeper (pref or auto-pick) of a work # group_size = the ⚑ N charts in the work # Grouping-ON is then just `WHERE is_group_representative = 1` (keyset-safe). # A derived cache: filename-keyed, rebuilt on demand (dirty flag) — safe to # drop/rebuild, so it's purged on delete and re-materialized after a scan. self.conn.execute(""" CREATE TABLE IF NOT EXISTS work_display ( filename TEXT PRIMARY KEY, work_key TEXT NOT NULL, effective_work_key TEXT NOT NULL, is_group_representative INTEGER NOT NULL DEFAULT 1, group_size INTEGER NOT NULL DEFAULT 1 ) """) self.conn.execute("CREATE INDEX IF NOT EXISTS idx_work_display_rep ON work_display(is_group_representative)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_work_display_eff ON work_display(effective_work_key)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_work_display_wk ON work_display(work_key)") self.conn.execute(""" CREATE TABLE IF NOT EXISTS loops ( id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL, name TEXT NOT NULL, start_time REAL NOT NULL, end_time REAL NOT NULL, created_at TEXT DEFAULT (datetime('now')) ) """) # fee[dB]ack v0.3.0 — single-user player profile (id=1), streak, and the # unified XP store. Peers of favorites/loops; additive + idempotent. # `player_hash` is a future-leaderboard identity label (SHA-256 of the # first display name + a once-generated salt), never an auth credential. self.conn.execute(""" CREATE TABLE IF NOT EXISTS profile ( id INTEGER PRIMARY KEY CHECK (id = 1), display_name TEXT, avatar_path TEXT, player_hash TEXT, player_salt TEXT, onboarded INTEGER NOT NULL DEFAULT 0, created_at TEXT ) """) self.conn.execute(""" CREATE TABLE IF NOT EXISTS profile_progress ( id INTEGER PRIMARY KEY CHECK (id = 1), current_streak INTEGER NOT NULL DEFAULT 0, best_streak INTEGER NOT NULL DEFAULT 0, last_active_date TEXT -- YYYY-MM-DD (local) ) """) # Unified XP store: the single source of truth the profile badge reads. # Song-play, minigames, and tutorials all feed THIS via award_xp() — no # second XP curve (lib/xp.py owns the math). self.conn.execute(""" CREATE TABLE IF NOT EXISTS xp_profile ( id INTEGER PRIMARY KEY CHECK (id = 1), xp INTEGER NOT NULL DEFAULT 0, total_awards INTEGER NOT NULL DEFAULT 0, minigames_seeded INTEGER NOT NULL DEFAULT 0, updated_at TEXT ) """) # Per-source XP ledger: the unified `xp` total above is a single number, # but a source (minigames, tutorials, song-play, …) needs to know its own # contribution so it can be reset/reversed independently (a minigames # profile-reset must subtract only its share, not song-play XP). self.conn.execute(""" CREATE TABLE IF NOT EXISTS xp_sources ( source TEXT PRIMARY KEY, xp INTEGER NOT NULL DEFAULT 0 ) """) # Per-song/arrangement practice stats (best score + accuracy, plays, # last position for Continue-Playing). Fed by the highway note-detection # scorer via POST /api/stats. Additive + idempotent; a 0.2.9 build # tolerates it and the new build opens an old db without it. self.conn.execute(""" CREATE TABLE IF NOT EXISTS song_stats ( filename TEXT NOT NULL, arrangement INTEGER NOT NULL DEFAULT 0, plays INTEGER NOT NULL DEFAULT 0, best_score INTEGER NOT NULL DEFAULT 0, best_accuracy REAL NOT NULL DEFAULT 0, last_score INTEGER NOT NULL DEFAULT 0, last_accuracy REAL NOT NULL DEFAULT 0, last_position REAL NOT NULL DEFAULT 0, last_played_at TEXT, updated_at TEXT, PRIMARY KEY (filename, arrangement) ) """) self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)") # Playlists + the reserved "Saved for Later" system playlist. Additive. self.conn.execute(""" CREATE TABLE IF NOT EXISTS playlists ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, system_key TEXT, -- 'saved_for_later' for reserved playlists, else NULL created_at TEXT, updated_at TEXT ) """) self.conn.execute(""" CREATE TABLE IF NOT EXISTS playlist_songs ( playlist_id INTEGER NOT NULL, filename TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (playlist_id, filename) ) """) self.conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_system_key ON playlists(system_key) WHERE system_key IS NOT NULL") # Smart collections (feedBack#636 item 2): a playlist row whose `rules` # JSON is non-NULL is a smart/dynamic collection — its membership is the # LIVE result of those library filter params, not a stored song list. # It surfaces as a registered library provider (the v3 source picker), # so it inherits the whole Songs UI. Additive, idempotent migration. try: self.conn.execute("ALTER TABLE playlists ADD COLUMN rules TEXT") except sqlite3.OperationalError: pass # Curated album (P6, metadata-design §7.2): a playlists row with # kind='album' is a hand-picked, ORDERED practice set of works with a # chosen chart per slot — the repeatable gameplay loop. Reuses the # playlist machinery wholesale (membership/order/cover/queue); the whole # schema delta is this `kind` discriminator plus two per-slot columns: # `arrangement` = the pinned arrangement NAME (names survive rescans; # the client resolves name→index at play), `work_key` = stamped at # add-time so a slot whose pinned chart is later deleted can self-heal # to the work's CURRENT preferred at read (never rewritten). Additive, # idempotent — same pattern as `rules` above. for _ddl in ("ALTER TABLE playlists ADD COLUMN kind TEXT", "ALTER TABLE playlist_songs ADD COLUMN arrangement TEXT", "ALTER TABLE playlist_songs ADD COLUMN work_key TEXT"): try: self.conn.execute(_ddl) except sqlite3.OperationalError: pass # Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable # list of songs the user does NOT own yet — the *arr "Wanted/Monitored" # analogue. Unlike playlists (which reference owned local songs by # filename), a wanted entry has no local file, so it lives in its own # table keyed by descriptive identity. Producers (the find_more plugin's # ownership-diff, or a manual add) POST here; the consuming UI reads it. # Additive + idempotent. self.conn.execute(""" CREATE TABLE IF NOT EXISTS wanted ( id INTEGER PRIMARY KEY AUTOINCREMENT, artist TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT '', -- e.g. 'find_more', 'manual' source_ref TEXT NOT NULL DEFAULT '', -- opaque id/url within that source note TEXT NOT NULL DEFAULT '', created_at TEXT ) """) # Identity = (artist, title, source, source_ref), case-insensitive on # the human fields, so re-running an ownership-diff doesn't duplicate. self.conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity " "ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)" ) # Metadata-enrichment cache (P7, library-metadata design §4/§5/§6): one # row per song holding its match lifecycle + the canonical values a # confident match supplies. A CACHE/OVERRIDE layer — canonical values # are displayed, NEVER auto-written into the pack file. Never purged on # rescan (only by the explicit per-song delete); re-derivable, so a lost # row just re-enriches. `content_hash` keys the row to the metadata a # match depends on (normalized artist|title|album|duration — NOT the # filename), which makes enrichment idempotent AND rename-survivable. # match_state lifecycle: unscanned → matched(source,score) | manual | # failed. A `manual` row is the user's pinned pick — NEVER auto-reset; # `failed` retries on backoff via `attempts` (the matcher, P8, owns # that policy). Additive + idempotent. self.conn.execute(""" CREATE TABLE IF NOT EXISTS song_enrichment ( filename TEXT PRIMARY KEY, content_hash TEXT, match_state TEXT NOT NULL DEFAULT 'unscanned', match_source TEXT, match_score REAL, attempts INTEGER NOT NULL DEFAULT 0, mb_recording_id TEXT, mb_release_id TEXT, mb_artist_id TEXT, isrc TEXT, canon_artist TEXT, canon_album TEXT, canon_title TEXT, canon_year TEXT, canon_artist_sort TEXT, genres TEXT, art_cache_path TEXT, art_state TEXT, fetched_at TEXT ) """) self.conn.execute("CREATE INDEX IF NOT EXISTS idx_enrichment_hash ON song_enrichment(content_hash)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_enrichment_state ON song_enrichment(match_state)") # P8 (the matcher): `candidates` holds the review tier's ranked # candidate list (JSON) so the Match-Review drawer never re-queries # MusicBrainz just to render; `last_attempt_at` anchors the failed-row # retry backoff (epoch seconds). Idempotent ALTERs, same pattern as # the `songs` migrations above. # R1 scraper options: `apply_mask` records which per-field auto-apply # toggles were OFF (suppressed) when an AUTOMATIC match settled the row, # as a canonical sorted comma-joined marker of blocked keys (''/NULL = # nothing suppressed). It keeps the per-field toggles to the same # "nothing forfeited" contract as the source/art toggles: re-enabling a # field re-queues affected `matched` rows for backfill (enrichment_pending) # and a partially-applied row is barred from seeding siblings # (enrichment_cache_lookup). Idempotent ALTER, same pattern as above. for ddl in ( "ALTER TABLE song_enrichment ADD COLUMN candidates TEXT", "ALTER TABLE song_enrichment ADD COLUMN last_attempt_at REAL", "ALTER TABLE song_enrichment ADD COLUMN apply_mask TEXT", ): try: self.conn.execute(ddl) except sqlite3.OperationalError: pass # Artist-level enrichment cache (artist pages, launch charrette §5): # ONE row per matched MusicBrainz artist holding the whitelisted # url-relations (external links) + MB genres from a single throttled # artist lookup, fetched lazily on the first artist-page links request # and refreshed only on demand. Keyed by mb_artist_id (NOT the display # name), so alias merges / renames never orphan it. Never purged on # rescan — like song_enrichment, it is re-derivable but expensive # (rate-limited) to re-fetch. Additive + idempotent. self.conn.execute(""" CREATE TABLE IF NOT EXISTS artist_enrichment ( mb_artist_id TEXT PRIMARY KEY, url_rels TEXT, genres TEXT, fetched_at TEXT ) """) # Progression (spec 010): instrument paths, challenges, quests, the # Decibels wallet, and the cosmetics shop. Targets/titles live in the # bundled content (data/progression/); these tables hold only player # state (counters, completion timestamps, spend, ownership) so content # edits update live displays without migrations. Additive + idempotent. self.conn.execute(""" CREATE TABLE IF NOT EXISTS progression_state ( id INTEGER PRIMARY KEY CHECK (id = 1), calibration_status TEXT NOT NULL DEFAULT 'pending', -- pending|completed|skipped calibration_completed_at TEXT, created_at TEXT ) """) self.conn.execute(""" CREATE TABLE IF NOT EXISTS player_paths ( path_id TEXT PRIMARY KEY, -- 'guitar' | 'bass' | 'drums' | future level INTEGER NOT NULL DEFAULT 0, selected_at TEXT ) """) self.conn.execute(""" CREATE TABLE IF NOT EXISTS challenge_progress ( challenge_id TEXT PRIMARY KEY, -- namespaced 'guitar.l1.clean-run' path_id TEXT NOT NULL, level INTEGER NOT NULL, -- the level whose set this belongs to count INTEGER NOT NULL DEFAULT 0, progress_detail TEXT, -- JSON, e.g. {"seen": [...]} for distinct goals completed_at TEXT ) """) self.conn.execute(""" CREATE TABLE IF NOT EXISTS quest_state ( period_type TEXT NOT NULL, -- 'daily' | 'weekly' period_key TEXT NOT NULL, -- '2026-06-12' | '2026-W24' quest_id TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 0, reward_db INTEGER NOT NULL DEFAULT 0, -- snapshot at instantiation progress_detail TEXT, completed_at TEXT, PRIMARY KEY (period_type, period_key, quest_id) ) """) # Spend is tracked separately from xp_profile.xp on purpose: the xp # total stays the monotonic lifetime-earned stat (db_earned goals, # xp_sources reset semantics) and balance = MAX(0, xp - spent). self.conn.execute(""" CREATE TABLE IF NOT EXISTS wallet ( id INTEGER PRIMARY KEY CHECK (id = 1), spent INTEGER NOT NULL DEFAULT 0 ) """) self.conn.execute(""" CREATE TABLE IF NOT EXISTS shop_owned ( item_id TEXT PRIMARY KEY, cost_paid INTEGER NOT NULL DEFAULT 0, acquired_at TEXT ) """) self.conn.execute(""" CREATE TABLE IF NOT EXISTS shop_equipped ( slot TEXT PRIMARY KEY, -- 'theme' | 'avatar_frame' item_id TEXT ) """) # Ensure the singleton rows exist so reads never special-case "no row". self.conn.execute("INSERT OR IGNORE INTO profile (id, onboarded, created_at) VALUES (1, 0, datetime('now'))") self.conn.execute("INSERT OR IGNORE INTO profile_progress (id) VALUES (1)") self.conn.execute("INSERT OR IGNORE INTO xp_profile (id, xp, total_awards, updated_at) VALUES (1, 0, 0, datetime('now'))") self.conn.execute("INSERT OR IGNORE INTO progression_state (id, created_at) VALUES (1, datetime('now'))") self.conn.execute("INSERT OR IGNORE INTO wallet (id) VALUES (1)") self.conn.commit() self._lock = threading.Lock() # work_display (P5a) is a derived cache; True forces a (re)build on the # first grouped query and after any songs churn (put / delete / rescan). self._work_display_dirty = True # One-time repair of pre-fix rows written under URL-encoded filenames # (idempotent: a no-op once every row is canonical). self._migrate_decode_stat_filenames() def _song_exists(self, filename: str) -> bool: return self.conn.execute( "SELECT 1 FROM songs WHERE filename = ?", (filename,)).fetchone() is not None def _canonical_song_filename(self, filename: str) -> str: """Map a (possibly URL-encoded) filename to the `songs` library key. The recorder relays encodeURIComponent'd names ('/'→'%2F', ' '→'%20'), but `songs` keys on the decoded on-disk path. Decoding is LIBRARY-AWARE so a real filename that legitimately contains literal %XX is never corrupted: prefer the form that already exists in `songs`, and decode only when the decoded form resolves to a real song. When NEITHER form is in the library (e.g. a play recorded before the library scan finishes) keep the stored name unchanged — the next-startup migration canonicalizes it once the song is scanned, rather than risk corrupting a real %XX name now.""" if not isinstance(filename, str): return filename if self._song_exists(filename): return filename # already a real library key (may contain %) from urllib.parse import unquote decoded = unquote(filename) if decoded != filename and self._song_exists(decoded): return decoded # encoded → real library key return filename # neither in library: leave as-is (heals on migrate) def _migrate_decode_stat_filenames(self): """Rewrite URL-encoded song_stats.filename rows to the decoded library-path key (the form `songs` uses). Pre-fix, the recorder stored encodeURIComponent'd names, so every recorded best was invisible to the reads that filter on `filename IN (SELECT filename FROM songs)`. Merge on collision — two encoded rows decoding to the same name, or an encoded row meeting an already-decoded one — with the same best=max / plays=sum / last-wins semantics as song_score.merge_stats, so the (filename, arrangement) primary key is never violated. Library-aware via the shared _canonical_song_filename rule: only decode a row when the decoded form is a real song, so a correctly-stored name containing literal %XX is never rewritten, and dead-song/orphan rows (neither form in the library) are left exactly as-is.""" cols = self._STATS_COLS with self._lock: rows = [dict(zip(cols, r)) for r in self.conn.execute( "SELECT " + ", ".join(cols) + " FROM song_stats").fetchall()] canon = self._canonical_song_filename if all(canon(r["filename"]) == r["filename"] for r in rows): return # every row already canonical (or an untouchable orphan) merged: dict = {} for r in rows: key = (canon(r["filename"]), int(r["arrangement"])) cur = merged.get(key) if cur is None: merged[key] = dict(r, filename=key[0], arrangement=key[1]) continue # Most-recently-updated row wins the "last_*"/position fields. def _stamp(x): return str(x.get("updated_at") or x.get("last_played_at") or "") newer = r if _stamp(r) >= _stamp(cur) else cur merged[key] = { "filename": key[0], "arrangement": key[1], "plays": (cur["plays"] or 0) + (r["plays"] or 0), "best_score": max(cur["best_score"] or 0, r["best_score"] or 0), "best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0), "last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"], "last_position": newer["last_position"], "last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"], } # Atomic swap: clear and reinsert the canonicalized set in one txn. try: self.conn.execute("DELETE FROM song_stats") self.conn.executemany( "INSERT INTO song_stats (" + ", ".join(cols) + ") VALUES (" + ", ".join("?" * len(cols)) + ")", [tuple(m[c] for c in cols) for m in merged.values()], ) self.conn.commit() except Exception: self.conn.rollback() raise def is_favorite(self, filename: str) -> bool: return self.conn.execute("SELECT 1 FROM favorites WHERE filename = ?", (filename,)).fetchone() is not None def toggle_favorite(self, filename: str) -> bool: """Toggle favorite status. Returns new state.""" with self._lock: if self.is_favorite(filename): self.conn.execute("DELETE FROM favorites WHERE filename = ?", (filename,)) self.conn.commit() return False else: self.conn.execute("INSERT OR IGNORE INTO favorites VALUES (?)", (filename,)) self.conn.commit() return True # ── Personal per-song metadata: user-difficulty / notes / tags ─────────── # All keyed by the on-disk `songs` filename and kept OUT of the shared # feedpak file. Likes are the `favorites` heart, deliberately NOT duplicated # here. Reads are lock-free (WAL); writes take self._lock like the rest. def get_song_user_meta(self, filename: str) -> dict: """{'user_difficulty', 'notes', 'tags'} for one song (tags sorted).""" row = self.conn.execute( "SELECT user_difficulty, notes FROM song_user_meta WHERE filename = ?", (filename,)).fetchone() tags = [r[0] for r in self.conn.execute( "SELECT tag FROM song_tags WHERE filename = ? ORDER BY tag COLLATE NOCASE", (filename,)).fetchall()] return { "user_difficulty": (row[0] if row else None), "notes": ((row[1] if row else None) or ""), "tags": tags, } def set_song_user_meta(self, filename: str, *, user_difficulty="__keep__", notes="__keep__") -> dict: """Partial upsert of the personal fields. Pass a value to set it, None to clear it, or leave it out (sentinel `__keep__`) to preserve the current one. When nothing personal remains the row is dropped so an unset-everything leaves no empty shell. Returns the merged meta.""" with self._lock: cur = self.conn.execute( "SELECT user_difficulty, notes FROM song_user_meta WHERE filename = ?", (filename,)).fetchone() cur_diff = cur[0] if cur else None cur_notes = cur[1] if cur else None new_diff = cur_diff if user_difficulty == "__keep__" else user_difficulty new_notes = cur_notes if notes == "__keep__" else notes if new_diff is None and not (new_notes or "").strip(): self.conn.execute("DELETE FROM song_user_meta WHERE filename = ?", (filename,)) else: self.conn.execute( "INSERT INTO song_user_meta (filename, user_difficulty, notes, updated_at) " "VALUES (?, ?, ?, datetime('now')) " "ON CONFLICT(filename) DO UPDATE SET " "user_difficulty = excluded.user_difficulty, " "notes = excluded.notes, updated_at = excluded.updated_at", (filename, new_diff, (new_notes or None))) self.conn.commit() return self.get_song_user_meta(filename) def set_song_tags(self, filename: str, tags) -> list: """Replace ALL of a song's tags with the given set (each normalized; blanks + case-dupes dropped). Full-replace so the whole personal-meta blob edits as a unit. Returns the stored tag list (sorted, like reads).""" norm: list = [] seen: set = set() for t in (tags or []): nt = _normalize_tag(t) if nt and nt not in seen: seen.add(nt) norm.append(nt) # Bound the number of tags so one PUT can't write unbounded rows. # Per-tag length is already capped in _normalize_tag; cap the count too. norm = norm[:50] with self._lock: self.conn.execute("DELETE FROM song_tags WHERE filename = ?", (filename,)) if norm: self.conn.executemany( "INSERT OR IGNORE INTO song_tags (filename, tag, created_at) " "VALUES (?, ?, datetime('now'))", [(filename, t) for t in norm]) self.conn.commit() return self.get_song_user_meta(filename)["tags"] def all_tags(self) -> list: """[{tag, count}] over songs that still exist, most-used first — powers the tag filter UI. Excludes tags whose only songs were deleted.""" rows = self.conn.execute( "SELECT tag, COUNT(*) c FROM song_tags " "WHERE filename IN (SELECT filename FROM songs) " "GROUP BY tag ORDER BY c DESC, tag COLLATE NOCASE").fetchall() return [{"tag": r[0], "count": r[1]} for r in rows] def user_meta_map(self, filenames) -> dict: """Batch {filename: user_difficulty} for a page of rows (set values only). Lets query_page embed difficulty without an N+1.""" fns = list(filenames) if not fns: return {} ph = ",".join("?" * len(fns)) rows = self.conn.execute( f"SELECT filename, user_difficulty FROM song_user_meta " f"WHERE filename IN ({ph}) AND user_difficulty IS NOT NULL", fns).fetchall() return {r[0]: r[1] for r in rows} def tags_map(self, filenames) -> dict: """Batch {filename: [tags]} for a page of rows.""" fns = list(filenames) if not fns: return {} ph = ",".join("?" * len(fns)) rows = self.conn.execute( f"SELECT filename, tag FROM song_tags WHERE filename IN ({ph}) " f"ORDER BY tag COLLATE NOCASE", fns).fetchall() out: dict = {} for fn, tag in rows: out.setdefault(fn, []).append(tag) return out def purge_song_user_data(self, filename: str) -> None: """Drop all personal rows for a deleted song. Called by delete_song INSIDE the caller's `meta_db._lock` — must not re-acquire the lock.""" self.conn.execute("DELETE FROM song_user_meta WHERE filename = ?", (filename,)) self.conn.execute("DELETE FROM song_tags WHERE filename = ?", (filename,)) def batch_user_meta(self, filenames, *, set_difficulty="__keep__", add_tags=None, remove_tags=None) -> int: """Apply personal-meta edits across MANY songs in one transaction — the bulk-edit primitive behind the batch bar. Additive by design so a bulk action never silently clobbers per-song data the user can't see: - `set_difficulty`: an int 1–5 sets it on every song; `None` clears it on every song; the `__keep__` sentinel leaves each song's own value untouched (mixed-state "leave unchanged"). Notes are preserved; a row that ends up difficulty-less AND notes-less is dropped (no empty shell, matching set_song_user_meta). - `add_tags` / `remove_tags`: tag sets ADDED to / REMOVED from each song (never a full-replace — bulk must not wipe a song's other tags). A tag in both add and remove resolves to add (explicit set wins). Returns the count of songs touched. Caller normalizes tags is NOT assumed — we normalize here so the endpoint and the DB agree.""" add = [] seen: set = set() for t in (add_tags or []): nt = _normalize_tag(t) if nt and nt not in seen: seen.add(nt) add.append(nt) rem = {nt for nt in (_normalize_tag(t) for t in (remove_tags or [])) if nt} rem -= set(add) # add wins a conflict fns = list(dict.fromkeys(filenames or [])) # dedupe, keep order if not fns: return 0 with self._lock: for fn in fns: if set_difficulty != "__keep__": cur = self.conn.execute( "SELECT notes FROM song_user_meta WHERE filename = ?", (fn,)).fetchone() cur_notes = cur[0] if cur else None if set_difficulty is None and not (cur_notes or "").strip(): self.conn.execute( "DELETE FROM song_user_meta WHERE filename = ?", (fn,)) else: self.conn.execute( "INSERT INTO song_user_meta (filename, user_difficulty, notes, updated_at) " "VALUES (?, ?, ?, datetime('now')) " "ON CONFLICT(filename) DO UPDATE SET " "user_difficulty = excluded.user_difficulty, " "updated_at = excluded.updated_at", (fn, set_difficulty, cur_notes)) if rem: ph = ",".join("?" * len(rem)) self.conn.execute( f"DELETE FROM song_tags WHERE filename = ? AND tag IN ({ph})", [fn, *rem]) if add: self.conn.executemany( "INSERT OR IGNORE INTO song_tags (filename, tag, created_at) " "VALUES (?, ?, datetime('now'))", [(fn, t) for t in add]) self.conn.commit() return len(fns) # ── Player profile (fee[dB]ack v0.3.0) ───────────────────────────────── def get_profile(self) -> dict: row = self.conn.execute( "SELECT display_name, avatar_path, player_hash, onboarded FROM profile WHERE id = 1" ).fetchone() if not row: return {"display_name": None, "avatar_url": None, "player_hash": None, "onboarded": False} return { "display_name": row[0], "avatar_url": row[1], "player_hash": row[2], "onboarded": bool(row[3]), } def set_profile(self, display_name: str, avatar_url: str | None) -> dict: """Set/update the display name (+ avatar). Computes player_hash ONCE from the first name + a stored random salt; it stays stable across later name changes. Marks onboarded=1.""" with self._lock: cur = self.conn.execute( "SELECT player_hash, player_salt FROM profile WHERE id = 1" ).fetchone() player_hash = cur[0] if cur else None salt = cur[1] if cur else None if not player_hash: salt = secrets.token_hex(16) player_hash = hashlib.sha256((display_name + salt).encode("utf-8")).hexdigest() self.conn.execute( "UPDATE profile SET display_name = ?, " "avatar_path = COALESCE(?, avatar_path), " "player_hash = ?, player_salt = ?, onboarded = 1 WHERE id = 1", (display_name, avatar_url, player_hash, salt), ) self.conn.commit() return self.get_profile() # ── Unified XP store ──────────────────────────────────────────────────── def get_xp(self) -> int: row = self.conn.execute("SELECT xp FROM xp_profile WHERE id = 1").fetchone() return int(row[0]) if row else 0 def award_xp(self, amount: int, source: str | None = None) -> int: """Add XP to the unified store; returns the new total. `amount` may be NEGATIVE — used internally to REVERSE a failed award (the total and the per-source bucket both clamp at 0). `source` (when given) is tracked in the xp_sources ledger so it can be reset independently. Service boundary: the plugin hook (context["award_xp"]) passes this straight through, so coerce defensively — bad input (bool, NaN/Inf, non-integral, out-of-int64-range) must neither raise NOR mutate state. _as_int rejects bool/non-integral; bad → no-op (0).""" try: amount = _as_int(amount) except (TypeError, ValueError, OverflowError): amount = 0 amount = max(-10_000_000, min(amount, 10_000_000)) with self._lock: # MAX(0, …) clamps the result so a reversal can't drive XP negative. self.conn.execute( "UPDATE xp_profile SET xp = MAX(0, xp + ?), " "total_awards = total_awards + ?, updated_at = datetime('now') WHERE id = 1", (amount, 1 if amount > 0 else 0), ) if source: self.conn.execute( "INSERT INTO xp_sources (source, xp) VALUES (?, MAX(0, ?)) " "ON CONFLICT(source) DO UPDATE SET xp = MAX(0, xp + ?)", (source, amount, amount), ) self.conn.commit() row = self.conn.execute("SELECT xp FROM xp_profile WHERE id = 1").fetchone() return int(row[0]) if row else 0 def reset_source_xp(self, source: str) -> dict: """Subtract a single source's tracked contribution from the unified total and zero its bucket (e.g. a minigames profile-reset removes only minigames XP, leaving song-play/tutorials XP intact). Returns progress.""" with self._lock: row = self.conn.execute("SELECT xp FROM xp_sources WHERE source = ?", (source,)).fetchone() amt = int(row[0]) if row and row[0] else 0 if amt: self.conn.execute( "UPDATE xp_profile SET xp = MAX(0, xp - ?), updated_at = datetime('now') WHERE id = 1", (amt,), ) self.conn.execute("UPDATE xp_sources SET xp = 0 WHERE source = ?", (source,)) self.conn.commit() return self.get_progress() def seed_xp_once(self, amount: int, marker: str = "minigames") -> bool: """One-time seed of the unified store from a pre-unification source (e.g. the minigames plugin's profile.json), so existing earned XP is preserved. No-ops if already seeded or the store already has XP. Returns True if it seeded.""" # Same no-raise / no-silent-mutate contract as award_xp(): this is a # plugin-facing service (context["seed_xp"]). _as_int rejects bool / # non-integral; bad input becomes a 0 (no-op) seed rather than raising. try: amount = _as_int(amount) except (TypeError, ValueError, OverflowError): amount = 0 amount = max(0, min(amount, 10_000_000)) if marker != "minigames": return False with self._lock: row = self.conn.execute( "SELECT xp, minigames_seeded FROM xp_profile WHERE id = 1" ).fetchone() xp_now, seeded = (row[0], row[1]) if row else (0, 0) if seeded or xp_now > 0 or amount <= 0: if not seeded: self.conn.execute("UPDATE xp_profile SET minigames_seeded = 1 WHERE id = 1") self.conn.commit() return False self.conn.execute( "UPDATE xp_profile SET xp = ?, minigames_seeded = 1, updated_at = datetime('now') WHERE id = 1", (amount,), ) # Record the seeded amount in the source ledger too, so a later # minigames reset subtracts the migrated XP rather than orphaning it. self.conn.execute( "INSERT INTO xp_sources (source, xp) VALUES (?, ?) " "ON CONFLICT(source) DO UPDATE SET xp = xp + ?", (marker, amount, amount), ) self.conn.commit() return True # ── Streak ────────────────────────────────────────────────────────────── def record_active_day(self, today: str) -> dict: """Mark `today` (YYYY-MM-DD, local) as an active day. Any session on a calendar day keeps the streak: yesterday→+1, today→unchanged, gap or first-ever→reset to 1. Updates best_streak.""" from datetime import date, timedelta with self._lock: row = self.conn.execute( "SELECT current_streak, best_streak, last_active_date FROM profile_progress WHERE id = 1" ).fetchone() cur, best, last = (row[0], row[1], row[2]) if row else (0, 0, None) if last != today: try: yesterday = (date.fromisoformat(today) - timedelta(days=1)).isoformat() except ValueError: yesterday = None cur = cur + 1 if (last and last == yesterday) else 1 best = max(best or 0, cur) self.conn.execute( "UPDATE profile_progress SET current_streak = ?, best_streak = ?, last_active_date = ? WHERE id = 1", (cur, best, today), ) self.conn.commit() last = today return {"current_streak": cur, "best_streak": best, "last_active_date": last} def get_progress(self) -> dict: """The full profile-badge payload: XP/level (lib/xp) + streak.""" from xp import progress as _xp_progress p = self.conn.execute( "SELECT current_streak, best_streak, last_active_date FROM profile_progress WHERE id = 1" ).fetchone() cur, best, last = (p[0], p[1], p[2]) if p else (0, 0, None) out = _xp_progress(self.get_xp()) out.update({"current_streak": cur, "best_streak": best, "last_active_date": last}) return out # ── Progression (spec 010): paths, challenges, quests, wallet, shop ──── # Lock discipline: self._lock is NOT reentrant and award_xp() takes it, so # record_progression_event() applies state inside the lock but awards quest # dB (and re-enters for quest_completed goals) only after releasing it. def get_progression_state(self) -> dict: row = self.conn.execute( "SELECT calibration_status, calibration_completed_at FROM progression_state WHERE id = 1" ).fetchone() status = row[0] if row else "pending" return {"calibration_status": status, "calibration_completed_at": row[1] if row else None} def skip_calibration(self) -> dict: """pending → skipped (no-op once completed/skipped). Either way the player holds onboarding rank 1 afterwards.""" with self._lock: self.conn.execute( "UPDATE progression_state SET calibration_status = 'skipped' " "WHERE id = 1 AND calibration_status = 'pending'" ) self.conn.commit() return self.get_progression_state() def get_player_paths(self) -> dict: """{path_id: level} for every selected path.""" rows = self.conn.execute("SELECT path_id, level FROM player_paths").fetchall() return {r[0]: int(r[1]) for r in rows} def add_player_paths(self, path_ids) -> dict: """Select paths (idempotent; re-adding never resets a level).""" with self._lock: for pid in path_ids: self.conn.execute( "INSERT OR IGNORE INTO player_paths (path_id, level, selected_at) " "VALUES (?, 0, datetime('now'))", (pid,), ) self.conn.commit() return self.get_player_paths() def get_challenge_state(self) -> dict: """{challenge_id: {count, completed, detail}} for every touched challenge.""" rows = self.conn.execute( "SELECT challenge_id, count, progress_detail, completed_at FROM challenge_progress" ).fetchall() out = {} for cid, count, detail, completed_at in rows: try: parsed = json.loads(detail) if detail else None except (ValueError, TypeError): parsed = None out[cid] = { "count": int(count or 0), "completed": completed_at is not None, "completed_at": completed_at, "detail": parsed, } return out def ensure_quest_period(self, content, now) -> None: """Lazily instantiate the current daily/weekly quest rows (deterministic per period key; rewards snapshot so live quests survive content edits).""" import progression as progression_mod keys = progression_mod.period_keys(now) with self._lock: for period_type in ("daily", "weekly"): cfg = (content.get("quests") or {}).get(period_type) or {} pool = cfg.get("pool") or {} count = int(cfg.get("count") or 0) if not pool or count < 1: continue key = keys[period_type] exists = self.conn.execute( "SELECT 1 FROM quest_state WHERE period_type = ? AND period_key = ? LIMIT 1", (period_type, key), ).fetchone() if exists: continue for qid in progression_mod.select_quests(pool.keys(), period_type, key, count): self.conn.execute( "INSERT OR IGNORE INTO quest_state " "(period_type, period_key, quest_id, reward_db) VALUES (?, ?, ?, ?)", (period_type, key, qid, int(pool[qid].get("reward_db") or 0)), ) self.conn.commit() def get_quest_rows(self, period_keys_map: dict) -> list: """Current-period quest instances as snapshot/API rows.""" out = [] for period_type, key in period_keys_map.items(): rows = self.conn.execute( "SELECT quest_id, count, reward_db, progress_detail, completed_at " "FROM quest_state WHERE period_type = ? AND period_key = ? ORDER BY quest_id", (period_type, key), ).fetchall() for qid, count, reward, detail, completed_at in rows: try: parsed = json.loads(detail) if detail else None except (ValueError, TypeError): parsed = None out.append({ "period_type": period_type, "period_key": key, "quest_id": qid, "count": int(count or 0), "reward_db": int(reward or 0), "detail": parsed, "completed": completed_at is not None, "completed_at": completed_at, }) return out def get_wallet(self) -> dict: """{balance, lifetime_db, spent} — see the wallet table comment for why spend never mutates xp_profile.xp.""" import progression as progression_mod row = self.conn.execute("SELECT spent FROM wallet WHERE id = 1").fetchone() spent = int(row[0]) if row and row[0] else 0 lifetime = self.get_xp() return { "balance": progression_mod.wallet_balance(lifetime, spent), "lifetime_db": lifetime, "spent": spent, } def buy_shop_item(self, item: dict) -> tuple: """Atomic purchase: balance check + spend + ownership in one transaction. Returns ("ok"|"owned"|"insufficient", wallet).""" with self._lock: owned = self.conn.execute( "SELECT 1 FROM shop_owned WHERE item_id = ?", (item["id"],) ).fetchone() if owned: status = "owned" else: xp_row = self.conn.execute("SELECT xp FROM xp_profile WHERE id = 1").fetchone() spent_row = self.conn.execute("SELECT spent FROM wallet WHERE id = 1").fetchone() balance = max(0, int(xp_row[0] if xp_row else 0) - int(spent_row[0] if spent_row else 0)) cost = int(item.get("cost") or 0) if cost < 0: status = "invalid" elif balance < cost: status = "insufficient" else: self.conn.execute( "UPDATE wallet SET spent = spent + ? WHERE id = 1", (cost,) ) self.conn.execute( "INSERT INTO shop_owned (item_id, cost_paid, acquired_at) " "VALUES (?, ?, datetime('now'))", (item["id"], cost), ) self.conn.commit() status = "ok" return status, self.get_wallet() def get_owned_items(self) -> dict: rows = self.conn.execute( "SELECT item_id, cost_paid, acquired_at FROM shop_owned" ).fetchall() return {r[0]: {"cost_paid": int(r[1] or 0), "acquired_at": r[2]} for r in rows} def get_equipped(self) -> dict: rows = self.conn.execute("SELECT slot, item_id FROM shop_equipped").fetchall() return {r[0]: r[1] for r in rows if r[1]} def equip_item(self, slot: str, item_id) -> dict: """Equip an owned item into a slot (item_id=None unequips).""" with self._lock: if item_id is None: self.conn.execute("DELETE FROM shop_equipped WHERE slot = ?", (slot,)) else: self.conn.execute( "INSERT INTO shop_equipped (slot, item_id) VALUES (?, ?) " "ON CONFLICT(slot) DO UPDATE SET item_id = excluded.item_id", (slot, item_id), ) self.conn.commit() return self.get_equipped() def progression_snapshot(self, content, now) -> dict: """The plain-dict state view lib/progression.evaluate_event reads.""" import progression as progression_mod keys = progression_mod.period_keys(now) streak_row = self.conn.execute( "SELECT current_streak FROM profile_progress WHERE id = 1" ).fetchone() return { "calibration_status": self.get_progression_state()["calibration_status"], "paths": self.get_player_paths(), "challenges": self.get_challenge_state(), "quests": self.get_quest_rows(keys), "streak": int(streak_row[0]) if streak_row and streak_row[0] else 0, "xp_total": self.get_xp(), } def record_progression_event(self, event_type: str, payload, content, now=None, _depth: int = 0) -> dict: """The single progression choke point: evaluate one event, persist the deltas, award quest dB, and re-enter once for quest_completed goals. Returns a toast-ready summary.""" import progression as progression_mod from datetime import datetime as _dt now = now or _dt.now() self.ensure_quest_period(content, now) snapshot = self.progression_snapshot(content, now) outcome = progression_mod.evaluate_event( {"type": event_type, "payload": payload or {}}, content, snapshot ) keys = progression_mod.period_keys(now) challenge_index = content.get("challenge_index") or {} quest_pools = content.get("quests") or {} summary = { "challenges_completed": [], "quests_completed": [], "level_ups": list(outcome["level_ups"]), "calibration_completed": bool(outcome["calibration_completed"]), } with self._lock: for ch in outcome["challenges"]: detail = json.dumps(ch["detail"]) if ch.get("detail") else None self.conn.execute( "INSERT INTO challenge_progress " "(challenge_id, path_id, level, count, progress_detail, completed_at) " "VALUES (?, ?, ?, ?, ?, CASE WHEN ? THEN datetime('now') END) " "ON CONFLICT(challenge_id) DO UPDATE SET " "count = excluded.count, progress_detail = excluded.progress_detail, " "completed_at = COALESCE(challenge_progress.completed_at, excluded.completed_at)", (ch["challenge_id"], ch["path_id"], ch["level"], ch["count"], detail, 1 if ch["completed"] else 0), ) if ch["completed"]: info = challenge_index.get(ch["challenge_id"]) or {} title = (info.get("challenge") or {}).get("title") or ch["challenge_id"] summary["challenges_completed"].append( {"id": ch["challenge_id"], "title": title, "path_id": ch["path_id"]} ) for lu in outcome["level_ups"]: # Guard on the old level so a stale evaluation can't double-bump. self.conn.execute( "UPDATE player_paths SET level = ? WHERE path_id = ? AND level = ?", (lu["new_level"], lu["path_id"], lu["new_level"] - 1), ) # Only quests whose row actually TRANSITIONED to completed in this # call get rewarded/re-entered. The pure outcome was computed from # a pre-lock snapshot, so a concurrent event may have completed the # same quest first — its guarded UPDATE (completed_at IS NULL) # then touches 0 rows here, and paying it again would double-award # Decibels and double-advance quest_completed challenges. newly_completed_quests = [] for q in outcome["quests"]: detail = json.dumps(q["detail"]) if q.get("detail") else None cur = self.conn.execute( "UPDATE quest_state SET count = ?, progress_detail = ?, " "completed_at = COALESCE(completed_at, CASE WHEN ? THEN datetime('now') END) " "WHERE period_type = ? AND period_key = ? AND quest_id = ? AND completed_at IS NULL", (q["count"], detail, 1 if q["completed"] else 0, q["period_type"], keys.get(q["period_type"], ""), q["quest_id"]), ) if q["completed"] and cur.rowcount > 0: newly_completed_quests.append(q) if outcome["calibration_completed"]: self.conn.execute( "UPDATE progression_state SET calibration_status = 'completed', " "calibration_completed_at = datetime('now') " "WHERE id = 1 AND calibration_status != 'completed'" ) self.conn.commit() # Quest awards + bounded re-entry, outside the lock (award_xp locks). for q in newly_completed_quests: pool = (quest_pools.get(q["period_type"]) or {}).get("pool") or {} qdef = pool.get(q["quest_id"]) or {} summary["quests_completed"].append({ "id": q["quest_id"], "title": qdef.get("title") or q["quest_id"], "period_type": q["period_type"], "reward_db": q["reward_db"], }) if q["reward_db"]: self.award_xp(q["reward_db"], "quests") if _depth < 1: sub = self.record_progression_event( "quest_completed", {"period_type": q["period_type"], "quest_id": q["quest_id"]}, content, now=now, _depth=_depth + 1, ) summary["challenges_completed"].extend(sub["challenges_completed"]) summary["quests_completed"].extend(sub["quests_completed"]) summary["level_ups"].extend(sub["level_ups"]) summary["mastery_rank"] = progression_mod.mastery_rank( self.get_progression_state()["calibration_status"], self.get_player_paths() ) return summary # ── Per-song practice stats ───────────────────────────────────────────--- _STATS_COLS = ( "filename", "arrangement", "plays", "best_score", "best_accuracy", "last_score", "last_accuracy", "last_position", "last_played_at", "updated_at", ) def _stats_row(self, filename: str, arrangement: int) -> dict | None: r = self.conn.execute( "SELECT " + ", ".join(self._STATS_COLS) + " FROM song_stats WHERE filename = ? AND arrangement = ?", (filename, int(arrangement)), ).fetchone() return dict(zip(self._STATS_COLS, r)) if r else None # Constant SQL fragment restricting stats reads to songs that still exist. # Unconditional: a genuinely empty (but scanned) library must still hide # stale stats/playlist ghosts. We rely on `songs` NEVER being transiently # empty mid-scan — /api/rescan/full bumps mtime to force a full re-scan # rather than DELETEing rows — so the only times `songs` is empty are a # fresh install (no stats anyway) or a truly empty library (ghosts should be # hidden). Race-free orphan handling: dead-song stats are hidden here, never # deleted on scan (see delete_missing). _EXISTING_SONG_FILTER = " AND filename IN (SELECT filename FROM songs) " def _existing_song_filter(self) -> str: return self._EXISTING_SONG_FILTER # ── Artist-name canonicalization (P4) ───────────────────────────────────── # "Apply at display": resolve songs.artist through the artist_alias override # for the deduped dropdown/tree (query_artists) — else keep the raw name. The # correlated PK-lookup subquery is fine for the offset-paged catalog; the grid # FILTER instead expands a canonical name to its raw variants (index-friendly, # keyset-safe), and the grid DISPLAY re-labels rows in Python via alias_map(). _EFFECTIVE_ARTIST_SQL = ( "COALESCE((SELECT aa.canonical_name FROM artist_alias aa " "WHERE aa.raw_name = songs.artist COLLATE NOCASE), songs.artist)" ) def alias_map(self) -> dict: """{raw_name_lower: canonical_name} for every alias — one read to re-label a page of grid rows without an N+1. Lowercased keys so the lookup matches the raw artist case-insensitively (the table is COLLATE NOCASE).""" return {r[0].lower(): r[1] for r in self.conn.execute( "SELECT raw_name, canonical_name FROM artist_alias").fetchall()} def effective_artist(self, raw: str, amap: dict | None = None) -> str: """Canonical display name for a raw artist (alias override else itself).""" if raw is None: return raw amap = self.alias_map() if amap is None else amap return amap.get(raw.lower(), raw) def _single_hop_canonical(self, name: str) -> str | None: """The stored canonical for a raw name (a SINGLE hop), or None if `name` is not itself an alias key. Case-insensitive (the table is COLLATE NOCASE) — the shared primitive the chain-flatteners reuse.""" if not name: return None row = self.conn.execute( "SELECT canonical_name FROM artist_alias WHERE raw_name = ? COLLATE NOCASE", (name,)).fetchone() return row[0] if row else None def _terminal_canonical(self, name: str) -> str: """Follow the alias chain from `name` to its TERMINAL canonical — the first name that is not itself an alias key — so transitive chains (raw → mid → … → terminal) collapse to one hop. A visited-set breaks cycles: if we come back to a name already seen we return the last name reached rather than looping. Reuses the single-hop primitive.""" seen: set = set() cur = name while True: key = (cur or "").lower() if key in seen: return cur # cycle — stop, return where we are seen.add(key) nxt = self._single_hop_canonical(cur) if nxt is None or (nxt or "").lower() == key: return cur # not an alias key (or self) → terminal cur = nxt def _raw_variants_for(self, canonical: str) -> list: """Every raw artist string that should match a filter on `canonical`: the canonical name itself plus all raw names aliased to it (case-insensitive). Lets the artist filter be `artist IN (...)` — uses the artist index and is keyset-safe, instead of a per-row COALESCE subquery.""" rows = self.conn.execute( "SELECT raw_name FROM artist_alias WHERE canonical_name = ? COLLATE NOCASE", (canonical,)).fetchall() seen, out = set(), [] for name in [canonical, *[r[0] for r in rows]]: k = (name or "").lower() if name and k not in seen: seen.add(k) out.append(name) return out def list_artist_aliases(self) -> list: """All alias rows (raw → canonical), canonical then raw, for the Tidy-up 'current merges' list.""" rows = self.conn.execute( "SELECT raw_name, canonical_name, mb_artist_id FROM artist_alias " "ORDER BY canonical_name COLLATE NOCASE, raw_name COLLATE NOCASE").fetchall() return [{"raw_name": r[0], "canonical_name": r[1], "mb_artist_id": r[2]} for r in rows] def _set_artist_alias_locked(self, raw_name: str, canonical_name: str, mb_artist_id: str | None = None) -> dict: """Core upsert — assumes self._lock is HELD and does NOT commit (so the single set and the batch merge can share one transaction). Flattens chains and guards cycles: * A self-alias (raw == canonical) DROPs any existing row (the UI un-merge). * Otherwise `canonical` is resolved to its TERMINAL canonical, so setting a new hop onto an existing chain collapses to one hop rather than growing a two-hop chain that grouping/filtering would then split. * Cycle guard: if that terminal IS `raw`, storing would loop the chain back on itself — we no-op and report it so the caller can surface a failure. * Forward-flatten: any existing rows whose canonical == `raw` are re-pointed to the new terminal, so previously-merged variants follow `raw` onward. Returns a result dict {ok, raw_name, canonical_name, ...}.""" raw = (raw_name or "").strip() canon = (canonical_name or "").strip() if not raw or not canon: raise ValueError("raw_name and canonical_name are required") if raw.lower() == canon.lower(): self.conn.execute("DELETE FROM artist_alias WHERE raw_name = ? COLLATE NOCASE", (raw,)) return {"ok": True, "raw_name": raw, "canonical_name": raw, "unmerged": True} terminal = self._terminal_canonical(canon) if (terminal or "").lower() == raw.lower(): # raw → … → raw would be a cycle; refuse rather than corrupt the chain. return {"ok": False, "reason": "cycle", "raw_name": raw, "canonical_name": canon, "terminal": terminal} self.conn.execute( "INSERT INTO artist_alias (raw_name, canonical_name, mb_artist_id, updated_at) " "VALUES (?, ?, ?, datetime('now')) " "ON CONFLICT(raw_name) DO UPDATE SET " "canonical_name = excluded.canonical_name, " "mb_artist_id = excluded.mb_artist_id, updated_at = excluded.updated_at", (raw, terminal, mb_artist_id)) # Re-point any variants that were previously merged INTO raw onto the new # terminal (raw itself now aliases onward, so it can't stay a canonical). self.conn.execute( "UPDATE artist_alias SET canonical_name = ?, updated_at = datetime('now') " "WHERE canonical_name = ? COLLATE NOCASE AND raw_name != ? COLLATE NOCASE", (terminal, raw, terminal)) return {"ok": True, "raw_name": raw, "canonical_name": terminal} def set_artist_alias(self, raw_name: str, canonical_name: str, mb_artist_id: str | None = None) -> dict: """Upsert one raw→canonical override (chain-flattened, cycle-guarded — see _set_artist_alias_locked). Returns the result dict.""" with self._lock: result = self._set_artist_alias_locked(raw_name, canonical_name, mb_artist_id) self.conn.commit() return result def remove_artist_alias(self, raw_name: str) -> None: with self._lock: self.conn.execute("DELETE FROM artist_alias WHERE raw_name = ? COLLATE NOCASE", (raw_name,)) self.conn.commit() def merge_artists(self, raw_names, canonical_name: str) -> int: """Point several raw artist names at one canonical (the Tidy-up merge). Skips the canonical's own self-alias. Returns the count of aliases written. ATOMIC: the whole batch runs under one lock and one commit, so a mid-batch cycle rejection can't leave a half-applied merge.""" canon = (canonical_name or "").strip() if not canon: raise ValueError("canonical_name is required") n = 0 with self._lock: for raw in (raw_names or []): r = (raw or "").strip() if r and r.lower() != canon.lower(): result = self._set_artist_alias_locked(r, canon) if result.get("ok"): n += 1 self.conn.commit() return n def raw_artists(self, limit: int = 2000) -> list: """Distinct RAW artist names in the library with song counts + their current canonical (for the Tidy-up picker — you merge raw variants). Raw, not effective, so both 'ACDC' and 'AC/DC' show as separate mergeable rows.""" limit = max(1, min(10000, int(limit))) amap = self.alias_map() rows = self.conn.execute( "SELECT artist, COUNT(*) c FROM songs WHERE artist IS NOT NULL AND artist != '' " "GROUP BY artist COLLATE NOCASE ORDER BY c DESC, artist COLLATE NOCASE LIMIT ?", (limit,)).fetchall() return [{"name": r[0], "count": r[1], "canonical": amap.get((r[0] or "").lower(), r[0])} for r in rows] # ── Artist pages (launch charrette PR-B) ───────────────────────────────── # The artist page is "X *in your library*" — a shelf plus your relationship # to it, never a discography browser (locked position 1). Everything here # reads LOCAL rows only; the external-links layer (artist_enrichment) is a # separate lazy cache keyed by mb_artist_id. def artist_known_mb_id(self, variants: list) -> str | None: """The artist's MusicBrainz id, if any of their songs' enrichment rows carry one. Only `matched`/`manual` rows count (partial coverage is the contract — degrade gracefully); the most common id wins so one stray wrong match can't out-vote the rest of the shelf.""" if not variants: return None ph = ",".join(["?"] * len(variants)) row = self.conn.execute( f"SELECT e.mb_artist_id, COUNT(*) c FROM song_enrichment e " f"JOIN songs s ON s.filename = e.filename " f"WHERE s.artist COLLATE NOCASE IN ({ph}) " f"AND e.match_state IN ('matched', 'manual') " f"AND e.mb_artist_id IS NOT NULL AND e.mb_artist_id != '' " f"GROUP BY e.mb_artist_id ORDER BY c DESC, e.mb_artist_id LIMIT 1", variants).fetchone() return row[0] if row else None def artist_page(self, name: str) -> dict: """The all-LOCAL artist-page payload: canonical name (alias-aware), the raw variants it merges, song/album counts, the albums list, the mastered count (DENOMINATOR LAW, locked position 2: every number counts songs YOU OWN — the WHERE is `artist IN (your variants)` over `songs`, never anything external), mb_artist_id when known, header- mosaic art, similar-in-library via genre co-occurrence (locked position 3: only artists already in the library, empty → hidden), and the play-all file list. An unknown name returns a zero-count page (an unmatched artist is still a fully functional page).""" from urllib.parse import quote canonical = self._terminal_canonical((name or "").strip()) variants = self._raw_variants_for(canonical) ph = ",".join(["?"] * len(variants)) if variants else "?" rows = self.conn.execute( f"SELECT filename, title, album, year, genre FROM songs " f"WHERE title != '' AND artist COLLATE NOCASE IN ({ph}) " f"ORDER BY album COLLATE NOCASE, (track_number IS NULL) ASC, " f"COALESCE(disc, 1), track_number, title COLLATE NOCASE", variants or [canonical]).fetchall() # Albums: distinct non-empty album names in shelf order, each with the # earliest authored year, a track count, and a representative cover # song (the first row → also the mosaic's source). albums: dict = {} album_order: list = [] for fn, _t, album, year, _g in rows: key = (album or "").strip() if not key: continue k = key.lower() if k not in albums: albums[k] = {"name": key, "year": (year or ""), "count": 0, "cover": fn} album_order.append(k) albums[k]["count"] += 1 if not albums[k]["year"] and year: albums[k]["year"] = year album_list = [albums[k] for k in album_order] # "also shown as": the raw variants actually present in the library # (the canonical itself is the headline, so it's excluded). vrows = self.conn.execute( f"SELECT artist, COUNT(*) FROM songs " f"WHERE title != '' AND artist COLLATE NOCASE IN ({ph}) " f"GROUP BY artist COLLATE NOCASE ORDER BY COUNT(*) DESC", variants or [canonical]).fetchall() shown_as = [{"name": r[0], "count": r[1]} for r in vrows if (r[0] or "").lower() != (canonical or "").lower()] # Mastered / practice presence — over THIS artist's library songs only. mastered = 0 has_stats = False fns = [r[0] for r in rows] if fns: fph = ",".join(["?"] * len(fns)) srows = self.conn.execute( f"SELECT filename, MAX(best_accuracy) FROM song_stats " f"WHERE filename IN ({fph}) GROUP BY filename", fns).fetchall() has_stats = len(srows) > 0 mastered = sum(1 for _fn, acc in srows if acc is not None and acc >= MASTERY_ACCURACY) # Similar in your library: other artists sharing songs.genre values, # ranked by distinct shared genres then by how many of their songs sit # in those genres. Raw artist rows are folded through the alias map so # "ACDC" and "AC/DC" rank as one artist; self is excluded either way. genres = sorted({(r[4] or "").strip().lower() for r in rows} - {""}) similar: list = [] if genres: gph = ",".join(["?"] * len(genres)) grows = self.conn.execute( f"SELECT artist, COUNT(DISTINCT lower(genre)), COUNT(*) FROM songs " f"WHERE title != '' AND genre != '' AND lower(genre) IN ({gph}) " f"AND artist IS NOT NULL AND artist != '' " f"GROUP BY artist COLLATE NOCASE", genres).fetchall() amap = self.alias_map() agg: dict = {} for raw, shared, n in grows: canon = amap.get((raw or "").lower(), raw) if (canon or "").lower() == (canonical or "").lower(): continue cur = agg.setdefault((canon or "").lower(), {"artist": canon, "shared_genres": 0, "count": 0}) cur["shared_genres"] = max(cur["shared_genres"], shared) cur["count"] += n similar = sorted( agg.values(), key=lambda a: (-a["shared_genres"], -a["count"], (a["artist"] or "").lower()) )[:5] # Header mosaic (locked position 10: MB hosts no artist images — the # default is a mosaic of OWNED album art via the playlist-cover # grammar): one representative song per album first, then fill from # the remaining songs, up to 4. seen: set = set() art_files: list = [] for al in album_list: if al["cover"] not in seen: seen.add(al["cover"]) art_files.append(al["cover"]) if len(art_files) >= 4: break if len(art_files) < 4: for fn in fns: if fn not in seen: seen.add(fn) art_files.append(fn) if len(art_files) >= 4: break return { "artist": canonical, "variants": shown_as, "song_count": len(rows), "album_count": len(album_list), "mastered_count": mastered, "has_stats": has_stats, "albums": album_list, "mb_artist_id": self.artist_known_mb_id(variants), "similar": similar, "art_urls": [f"/api/song/{quote(fn)}/art" for fn in art_files], # Play-all seed (album/track order, same as the rows above). # Bounded so a pathological library can't balloon the payload. "files": fns[:1000], } def get_artist_enrichment(self, mb_artist_id: str) -> dict | None: """Cached artist-level enrichment row, JSON fields parsed (bad/legacy JSON degrades to empty rather than 500ing the links route).""" row = self.conn.execute( "SELECT mb_artist_id, url_rels, genres, fetched_at " "FROM artist_enrichment WHERE mb_artist_id = ?", (mb_artist_id,)).fetchone() if not row: return None def _parsed(raw, fallback): try: v = json.loads(raw) if raw else fallback except (TypeError, ValueError): return fallback return v if isinstance(v, type(fallback)) else fallback return {"mb_artist_id": row[0], "url_rels": _parsed(row[1], {}), "genres": _parsed(row[2], []), "fetched_at": row[3]} def put_artist_enrichment(self, mb_artist_id: str, url_rels: dict, genres: list) -> None: """Store (or refresh) the one artist-level cache row.""" with self._lock: self.conn.execute( "INSERT OR REPLACE INTO artist_enrichment " "(mb_artist_id, url_rels, genres, fetched_at) " "VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))", (mb_artist_id, json.dumps(url_rels or {}), json.dumps(genres or []))) self.conn.commit() def record_session(self, filename: str, arrangement: int, *, score: int, accuracy: float, last_position=None) -> dict: """Record a scored play: plays += 1, best_* = max, last_* = new.""" from song_score import merge_stats with self._lock: existing = self._stats_row(filename, int(arrangement)) merged = merge_stats(existing, { "score": score, "accuracy": accuracy, "last_position": last_position, }) self.conn.execute( """INSERT INTO song_stats (filename, arrangement, plays, best_score, best_accuracy, last_score, last_accuracy, last_position, last_played_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) ON CONFLICT(filename, arrangement) DO UPDATE SET plays = excluded.plays, best_score = excluded.best_score, best_accuracy = excluded.best_accuracy, last_score = excluded.last_score, last_accuracy = excluded.last_accuracy, last_position = excluded.last_position, last_played_at = excluded.last_played_at, updated_at = excluded.updated_at""", (filename, int(arrangement), merged["plays"], merged["best_score"], merged["best_accuracy"], merged["last_score"], merged["last_accuracy"], merged["last_position"]), ) self.conn.commit() return self._stats_row(filename, int(arrangement)) def touch_position(self, filename: str, arrangement: int, last_position: float) -> dict: """Persist just the resume position (no plays/score change), so Continue-Playing works for non-scored plays. Also stamps last_played_at — both /api/stats/recent and /api/session/continue filter/order on it, so a position-only touch must set it or the song never surfaces as 'recent' / 'continue playing'.""" with self._lock: self.conn.execute( """INSERT INTO song_stats (filename, arrangement, last_position, last_played_at, updated_at) VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) ON CONFLICT(filename, arrangement) DO UPDATE SET last_position = excluded.last_position, last_played_at = excluded.last_played_at, updated_at = excluded.updated_at""", (filename, int(arrangement), float(last_position)), ) self.conn.commit() return self._stats_row(filename, int(arrangement)) def get_song_stats(self, filename: str) -> dict: """Best/last/plays across all arrangements of a song, plus per-arrangement rows.""" rows = self.conn.execute( "SELECT " + ", ".join(self._STATS_COLS) + " FROM song_stats WHERE filename = ? ORDER BY arrangement", (filename,), ).fetchall() arr = [dict(zip(self._STATS_COLS, r)) for r in rows] best_acc = max((a["best_accuracy"] for a in arr), default=0.0) best_score = max((a["best_score"] for a in arr), default=0) plays = sum(a["plays"] for a in arr) return { "filename": filename, "best_accuracy": best_acc, "best_score": best_score, "plays": plays, "arrangements": arr, } def recent_stats(self, limit: int = 12) -> list[dict]: """Recently-played rows (most recent first) for 'Jump back in'.""" limit = max(1, min(100, int(limit))) rows = self.conn.execute( "SELECT " + ", ".join(self._STATS_COLS) + " FROM song_stats WHERE last_played_at IS NOT NULL " + self._existing_song_filter() + "ORDER BY last_played_at DESC LIMIT ?", (limit,), ).fetchall() return [dict(zip(self._STATS_COLS, r)) for r in rows] def best_accuracy_map(self) -> dict: """{filename: best_accuracy} across all arrangements, for batch-badging the library grid in one request. Includes every SCORED song (plays > 0) — even a genuine 0% best — but excludes resume-only rows (plays == 0, which carry a default best_accuracy of 0 and shouldn't badge).""" rows = self.conn.execute( "SELECT filename, MAX(best_accuracy), SUM(plays) FROM song_stats " "WHERE 1=1 " + self._existing_song_filter() + # skip dead songs (race-free) "GROUP BY filename" ).fetchall() return {r[0]: r[1] for r in rows if r[2] and r[2] > 0} def top_stats(self, limit: int = 5) -> list[dict]: """Top scored songs (best score first) for the profile 'Your best scores' panel. Aggregated per-song across arrangements (best score, best accuracy, total plays), only SCORED songs (plays > 0), dead songs skipped. Mirrors best_accuracy_map's grouping; enriched with metadata by the /api/stats/top route.""" limit = max(1, min(50, int(limit))) rows = self.conn.execute( "SELECT filename, MAX(best_score), MAX(best_accuracy), SUM(plays) " "FROM song_stats WHERE 1=1 " + self._existing_song_filter() + # skip dead songs "GROUP BY filename HAVING SUM(plays) > 0 " "ORDER BY MAX(best_score) DESC, MAX(best_accuracy) DESC LIMIT ?", (limit,), ).fetchall() return [ {"filename": r[0], "best_score": r[1], "best_accuracy": r[2], "plays": r[3]} for r in rows ] # ── FUTURE ENHANCEMENT (revisit once the feedpak difficulty spec is locked) ── # The library-metadata design (§8) calls for user-difficulty to be # PER-ARRANGEMENT ("easy on bass ≠ easy on lead") and SEEDED FROM the authored/ # derived difficulty so it's never blank. Neither ships here on purpose: # • personal difficulty is currently per-FILENAME (P1's song_user_meta); # per-arrangement is a P1-schema + Details-drawer (P2) re-scope; and # • there is NO authored/derived difficulty field on `songs` yet — that waits # on the feedpak difficulty spec (the #37-family FEP), which is unmerged. # So this recommender ships the growth-edge PAYOFF now and degrades gracefully # (an unrated song is treated as mid). When the feedpak difficulty field lands, # revisit: (1) seed unset user-difficulty from authored instead of assuming mid, # and (2) score per (filename, arrangement) rather than per song. @staticmethod def _growth_edge_score(best_accuracy: float, user_difficulty) -> float: """The 'practice next' score = difficulty-appropriateness × proximity to mastery. Peaks where a song is BOTH at a productive challenge level (the mid difficulty band) AND close to — but not yet at — mastery (the goal-gradient push). An UNSET personal difficulty is treated as mid, so the recommender still works before anything is rated (it degrades to closest-to-mastery-first) — see P3 notes: authored/derived difficulty seeding waits on the feedpak difficulty spec. diff_weight: 3 → 1.0, 2/4 → 0.8, 1/5 → 0.6 (extremes deprioritized, never zeroed — you grow on the challenging middle, not the trivially easy or the frustratingly hard). Never writes anything.""" d = user_difficulty if user_difficulty is not None else 3 weight = 1.0 - abs(d - 3) * 0.2 return weight * (best_accuracy or 0.0) def growth_edge_suggestions(self, limit: int = 8) -> list[dict]: """Attempted-but-not-yet-mastered songs ranked by the growth-edge score — the 'Keep practicing' recommender that replaces recency-only ordering. Song-level (best accuracy across arrangements, like the badge); the suggested `arrangement` is the one you're closest to mastering, so the shelf opens the version worth pushing. Read-only.""" limit = max(1, min(24, int(limit))) rows = self.conn.execute( "SELECT filename, arrangement, best_accuracy, plays, last_played_at " "FROM song_stats WHERE 1=1 " + self._existing_song_filter() ).fetchall() # Aggregate per song: best accuracy + the arrangement that owns it, total # plays, most-recent play (used as a stable tiebreak). agg: dict = {} for fn, arr, acc, plays, lp in rows: a = agg.get(fn) if a is None: a = agg[fn] = {"acc": None, "arr": 0, "plays": 0, "lp": None} a["plays"] += (plays or 0) if acc is not None and (a["acc"] is None or acc > a["acc"]): a["acc"] = acc a["arr"] = arr if lp and (not a["lp"] or lp > a["lp"]): a["lp"] = lp cands = [(fn, a) for fn, a in agg.items() if a["plays"] > 0 and a["acc"] is not None and a["acc"] < MASTERY_ACCURACY] if not cands: # Two different empties (launch polish): attempts exist but # everything attempted is mastered → an empty shelf is honest; # NOTHING attempted yet (day one) → "starter" picks instead, so # the library home invites a first play rather than dead-ending. if any(a["plays"] > 0 and a["acc"] is not None for a in agg.values()): return [] return self.starter_suggestions(limit) diffs = self.user_meta_map([fn for fn, _ in cands]) # {filename: 1..5} out = [] for fn, a in cands: d = diffs.get(fn) out.append({ "filename": fn, "best_accuracy": a["acc"], "arrangement": a["arr"], "last_played_at": a["lp"], "user_difficulty": d, "growth_score": round(self._growth_edge_score(a["acc"], d), 6), }) out.sort(key=lambda r: (r["growth_score"], r["last_played_at"] or "", r["filename"]), reverse=True) return out[:limit] def starter_suggestions(self, limit: int = 8) -> list[dict]: """Day-one 'Start here' picks for a library with no practice attempts yet: up to 8 approachable songs — sensible length (90s–480s, so intros/ jingles and 10-minute epics don't lead), shortest first, filename as a stable tiebreak. Same row shape as the growth-edge rows plus a `starter: true` marker so the client renders the invitational 'Start here' shelf instead of 'Keep practicing'. Read-only.""" limit = max(1, min(8, int(limit))) rows = self.conn.execute( "SELECT filename FROM songs WHERE title != '' " "AND duration >= 90 AND duration <= 480 " "ORDER BY duration ASC, filename ASC LIMIT ?", (limit,)).fetchall() return [{ "filename": r[0], "best_accuracy": None, "arrangement": None, "last_played_at": None, "user_difficulty": None, "growth_score": 0.0, "starter": True, } for r in rows] # ── Playlists ─────────────────────────────────────────────────────────-- SAVED_KEY = "saved_for_later" def _playlist_count(self, pid: int, kind: str | None = None) -> int: # An ALBUM keeps every slot in its denominator: get_playlist renders / # plays ALL slots — self-healing orphans and even fully-missing works # (§7.2) stay visible — so the list-card count must agree with the detail # view and skip the dead-filter (is_album → no `AND s.filename IS NOT # NULL`, mirroring get_playlist). Mixes/other kinds count only songs that # still exist (mirrors the stats read-filter — dead songs are hidden, not # deleted on scan), passing through when the songs table is empty. Single # statement → no probe-then-read race. `kind` is passed by list_playlists # (already in hand); fetched here when a caller omits it. if kind is None: row = self.conn.execute( "SELECT kind FROM playlists WHERE id = ?", (pid,) ).fetchone() kind = row[0] if row else None if kind == "album": return self.conn.execute( "SELECT COUNT(*) FROM playlist_songs WHERE playlist_id = ?", (pid,), ).fetchone()[0] return self.conn.execute( "SELECT COUNT(*) FROM playlist_songs ps WHERE ps.playlist_id = ? " "AND EXISTS (SELECT 1 FROM songs s WHERE s.filename = ps.filename)", (pid,), ).fetchone()[0] def arrangement_count(self, filename: str): """Number of arrangements for a song, or None if the song isn't in the library (so callers can skip validation when it can't be checked).""" row = self.conn.execute("SELECT arrangements FROM songs WHERE filename = ?", (filename,)).fetchone() if not row or not row[0]: return None try: arr = json.loads(row[0]) except (ValueError, TypeError): return None return len(arr) if isinstance(arr, list) else None def arrangement_entry(self, filename: str, index: int): """One arrangement's metadata dict for a library song, or None when the song/index is unknown (progression then falls back to guitar).""" row = self.conn.execute("SELECT arrangements FROM songs WHERE filename = ?", (filename,)).fetchone() if not row or not row[0]: return None try: arr = json.loads(row[0]) except (ValueError, TypeError): return None if isinstance(arr, list) and 0 <= index < len(arr) and isinstance(arr[index], dict): return arr[index] return None def list_playlists(self) -> list[dict]: from urllib.parse import quote rows = self.conn.execute( "SELECT id, name, system_key, created_at, updated_at, kind FROM playlists " "WHERE rules IS NULL " # smart collections live in the source picker, not here "ORDER BY (system_key IS NULL), name COLLATE NOCASE" ).fetchall() out = [] for r in rows: pid = r[0] # First few still-present songs (in order) → art URLs, for a # content-dependent playlist cover (single art / 2x2 mosaic). The # JOIN drops dead songs, matching get_playlist's visibility. arts = self.conn.execute( "SELECT ps.filename FROM playlist_songs ps " "JOIN songs s ON s.filename = ps.filename " "WHERE ps.playlist_id = ? ORDER BY ps.position LIMIT 4", (pid,), ).fetchall() out.append({ "id": pid, "name": r[1], "system_key": r[2], "created_at": r[3], "updated_at": r[4], "kind": r[5], "count": self._playlist_count(pid, r[5]), "art_urls": [f"/api/song/{quote(a[0])}/art" for a in arts], }) return out def create_playlist(self, name: str, system_key: str | None = None, kind: str | None = None) -> dict: with self._lock: cur = self.conn.execute( "INSERT INTO playlists (name, system_key, kind, created_at, updated_at) " "VALUES (?, ?, ?, datetime('now'), datetime('now'))", (name, system_key, kind), ) self.conn.commit() pid = cur.lastrowid return self.get_playlist(pid) def saved_playlist_id(self) -> int: """Id of the reserved Saved-for-Later playlist, created on first use. Tolerates a create race: two concurrent first-use toggles can both see no row and try to insert; the unique system_key index makes the loser raise IntegrityError, so catch it and re-read the winner's row rather than 500.""" row = self.conn.execute( "SELECT id FROM playlists WHERE system_key = ?", (self.SAVED_KEY,) ).fetchone() if row: return row[0] try: return self.create_playlist("Saved for Later", self.SAVED_KEY)["id"] except sqlite3.IntegrityError: row = self.conn.execute( "SELECT id FROM playlists WHERE system_key = ?", (self.SAVED_KEY,) ).fetchone() if row: return row[0] raise def rename_playlist(self, pid: int, name: str) -> bool: with self._lock: cur = self.conn.execute( "UPDATE playlists SET name = ?, updated_at = datetime('now') WHERE id = ?", (name, pid), ) self.conn.commit() return cur.rowcount > 0 def delete_playlist(self, pid: int) -> bool: """Delete a user playlist (system playlists are protected — caller checks).""" with self._lock: self.conn.execute("DELETE FROM playlist_songs WHERE playlist_id = ?", (pid,)) cur = self.conn.execute("DELETE FROM playlists WHERE id = ?", (pid,)) self.conn.commit() return cur.rowcount > 0 # ── Smart collections (feedBack#636 item 2) ─────────────────────────── @staticmethod def _collection_row(r) -> dict: rules = {} if r[3]: try: parsed = json.loads(r[3]) if isinstance(parsed, dict): rules = parsed except (ValueError, TypeError): rules = {} return {"id": r[0], "name": r[1], "system_key": r[2], "rules": rules, "created_at": r[4], "updated_at": r[5]} def is_collection(self, pid: int) -> bool: row = self.conn.execute( "SELECT rules IS NOT NULL FROM playlists WHERE id = ?", (pid,) ).fetchone() return bool(row and row[0]) def list_collections(self) -> list[dict]: rows = self.conn.execute( "SELECT id, name, system_key, rules, created_at, updated_at FROM playlists " "WHERE rules IS NOT NULL ORDER BY name COLLATE NOCASE" ).fetchall() return [self._collection_row(r) for r in rows] def get_collection(self, pid: int) -> dict | None: r = self.conn.execute( "SELECT id, name, system_key, rules, created_at, updated_at FROM playlists " "WHERE id = ? AND rules IS NOT NULL", (pid,) ).fetchone() return self._collection_row(r) if r else None def create_collection(self, name: str, rules: dict) -> dict: with self._lock: cur = self.conn.execute( "INSERT INTO playlists (name, system_key, rules, created_at, updated_at) " "VALUES (?, NULL, ?, datetime('now'), datetime('now'))", (name, json.dumps(rules or {})), ) self.conn.commit() pid = cur.lastrowid return self.get_collection(pid) def update_collection(self, pid: int, name: str | None = None, rules: dict | None = None) -> dict | None: if not self.is_collection(pid): return None with self._lock: if name is not None: self.conn.execute("UPDATE playlists SET name = ? WHERE id = ?", (name, pid)) if rules is not None: self.conn.execute("UPDATE playlists SET rules = ? WHERE id = ?", (json.dumps(rules or {}), pid)) self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,)) self.conn.commit() return self.get_collection(pid) def get_playlist(self, pid: int) -> dict | None: # A path-param int outside SQLite's 64-bit range raises OverflowError at # bind time (→ 500). Treat it as a miss; every mutating playlist handler # gates on this first, so the guard covers them too. if not isinstance(pid, int) or not (-(2**63) <= pid < 2**63): return None # `rules IS NULL` excludes smart collections (#636 item 2): they share # the playlists table but their membership is rules-based, so every # manual-playlist mutation (add/remove/reorder/cover) that gates on # get_playlist uniformly 404s on a collection id — collections are # managed only through /api/collections. head = self.conn.execute( "SELECT id, name, system_key, created_at, updated_at, kind FROM playlists " "WHERE id = ? AND rules IS NULL", (pid,) ).fetchone() if not head: return None is_album = head[5] == "album" # Mixes hide dead songs (race-free; not deleted on scan). An ALBUM keeps # every slot: a slot whose pinned chart was deleted self-heals to the # work's current preferred at READ (§7.2 orphan-at-play — never a # membership rewrite), and reports `missing` when the whole work is gone # so the practice set keeps its denominator visible. dead_filter = "" if is_album else "AND s.filename IS NOT NULL" rows = self.conn.execute( f"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name, ps.arrangement, ps.work_key, s.arrangements, (s.filename IS NULL) AS dead FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename WHERE ps.playlist_id = ? {dead_filter} ORDER BY ps.position, ps.filename""", (pid,), ).fetchall() from urllib.parse import quote songs = [] for r in rows: entry = { "filename": r[0], "position": r[1], "title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "", "art_url": f"/api/song/{quote(r[0])}/art", } if is_album: entry["arrangement"] = r[5] entry["work_key"] = r[6] try: entry["arrangements"] = _ensure_smart_names(json.loads(r[7]) if r[7] else []) except Exception: entry["arrangements"] = [] if r[8]: entry.update(self._resolve_album_orphan(r[6])) songs.append(entry) return { "id": head[0], "name": head[1], "system_key": head[2], "created_at": head[3], "updated_at": head[4], "songs": songs, **({"kind": head[5]} if head[5] else {}), } def _resolve_album_orphan(self, work_key: str | None) -> dict: """A deleted album slot resolves to its work's CURRENT preferred/auto pick at read (§7.2): the slot plays `resolved_filename` today, and if the pinned file reappears (rescan) it simply resolves back to itself — no rewrite in either direction. A work with no charts left reports `missing` (the row stays, dimmed, so the set's denominator is honest).""" if work_key: self._ensure_work_display() row = self.conn.execute( "SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements " "FROM work_display wd JOIN songs s ON s.filename = wd.filename " "WHERE wd.effective_work_key = ? AND wd.is_group_representative = 1", (work_key,)).fetchone() if row: from urllib.parse import quote try: arrs = _ensure_smart_names(json.loads(row[4]) if row[4] else []) except Exception: arrs = [] return {"resolved_filename": row[0], "title": row[1] or row[0], "artist": row[2] or "", "tuning_name": row[3] or "", "arrangements": arrs, "art_url": f"/api/song/{quote(row[0])}/art", "resolved_from_orphan": True} return {"missing": True} def add_playlist_song(self, pid: int, filename: str): with self._lock: # Re-check existence INSIDE the lock: the handler's earlier 404 check # is a separate step, so a concurrent delete_playlist could land # between them and leave an orphan playlist_songs row. Returning None # lets the handler answer 404 instead of inserting an orphan. row = self.conn.execute("SELECT kind FROM playlists WHERE id = ?", (pid,)).fetchone() if not row: return None # Album slots stamp the work identity at ADD time (§7.2 "resolved to # preferred once at add, pinned thereafter") — it's what lets a # later-deleted chart's slot self-heal to the work's current keeper. wk = self.work_key_for(filename) if row[0] == "album" else None nxt = self.conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_songs WHERE playlist_id = ?", (pid,) ).fetchone()[0] cur = self.conn.execute( "INSERT OR IGNORE INTO playlist_songs (playlist_id, filename, position, work_key) " "VALUES (?, ?, ?, ?)", (pid, filename, nxt, wk), ) self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,)) self.conn.commit() return cur.rowcount > 0 _SLOT_KEEP = object() # sentinel: "leave the arrangement pin unchanged" def update_playlist_slot(self, pid: int, filename: str, new_filename: str | None = None, arrangement=_SLOT_KEEP): """Edit ONE album slot in place (§7.2): pin/clear its arrangement (a NAME — names survive rescans; None clears back to full-song) and/or swap the slot's chart for another chart of the SAME work, keeping position + pin — the per-slot pick is deliberately independent of the work's global preferred. Returns the slot's (possibly new) filename, or None when the slot doesn't exist, the swap target isn't a chart of the slot's work, or it's already in the playlist.""" with self._lock: row = self.conn.execute( "SELECT position, work_key FROM playlist_songs " "WHERE playlist_id = ? AND filename = ?", (pid, filename)).fetchone() if not row: return None out_fn = filename if new_filename and new_filename != filename: # Same-work guard: the stored stamp wins (works even when the # pinned file is gone); fall back to computing from the row. wk_slot = row[1] or self.work_key_for(filename) if not wk_slot or self.work_key_for(new_filename) != wk_slot: return None if self.conn.execute( "SELECT 1 FROM playlist_songs WHERE playlist_id = ? AND filename = ?", (pid, new_filename)).fetchone(): return None self.conn.execute( "UPDATE playlist_songs SET filename = ?, work_key = ? " "WHERE playlist_id = ? AND filename = ?", (new_filename, wk_slot, pid, filename)) out_fn = new_filename if arrangement is not self._SLOT_KEEP: self.conn.execute( "UPDATE playlist_songs SET arrangement = ? " "WHERE playlist_id = ? AND filename = ?", (arrangement, pid, out_fn)) self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,)) self.conn.commit() return out_fn def remove_playlist_song(self, pid: int, filename: str) -> bool: with self._lock: cur = self.conn.execute( "DELETE FROM playlist_songs WHERE playlist_id = ? AND filename = ?", (pid, filename) ) self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,)) self.conn.commit() return cur.rowcount > 0 def reorder_playlist(self, pid: int, ordered_filenames: list[str]) -> bool: with self._lock: for pos, fn in enumerate(ordered_filenames): self.conn.execute( "UPDATE playlist_songs SET position = ? WHERE playlist_id = ? AND filename = ?", (pos, pid, fn), ) self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,)) self.conn.commit() return True def toggle_saved(self, filename: str) -> bool: """Add/remove a song on the Saved-for-Later playlist. Returns new state. The presence check and the add/remove run under one lock so two concurrent toggles of the same song can't both take the add path (or both remove) and leave an inconsistent saved state.""" pid = self.saved_playlist_id() with self._lock: present = self.conn.execute( "SELECT 1 FROM playlist_songs WHERE playlist_id = ? AND filename = ?", (pid, filename) ).fetchone() is not None if present: self.conn.execute( "DELETE FROM playlist_songs WHERE playlist_id = ? AND filename = ?", (pid, filename)) new_state = False else: nxt = self.conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_songs WHERE playlist_id = ?", (pid,) ).fetchone()[0] self.conn.execute( "INSERT OR IGNORE INTO playlist_songs (playlist_id, filename, position) VALUES (?, ?, ?)", (pid, filename, nxt)) new_state = True self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,)) self.conn.commit() return new_state # ── Wishlist / "wanted" (feedBack#636 item 4) ───────────────────────── _WANTED_COLS = ("id", "artist", "title", "source", "source_ref", "note", "created_at") def add_wanted(self, artist: str, title: str, source: str = "manual", source_ref: str = "", note: str = "") -> dict: """Add a not-owned song to the wishlist (or return the existing row if an entry with the same identity is already wanted — idempotent, so a re-run of an ownership-diff doesn't duplicate). Returns the row.""" artist = (artist or "").strip() title = (title or "").strip() source = (source or "manual").strip() or "manual" source_ref = (source_ref or "").strip() note = (note or "").strip() with self._lock: self.conn.execute( "INSERT OR IGNORE INTO wanted (artist, title, source, source_ref, note, created_at) " "VALUES (?, ?, ?, ?, ?, datetime('now'))", (artist, title, source, source_ref, note), ) row = self.conn.execute( "SELECT " + ", ".join(self._WANTED_COLS) + " FROM wanted " "WHERE artist = ? COLLATE NOCASE AND title = ? COLLATE NOCASE " "AND source = ? AND source_ref = ?", (artist, title, source, source_ref), ).fetchone() self.conn.commit() return dict(zip(self._WANTED_COLS, row)) if row else {} def list_wanted(self) -> list[dict]: """All wishlist entries, newest first.""" rows = self.conn.execute( "SELECT " + ", ".join(self._WANTED_COLS) + " FROM wanted " "ORDER BY created_at DESC, id DESC" ).fetchall() return [dict(zip(self._WANTED_COLS, r)) for r in rows] def remove_wanted(self, wanted_id: int) -> bool: """Drop a wishlist entry by id. Returns True if a row was removed.""" with self._lock: cur = self.conn.execute("DELETE FROM wanted WHERE id = ?", (wanted_id,)) self.conn.commit() return cur.rowcount > 0 def count_wanted(self) -> int: return self.conn.execute("SELECT COUNT(*) FROM wanted").fetchone()[0] def continue_session(self) -> dict | None: """Most-recently-played song (from song_stats) + metadata, for the Continue-Playing card. Null when nothing has been played.""" row = self.conn.execute( "SELECT filename, arrangement, last_position FROM song_stats " "WHERE last_played_at IS NOT NULL " + self._existing_song_filter() + # skip dead songs (race-free) "ORDER BY last_played_at DESC LIMIT 1" ).fetchone() if not row: return None filename, arrangement, last_position = row meta = self.conn.execute( "SELECT title, artist, tuning_name, duration FROM songs WHERE filename = ?", (filename,) ).fetchone() title, artist, tuning_name, duration = meta if meta else (None, None, None, None) from urllib.parse import quote return { "filename": filename, "arrangement": arrangement, "title": title or filename, "artist": artist or "", "tuning_name": tuning_name or "", "duration": duration or 0, "last_position": last_position, "art_url": f"/api/song/{quote(filename)}/art", } def favorite_set(self) -> set[str]: return {r[0] for r in self.conn.execute("SELECT filename FROM favorites").fetchall()} def get(self, filename: str, mtime: float, size: int) -> dict | None: cache_key = str(filename) with self._lock: row = self.conn.execute( "SELECT mtime, size, title, artist, album, year, duration, tuning, arrangements, has_lyrics, " "format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets " "FROM songs WHERE filename = ?", (cache_key,) ).fetchone() if row and row[0] == mtime and row[1] == size and row[2]: return { "title": row[2], "artist": row[3], "album": row[4], "year": row[5], "duration": row[6], "tuning": row[7], "arrangements": json.loads(row[8]) if row[8] else [], "has_lyrics": bool(row[9]), "format": row[10] or "archive", "stem_count": int(row[11] or 0), "stem_ids": json.loads(row[12]) if row[12] else [], "tuning_name": row[13] or "", "tuning_sort_key": int(row[14] or 0), "tuning_offsets": row[15] or "", } return None def put(self, filename: str, mtime: float, size: int, meta: dict): with self._lock: self.conn.execute( "INSERT OR REPLACE INTO songs " "(filename, mtime, size, title, artist, album, year, duration, tuning, arrangements, " "has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (filename, mtime, size, meta.get("title", ""), meta.get("artist", ""), meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0), meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])), 1 if meta.get("has_lyrics") else 0, meta.get("format", "archive"), int(meta.get("stem_count", 0) or 0), json.dumps(meta.get("stem_ids", []) or []), meta.get("tuning_name", "") or "", int(meta.get("tuning_sort_key", 0) or 0), meta.get("tuning_offsets", "") or "", meta.get("genre", "") or "", meta.get("track_number"), meta.get("disc")), ) self.conn.commit() # A song's identity may have changed → the grouping read-model is stale. self._work_display_dirty = True def count(self) -> int: return self.conn.execute("SELECT COUNT(*) FROM songs WHERE title != ''").fetchone()[0] def delete_missing(self, current_filenames: set[str]): """Remove `songs` rows for files no longer on disk. Deliberately does NOT purge song_stats / playlist_songs here: a scan is a point-in-time snapshot, so a song that briefly disappears mid-scan (e.g. a directory-form .sloppak being overwritten via rmtree-then-extract, or a delete+reupload) and returns under the same filename would otherwise lose its stats/playlist membership permanently. Instead, stats are purged on the EXPLICIT delete path (DELETE /api/song) and dead-song rows are filtered at read time (recent_stats / continue_session / best_accuracy_map gate on the song still existing).""" with self._lock: db_files = {r[0] for r in self.conn.execute("SELECT filename FROM songs").fetchall()} stale = db_files - current_filenames if stale: self.conn.executemany("DELETE FROM songs WHERE filename = ?", [(f,) for f in stale]) self.conn.commit() self._work_display_dirty = True # membership changed → regroup # Report both deltas from the one query we already ran: rows pruned, # and how many current files are genuinely new (not yet in the DB), # so a scan can surface an "N added / M removed" summary. return {"removed": len(stale), "added": len(current_filenames - db_files)} # ── Metadata enrichment (P7 — plumbing; the matcher itself is the next # slice) ───────────────────────────────────────────────────────────────── @staticmethod def enrichment_content_hash(artist, title, album, duration) -> str: """Identity hash of the metadata a match keys on — normalized artist|title|album|duration. Deliberately excludes the filename, so a renamed pack keeps its enrichment (rename-survivable), and an unchanged hash makes re-enrichment a no-op (idempotent). Whitespace/case-folded so trivial edits don't invalidate a match; duration is rounded to whole seconds for the same reason.""" def norm(s): return " ".join(str(s or "").lower().split()) try: dur = str(int(round(float(duration or 0)))) except (TypeError, ValueError): dur = "0" raw = "|".join([norm(artist), norm(title), norm(album), dur]) return hashlib.sha1(raw.encode("utf-8")).hexdigest() def enrichment_pending(self, limit: int = 500, allowed_keys: frozenset | None = None) -> list[dict]: """Songs whose enrichment row needs (re)matching: no row yet, or a row whose content_hash no longer matches the song's current metadata (an edit changed the identity → re-match), or an `unscanned` row. `manual` rows are the user's pinned pick and are NEVER re-queued. `matched`/`review`/`failed` rows with an UNCHANGED hash are settled here — a review row stands until the user acts, and a failed row retries only via the matcher's backoff policy (enrichment_failed_rows) rather than being re-queued every pass. An identity edit (say, the user fixes the typo that made matching fail) re-queues any of them immediately via the hash mismatch. `allowed_keys` is the set of per-field auto-apply toggle keys that are currently ON. A `matched` row stamped while one of those fields was suppressed (its key in `apply_mask`) is re-queued for backfill, so re-enabling a field honours the same "nothing forfeited" contract the source/art toggles already keep. None = don't apply the mask rule (the caller isn't the field-aware matcher, e.g. a plain count).""" # Read under _lock: the worker commits on this shared connection under # _lock, so an unlocked SELECT could interleave with its execute+commit. with self._lock: rows = self.conn.execute( "SELECT s.filename, s.artist, s.title, s.album, s.year, s.duration, " "e.content_hash, e.match_state, e.apply_mask " "FROM songs s LEFT JOIN song_enrichment e ON e.filename = s.filename " "WHERE s.title != '' AND (e.filename IS NULL " "OR e.match_state IN ('unscanned', 'matched', 'review', 'failed')) " "ORDER BY s.filename LIMIT ?", (max(1, int(limit)),)).fetchall() out = [] for fn, artist, title, album, year, duration, ehash, state, mask in rows: h = self.enrichment_content_hash(artist, title, album, duration) # No row yet, still unmatched, or the identity changed under a # settled row → needs the matcher. A settled row with an # unchanged hash stays settled (idempotence)… needs = state is None or state == "unscanned" or ehash != h # …EXCEPT a `matched` row that suppressed a field now re-enabled: # re-queue it so the newly-allowed field gets backfilled. if not needs and state == "matched" and allowed_keys is not None and mask: if {k for k in mask.split(",") if k} & allowed_keys: needs = True if needs: out.append({"filename": fn, "artist": artist, "title": title, "album": album, "year": year, "duration": duration, "content_hash": h, "match_state": state}) return out def upsert_enrichment_stub(self, filename: str, content_hash: str) -> None: """Write/refresh a row's identity hash ahead of matching. A row whose hash changed drops back to `unscanned` (the old match no longer applies) — EXCEPT a `manual` row, which is the user's explicit pick and survives metadata edits untouched.""" with self._lock: # Idempotence: skip the UPDATE/commit when the upsert would be a # no-op. The no-op matcher (P7) re-stamps every pending row each # pass; without this guard an already-settled row would be # rewritten every ~5 min, N commits/pass contending with request # writes. A `manual` pick never changes here, and a non-manual row # whose hash already matches keeps its state+hash — both no-ops. cur = self.conn.execute( "SELECT content_hash, match_state FROM song_enrichment WHERE filename = ?", (filename,)).fetchone() if cur is not None: old_hash, state = cur if state == "manual" or old_hash == content_hash: return self.conn.execute( "INSERT INTO song_enrichment (filename, content_hash, match_state) " "VALUES (?, ?, 'unscanned') " "ON CONFLICT(filename) DO UPDATE SET " " match_state = CASE WHEN song_enrichment.match_state = 'manual' " " THEN song_enrichment.match_state " " WHEN song_enrichment.content_hash IS NOT excluded.content_hash " " THEN 'unscanned' " " ELSE song_enrichment.match_state END, " # An identity change restarts the failure backoff too — the # accumulated attempts belonged to the OLD identity (e.g. the # user just fixed the typo that made matching fail). " attempts = CASE WHEN song_enrichment.match_state = 'manual' " " THEN song_enrichment.attempts " " WHEN song_enrichment.content_hash IS NOT excluded.content_hash " " THEN 0 " " ELSE song_enrichment.attempts END, " " content_hash = CASE WHEN song_enrichment.match_state = 'manual' " " THEN song_enrichment.content_hash " " ELSE excluded.content_hash END", (filename, content_hash)) self.conn.commit() def get_enrichment(self, filename: str) -> dict | None: # Read under _lock (shared write connection — see enrichment_pending). with self._lock: row = self.conn.execute( "SELECT filename, content_hash, match_state, match_source, match_score, attempts, " "mb_recording_id, mb_release_id, mb_artist_id, isrc, " "canon_artist, canon_album, canon_title, canon_year, canon_artist_sort, " "genres, art_cache_path, art_state, fetched_at, candidates, last_attempt_at, " "apply_mask " "FROM song_enrichment WHERE filename = ?", (filename,)).fetchone() if not row: return None keys = ("filename", "content_hash", "match_state", "match_source", "match_score", "attempts", "mb_recording_id", "mb_release_id", "mb_artist_id", "isrc", "canon_artist", "canon_album", "canon_title", "canon_year", "canon_artist_sort", "genres", "art_cache_path", "art_state", "fetched_at", "candidates", "last_attempt_at", "apply_mask") out = dict(zip(keys, row)) for k in ("genres", "candidates"): try: out[k] = json.loads(out[k]) if out[k] else [] except (ValueError, TypeError): out[k] = [] return out def enrichment_state_counts(self) -> dict: """{match_state: count} over rows whose song still exists (dead rows are filtered at read time, matching the never-purged-on-rescan contract).""" # Read under _lock (shared write connection — see enrichment_pending). with self._lock: rows = self.conn.execute( "SELECT e.match_state, COUNT(*) FROM song_enrichment e " "JOIN songs s ON s.filename = e.filename GROUP BY e.match_state").fetchall() return {r[0]: r[1] for r in rows} def enrichment_song_row(self, filename: str) -> dict | None: """The identity fields the matcher/scorer keys on, for one song.""" row = self.conn.execute( "SELECT filename, artist, title, album, year, duration " "FROM songs WHERE filename = ?", (filename,)).fetchone() if not row: return None return dict(zip(("filename", "artist", "title", "album", "year", "duration"), row)) def enrichment_failed_rows(self, limit: int = 500) -> list[dict]: """`failed` rows that MAY retry, with the fields the backoff policy (worker-side) needs to decide eligibility. `rejected` rows are the user's explicit "none of these" — never auto-retried (an identity edit re-queues them through enrichment_pending's hash mismatch instead).""" rows = self.conn.execute( "SELECT s.filename, s.artist, s.title, s.album, s.year, s.duration, " "e.attempts, e.last_attempt_at " "FROM songs s JOIN song_enrichment e ON e.filename = s.filename " "WHERE s.title != '' AND e.match_state = 'failed' " "AND COALESCE(e.match_source, '') != 'rejected' " "ORDER BY s.filename LIMIT ?", (max(1, int(limit)),)).fetchall() out = [] for fn, artist, title, album, year, duration, attempts, last_at in rows: out.append({"filename": fn, "artist": artist, "title": title, "album": album, "year": year, "duration": duration, "content_hash": self.enrichment_content_hash(artist, title, album, duration), "attempts": attempts or 0, "last_attempt_at": last_at}) return out def enrichment_cache_lookup(self, content_hash: str, exclude_filename: str = "") -> dict | None: """A settled match for the same identity hash — another chart of the same recording already matched/pinned → copy it, no network (design §5 step 1: the local match-cache). Only FULLY-applied donors qualify (apply_mask empty/NULL): a row that suppressed a display field under an auto-apply toggle would otherwise seed siblings with its blanks even when the reader's own toggles want that field — so a partial row is skipped and the sibling falls through to its own (re-filtered) match.""" row = self.conn.execute( "SELECT match_score, mb_recording_id, mb_release_id, mb_artist_id, isrc, " "canon_artist, canon_album, canon_title, canon_year, canon_artist_sort, genres " "FROM song_enrichment WHERE content_hash = ? AND filename != ? " "AND match_state IN ('matched', 'manual') AND mb_recording_id IS NOT NULL " "AND COALESCE(apply_mask, '') = '' " "LIMIT 1", (content_hash, exclude_filename or "")).fetchone() if not row: return None try: genres = json.loads(row[10]) if row[10] else [] except (ValueError, TypeError): genres = [] return { "score": row[0], "recording_id": row[1], "release_id": row[2] or "", "artist_id": row[3] or "", "isrc": row[4] or "", "artist": row[5] or "", "album": row[6] or "", "title": row[7] or "", "year": row[8] or "", "artist_sort": row[9] or "", "genres": genres, } def apply_enrichment_match(self, filename: str, content_hash: str, state: str, source: str | None = None, score: float | None = None, cand: dict | None = None, candidates: list | None = None, bump_attempts: bool = False, allow_manual_overwrite: bool = False, apply_mask: str | None = None) -> bool: """The single writer for every matcher/review outcome. Writes the full lifecycle row: state + source + score, the canonical fields a confident match supplies (`cand`), and/or the review tier's ranked `candidates`. Returns False without touching anything when the row is `manual` and the caller isn't explicitly acting for the user — the never-overwrite-manual contract lives HERE so no future call path can forget it. Art-cache fields are preserved verbatim (they belong to the art slice, not the matcher). `apply_mask` (blocked per-field keys, from the matcher) is stamped verbatim so enrichment_pending / enrichment_cache_lookup can tell a fully-applied match from a field-suppressed one; the review/manual writers leave it NULL (a confirmed pick applies in full).""" cand = cand or {} now = time.time() with self._lock: cur = self.conn.execute( "SELECT match_state, attempts, art_cache_path, art_state, fetched_at " "FROM song_enrichment WHERE filename = ?", (filename,)).fetchone() if cur and cur[0] == "manual" and not allow_manual_overwrite: return False # An explicit reset to `unscanned` (Refresh metadata) is a fresh # start — the failure backoff restarts with the identity, same as # the stub upsert's hash-change rule. attempts = 0 if state == "unscanned" else (int(cur[1] or 0) if cur else 0) if bump_attempts: attempts += 1 fetched_at = (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) if state in ("matched", "manual", "review") else (cur[4] if cur else None)) self.conn.execute( "INSERT OR REPLACE INTO song_enrichment (filename, content_hash, " "match_state, match_source, match_score, attempts, " "mb_recording_id, mb_release_id, mb_artist_id, isrc, " "canon_artist, canon_album, canon_title, canon_year, canon_artist_sort, " "genres, art_cache_path, art_state, fetched_at, candidates, last_attempt_at, " "apply_mask) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (filename, content_hash, state, source, score, attempts, cand.get("recording_id") or None, cand.get("release_id") or None, cand.get("artist_id") or None, cand.get("isrc") or None, cand.get("artist") or None, cand.get("album") or None, cand.get("title") or None, cand.get("year") or None, cand.get("artist_sort") or None, json.dumps(cand.get("genres") or []) if cand else "[]", cur[2] if cur else None, cur[3] if cur else None, fetched_at, json.dumps(candidates) if candidates else None, now if state == "failed" else None, apply_mask or None)) self.conn.commit() return True def set_enrichment_manual(self, filename: str, cand: dict, source: str = "search") -> bool: """User-pinned match (review Accept / manual search-and-pick). The highest-authority state: never auto-reset, survives identity edits. `source` records HOW it was pinned ('review' = accepted a proposed candidate, 'search' = picked from a manual search).""" song = self.enrichment_song_row(filename) if not song: return False h = self.enrichment_content_hash( song["artist"], song["title"], song["album"], song["duration"]) return self.apply_enrichment_match( filename, h, "manual", source=source, score=1.0, cand=cand, allow_manual_overwrite=True) def set_enrichment_rejected(self, filename: str) -> bool: """User said "none of these candidates" — clear any canonical values and park the row as failed/rejected (never auto-retried; an identity edit re-queues it). Refused for `manual` rows: un-pinning a pick the user explicitly made is not a review-drawer action.""" row = self.get_enrichment(filename) if not row or row["match_state"] not in ("review", "matched"): return False return self.apply_enrichment_match( filename, row["content_hash"], "failed", source="rejected", score=None, candidates=row.get("candidates") or None) def enrichment_review_queue(self, limit: int = 200, order: str = "missing_first") -> list[dict]: """The Match-Review drawer's queue: review-tier rows joined to their (still-existing) songs, with the stored candidate list parsed. `order` is the user's review-queue preference: 'missing_first' (default — charts missing album/year surface first, they gain the most from a confirm; complete charts only stand to be re-labelled), 'artist' (A–Z), or 'recent' (newest files first). Unknown values fall back to missing_first.""" order_sql = { "artist": "s.artist COLLATE NOCASE, s.title COLLATE NOCASE, e.filename", "recent": "s.mtime DESC, e.filename", }.get(order, "((COALESCE(s.album, '') = '') + (COALESCE(s.year, '') = '')) DESC, " "s.artist COLLATE NOCASE, s.title COLLATE NOCASE, e.filename") rows = self.conn.execute( "SELECT e.filename, s.title, s.artist, s.album, s.year, s.duration, s.mtime, " "e.match_score, e.candidates, e.attempts " "FROM song_enrichment e JOIN songs s ON s.filename = e.filename " "WHERE e.match_state = 'review' " "ORDER BY " + order_sql + " " "LIMIT ?", (max(1, int(limit)),)).fetchall() out = [] for fn, title, artist, album, year, duration, mtime, score, cands, attempts in rows: try: candidates = json.loads(cands) if cands else [] except (ValueError, TypeError): candidates = [] out.append({"filename": fn, "title": title, "artist": artist, "album": album, "year": year, "duration": duration, "mtime": mtime, "match_score": score, "candidates": candidates, "attempts": attempts or 0}) return out def enrichment_art_pending(self, limit: int = 500) -> list[dict]: """Matched songs whose cover-art situation hasn't been evaluated yet (art_state NULL). The art worker resolves each to 'pack' (song has its own art), 'user' (an override exists), 'caa' (fetched), 'none' (the release has no cover) or 'error' — any of which settles the row, so this never re-offers a song each pass.""" rows = self.conn.execute( "SELECT e.filename, e.mb_release_id " "FROM song_enrichment e JOIN songs s ON s.filename = e.filename " "WHERE e.match_state IN ('matched', 'manual') " "AND e.mb_release_id IS NOT NULL AND e.art_state IS NULL " "ORDER BY e.filename LIMIT ?", (max(1, int(limit)),)).fetchall() return [{"filename": r[0], "mb_release_id": r[1]} for r in rows] def set_enrichment_art(self, filename: str, path: str | None, state: str | None) -> None: """Stamp a row's art-cache outcome. Targeted UPDATE (not the match writer) so it can never disturb the match lifecycle fields.""" with self._lock: self.conn.execute( "UPDATE song_enrichment SET art_cache_path = ?, art_state = ? " "WHERE filename = ?", (path, state, filename)) self.conn.commit() def clear_enrichment_art_paths(self, paths: list[str]) -> None: """Reset rows whose cached art file was evicted (LRU prune) back to unevaluated, so a later pass may re-fetch if the song still qualifies.""" if not paths: return with self._lock: ph = ",".join("?" * len(paths)) self.conn.execute( f"UPDATE song_enrichment SET art_cache_path = NULL, art_state = NULL " f"WHERE art_cache_path IN ({ph})", paths) self.conn.commit() def _estd_set(self) -> set[str]: """Get set of filenames that have a retuned variant (_EStd_ or _DropD_) in the DB.""" rows = self.conn.execute( "SELECT filename FROM songs WHERE filename LIKE '%\\_EStd\\_%' ESCAPE '\\' " "OR filename LIKE '%\\_DropD\\_%' ESCAPE '\\'" ).fetchall() originals = set() for (fname,) in rows: originals.add(fname.replace("_EStd_", "_").replace("_DropD_", "_")) return originals # Manifest-allowed filter values. Whitelisted before binding so a # malformed query string can't push arbitrary text through to SQL — # parameters are bound, but capping the input space is still cheap # defense-in-depth (see feedBack#129). _ALLOWED_ARRANGEMENT_NAMES = {"Lead", "Rhythm", "Bass", "Combo"} # Per-smart-type list of (sql_op, sql_param) pairs appended to the SQL # name-fallback branch (key-absent smart_name). Covers legacy raw names # and load_song()'s synthesised display names that map to each smart type. _SMART_NULL_FALLBACK_EXTRAS: dict[str, tuple[tuple[str, str], ...]] = { "Lead": (("=", "Combo"), ("LIKE", "Alt. Combo%"), ("LIKE", "Bonus Combo%")), "Bass": (("=", "Bass 2"),), } # Stem ids match the bare strings sloppak manifests use today — # `full`, `guitar`, `bass`, `drums`, `vocals`, `piano`, `other`. The # frontend filter UI omits `full` (it's the always-on fallback mix # and would match every sloppak), but the server-side whitelist # keeps it so a hand-rolled API client can still ask for it. _ALLOWED_STEM_IDS = {"full", "guitar", "bass", "drums", "vocals", "piano", "other"} @classmethod def _smart_null_extras(cls, arr_type: str) -> tuple[str, list[str]]: """Return (sql_fragment, bound_params) for the extra raw-name terms to OR into the key-absent NULL-smart_name fallback branch for arr_type. Empty when no extras are defined.""" terms = cls._SMART_NULL_FALLBACK_EXTRAS.get(arr_type, ()) fragment = "".join( f" OR json_extract(value, '$.name') {op} ?" for op, _ in terms ) return fragment, [val for _, val in terms] def _build_where(self, q: str = "", favorites_only: bool = False, format_filter: str = "", artist_filter: str = "", album_filter: str = "", arrangements_has: list[str] | None = None, arrangements_lacks: list[str] | None = None, stems_has: list[str] | None = None, stems_lacks: list[str] | None = None, has_lyrics: int | None = None, tunings: list[str] | None = None, mastery: list[str] | None = None, tags_has: list[str] | None = None, user_difficulty_in: list[str] | None = None, match_states: list[str] | None = None, genre: list[str] | None = None, naming_mode: str = "legacy", include_intrinsic: bool = True) -> tuple[str, list]: """Shared WHERE-clause builder for query_page / query_artists / query_stats. Returns (where_sql, params). Leading 'WHERE' is included so callers paste it directly. See feedBack#129/#69. Clauses are two classes (the §7.1 filter law): work-identity + practice-state predicates live here; CHART-INTRINSIC predicates (format / arrangements / stems / lyrics / tuning) are built by `_build_intrinsic_where` and appended when `include_intrinsic`. Grouped queries pass include_intrinsic=False and re-apply the intrinsic set as a match-if-ANY-member subquery instead. """ where = "WHERE title != ''" params: list = [] if favorites_only: where += " AND filename IN (SELECT filename FROM favorites)" if artist_filter: # The dropdown/tree list CANONICAL names (query_artists), so a filter # value is canonical — expand it to every raw variant aliased to it so # picking "AC/DC" returns songs tagged "ACDC" too. `artist IN (...)` # keeps the artist index (keyset-safe), unlike a per-row COALESCE. variants = self._raw_variants_for(artist_filter) ph = ",".join(["?"] * len(variants)) where += f" AND artist COLLATE NOCASE IN ({ph})" params += variants if album_filter: where += " AND album = ? COLLATE NOCASE" params.append(album_filter) # Genre facet (primary genre column, populated from the feedpak `genres` # list on scan). OR within the selected set. if genre: _gph = ",".join(["?"] * len(genre)) where += f" AND genre COLLATE NOCASE IN ({_gph})" params += list(genre) # Mastery bands = best accuracy across a song's arrangements (song_stats, # a separate table -> correlated subquery). mastered >= 0.9, in_progress = # attempted but < 0.9, not_started = no score. OR within the selected set. if mastery: _msub = "(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename)" _bands = { "mastered": f"{_msub} >= 0.9", "in_progress": f"({_msub} IS NOT NULL AND {_msub} < 0.9)", "not_started": f"{_msub} IS NULL", } _sel = [_bands[b] for b in mastery if b in _bands] if _sel: where += " AND (" + " OR ".join(_sel) + ")" # Personal practice tags (song_tags) — any-of. EXISTS-style IN keeps it a # predicate on `songs` (keyset-safe, no row multiplication). Normalized to # match how tags are stored. _tags = [t for t in (_normalize_tag(x) for x in (tags_has or [])) if t] if _tags: ph = ",".join(["?"] * len(_tags)) where += (" AND filename IN (SELECT filename FROM song_tags " f"WHERE tag IN ({ph}))") params += _tags # Personal user-difficulty (song_user_meta) — any-of over the 1..5 set. _diffs = [] for d in (user_difficulty_in or []): try: di = int(d) except (TypeError, ValueError): continue if 1 <= di <= 5: _diffs.append(di) if _diffs: ph = ",".join(["?"] * len(_diffs)) where += (" AND filename IN (SELECT filename FROM song_user_meta " f"WHERE user_difficulty IN ({ph}))") params += _diffs # Match facet (P8) = the song's enrichment lifecycle state, from the # separate song_enrichment table (same EXISTS idiom as mastery above). # 'matched' folds in 'manual' (a user pin IS a match); 'pending' means # no verdict yet (no row, or still unscanned). OR within the set. if match_states: _esub = "SELECT 1 FROM song_enrichment e WHERE e.filename = songs.filename" _mstates = { "review": f"EXISTS ({_esub} AND e.match_state = 'review')", "matched": f"EXISTS ({_esub} AND e.match_state IN ('matched', 'manual'))", "unmatched": f"EXISTS ({_esub} AND e.match_state = 'failed')", "pending": f"NOT EXISTS ({_esub} AND e.match_state != 'unscanned')", } _msel = [_mstates[b] for b in match_states if b in _mstates] if _msel: where += " AND (" + " OR ".join(_msel) + ")" if q: _qlike = f"%{q}%" _qterms = ("title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE " "OR album LIKE ? COLLATE NOCASE") _qparams = [_qlike] * 3 # Alias-aware artist term (launch polish): searching the CANONICAL # name ("AC/DC") must also find songs whose raw tag is a merged # variant ("ACDC") — expand via the artist_alias table. Pure # predicate (keyset-safe); probe-guarded so the common no-aliases # library keeps the exact original 3-term query. if self.conn.execute("SELECT 1 FROM artist_alias LIMIT 1").fetchone() is not None: _qterms += (" OR artist COLLATE NOCASE IN (SELECT raw_name FROM artist_alias " "WHERE canonical_name LIKE ? COLLATE NOCASE)") _qparams.append(_qlike) where += f" AND ({_qterms})" params += _qparams if include_intrinsic: ifrag, iparams = self._build_intrinsic_where( "songs", format_filter=format_filter, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode) where += ifrag params += iparams return where, params def _build_intrinsic_where(self, alias: str, format_filter: str = "", arrangements_has: list[str] | None = None, arrangements_lacks: list[str] | None = None, stems_has: list[str] | None = None, stems_lacks: list[str] | None = None, has_lyrics: int | None = None, tunings: list[str] | None = None, naming_mode: str = "legacy") -> tuple[str, list]: """CHART-INTRINSIC predicates (format / arrangements / stems / lyrics / tuning) as ' AND …' fragments against an explicit table alias. Flat queries apply them to `songs` directly; grouped queries evaluate them against each work member `m` inside an EXISTS (§7.1 filter law — a work matches when ANY of its charts does, so a song you own in Drop D isn't hidden because your preferred chart is E Standard).""" where = "" params: list = [] if format_filter: where += f" AND {alias}.format = ?" params.append(format_filter) # arrangements_has / arrangements_lacks: OR within axis (any-of). # Uses JSON1's json_each which yields one row per arrangement, then # matches the relevant field. The whole subquery is wrapped in EXISTS # so we don't multiply rows in the outer SELECT. # # Smart mode: each requested type (Lead/Rhythm/Bass) matches against # smart_name when present. "Lead" matches smart_name in # ('Lead', 'Alt. Lead', 'Alt. Lead N', 'Bonus Lead', 'Bonus Lead N'). # Falls back to matching `name` for older rows without smart_name. # Legacy mode: matches `name` directly (original behaviour). arr_has = [a for a in (arrangements_has or []) if a in self._ALLOWED_ARRANGEMENT_NAMES] if arr_has and naming_mode == "smart": # Smart mode subsumes "Combo" into "Lead" — normalize here so a # hand-rolled API client matches the client-side behaviour and # the SQL doesn't need a "Combo" smart-type branch. arr_has = list(dict.fromkeys("Lead" if a == "Combo" else a for a in arr_has)) if arr_has: if naming_mode == "smart": clauses = [] for arr_type in arr_has: # Extra raw-name fragments matched only in the key-absent # NULL-smart_name fallback branch — they cover the legacy # display names that map to this smart type: # Lead: "Combo" (combined guitar) + Alt./Bonus Combo # Bass: "Bass 2" (load_song synthesises for real_bass_22) extra_null, extra_null_params = self._smart_null_extras(arr_type) # json_type() returns NULL when the key is absent and the # string 'null' when the key exists with explicit JSON null # (set by the scanner for ambiguous duplicate-name rows). # Name-fallback only applies to key-absent rows so an # explicit null suppresses the fallback and lets the # background rescan resolve the ambiguity authoritatively. clauses.append( "(json_extract(value, '$.smart_name') IS NOT NULL AND (" f"json_extract(value, '$.smart_name') = ? OR " f"json_extract(value, '$.smart_name') LIKE ? OR " f"json_extract(value, '$.smart_name') LIKE ?" ")) OR (" "json_type(value, '$.smart_name') IS NULL AND (" "json_extract(value, '$.name') = ? OR " "json_extract(value, '$.name') LIKE ? OR " f"json_extract(value, '$.name') LIKE ?{extra_null}))" ) params += [ arr_type, f"Alt. {arr_type}%", f"Bonus {arr_type}%", arr_type, f"Alt. {arr_type}%", f"Bonus {arr_type}%", ] + extra_null_params where += ( f" AND EXISTS (SELECT 1 FROM json_each({alias}.arrangements) WHERE " + " OR ".join(f"({c})" for c in clauses) + ")" ) else: placeholders = ",".join(["?"] * len(arr_has)) where += (f" AND EXISTS (SELECT 1 FROM json_each({alias}.arrangements) " f"WHERE json_extract(value, '$.name') IN ({placeholders}))") params += arr_has arr_lacks = [a for a in (arrangements_lacks or []) if a in self._ALLOWED_ARRANGEMENT_NAMES] if arr_lacks and naming_mode == "smart": arr_lacks = list(dict.fromkeys("Lead" if a == "Combo" else a for a in arr_lacks)) if arr_lacks: if naming_mode == "smart": clauses = [] for arr_type in arr_lacks: extra_null, extra_null_params = self._smart_null_extras(arr_type) # See "has" branch above for the json_type rationale. # Extra branch (vs `has`): an explicit smart_name=null # arrangement is ambiguous; we don't know whether it's # `arr_type` or not. Be conservative and treat it as # potentially matching, so `arrangements_lacks` excludes # the parent row instead of falsely claiming it lacks # `arr_type`. The background rescan resolves the ambiguity. clauses.append( "(json_extract(value, '$.smart_name') IS NOT NULL AND (" f"json_extract(value, '$.smart_name') = ? OR " f"json_extract(value, '$.smart_name') LIKE ? OR " f"json_extract(value, '$.smart_name') LIKE ?" ")) OR (" "json_type(value, '$.smart_name') = 'null'" ") OR (" "json_type(value, '$.smart_name') IS NULL AND (" "json_extract(value, '$.name') = ? OR " "json_extract(value, '$.name') LIKE ? OR " f"json_extract(value, '$.name') LIKE ?{extra_null}))" ) params += [ arr_type, f"Alt. {arr_type}%", f"Bonus {arr_type}%", arr_type, f"Alt. {arr_type}%", f"Bonus {arr_type}%", ] + extra_null_params where += ( f" AND NOT EXISTS (SELECT 1 FROM json_each({alias}.arrangements) WHERE " + " OR ".join(f"({c})" for c in clauses) + ")" ) else: placeholders = ",".join(["?"] * len(arr_lacks)) where += (f" AND NOT EXISTS (SELECT 1 FROM json_each({alias}.arrangements) " f"WHERE json_extract(value, '$.name') IN ({placeholders}))") params += arr_lacks stems_h = [s for s in (stems_has or []) if s in self._ALLOWED_STEM_IDS] if stems_h: placeholders = ",".join(["?"] * len(stems_h)) where += (f" AND EXISTS (SELECT 1 FROM json_each({alias}.stem_ids) " f"WHERE value IN ({placeholders}))") params += stems_h stems_l = [s for s in (stems_lacks or []) if s in self._ALLOWED_STEM_IDS] if stems_l: placeholders = ",".join(["?"] * len(stems_l)) where += (f" AND NOT EXISTS (SELECT 1 FROM json_each({alias}.stem_ids) " f"WHERE value IN ({placeholders}))") params += stems_l if has_lyrics in (0, 1): where += f" AND {alias}.has_lyrics = ?" params.append(has_lyrics) if tunings: # Keep the input cap conservative (32) so a hostile caller # can't blow out the parameter list. Real tuning sets in the # wild number in the low double digits. tn = [t for t in tunings if isinstance(t, str) and t][:32] if tn: placeholders = ",".join(["?"] * len(tn)) # Match the same grouping key tuning_names() returns so a single # "Custom Tuning" pill selects exactly its offset set while named # tunings still match by name. where += (f" AND {_tuning_group_key_sql(alias)} " f"COLLATE NOCASE IN ({placeholders})") params += tn return where, params # Under group=1, chart-intrinsic filters match if ANY member of the work # matches (§7.1 filter law). A pure predicate on the representative scan — # no GROUP BY, no row multiplication — so the keyset cursor stays valid. def _grouped_member_match(self, intrinsic_frag: str, intrinsic_params: list) -> tuple[str, list]: if not intrinsic_frag: return "", [] return ((" AND EXISTS (SELECT 1 FROM songs m JOIN work_display mw ON mw.filename = m.filename " "WHERE mw.effective_work_key = (SELECT w0.effective_work_key FROM work_display w0 " "WHERE w0.filename = songs.filename)" + intrinsic_frag + ")"), list(intrinsic_params)) # ── Multi-chart grouping engine (P5a) ──────────────────────────────────── @staticmethod def _norm_token(s, fold_the=False): """Fold a name to a comparison token: strip diacritics + punctuation + whitespace, lowercase, optionally drop a leading 'the ' (artist names).""" import re import unicodedata raw = str(s or "") s = unicodedata.normalize("NFKD", raw) s = "".join(c for c in s if not unicodedata.combining(c)).lower() if fold_the: s = re.sub(r"^the\s+", "", s) folded = re.sub(r"[^a-z0-9]+", "", s) if folded: return folded # All-non-Latin titles (CJK/Cyrillic/Greek/Arabic) fold to "" above, # which would collapse every such song into one bogus work. Fall back to # the raw text lowercased with whitespace collapsed so distinct titles # keep distinct keys. Latin names always hit the `folded` branch, so # their behavior is unchanged. return re.sub(r"\s+", " ", raw.strip().lower()) @classmethod def _work_key(cls, artist, title) -> str: """Identity of a musical WORK = normalize(artist)+'|'+normalize(title). Recording-MBID identity is a later enrichment upgrade (§3); this text key groups the common 'same song, several charts' case now.""" return cls._norm_token(artist, fold_the=True) + "|" + cls._norm_token(title) def _alias_map_if_exists(self) -> dict: """{raw_artist_lower: canonical} from P4's artist_alias when that table is present, so work_key groups across artist aliases (ACDC/AC/DC) once P4 is merged; {} (→ raw artist) when it isn't. Forward-compatible, no hard P4 dep.""" try: rows = self.conn.execute("SELECT raw_name, canonical_name FROM artist_alias").fetchall() except Exception: return {} return {r[0].lower(): r[1] for r in rows} @staticmethod def _pick_representative(members: list, prefs: dict) -> str: """The keeper chart of a group: the user's chart_group_pref when its file is present, else auto-pick = MOST-PLAYED (history-sticky, §7.1: real practice wins — a newer/'more complete' import must not silently take the pick from the chart your reps accrued on, and a one-off try of an alternate can't out-rank a practiced incumbent) → most-complete (arrangements) → newest → filename. An all-unplayed group therefore still picks by completeness. `members` = dicts {fn, wk, arr, plays, mtime}.""" if members: pref = prefs.get(members[0]["wk"]) if pref and any(m["fn"] == pref for m in members): return pref best = min(members, key=lambda m: (-m["plays"], -m["arr"], -m["mtime"], m["fn"])) return best["fn"] def _load_work_members(self): """Read songs + overrides → ({effective_work_key: [member dicts]}, prefs).""" amap = self._alias_map_if_exists() splits = dict(self.conn.execute( "SELECT filename, split_key FROM chart_group_split").fetchall()) prefs = dict(self.conn.execute( "SELECT work_key, preferred_filename FROM chart_group_pref").fetchall()) plays = dict(self.conn.execute( "SELECT filename, SUM(plays) FROM song_stats GROUP BY filename").fetchall()) groups: dict = {} for fn, artist, title, arr_json, mtime in self.conn.execute( "SELECT filename, artist, title, arrangements, mtime FROM songs WHERE title != ''"): wk = self._work_key(amap.get((artist or "").lower(), artist), title) eff = splits.get(fn) or wk try: arr = len(json.loads(arr_json)) if arr_json else 0 except Exception: arr = 0 groups.setdefault(eff, []).append( {"fn": fn, "wk": wk, "arr": arr, "plays": int(plays.get(fn) or 0), "mtime": mtime or 0}) return groups, prefs def rebuild_work_display(self) -> None: """Full re-materialization of work_display from songs + the override tables. O(n) — cheap enough to run lazily after any songs churn.""" with self._lock: groups, prefs = self._load_work_members() out = [] for eff, members in groups.items(): rep = self._pick_representative(members, prefs) n = len(members) for m in members: out.append((m["fn"], m["wk"], eff, 1 if m["fn"] == rep else 0, n)) self.conn.execute("DELETE FROM work_display") if out: self.conn.executemany( "INSERT INTO work_display (filename, work_key, effective_work_key, " "is_group_representative, group_size) VALUES (?, ?, ?, ?, ?)", out) self.conn.commit() self._work_display_dirty = False def _ensure_work_display(self) -> None: """(Re)build the read-model when a change marked it dirty (or it's never been built). Called at the top of every grouped query.""" if getattr(self, "_work_display_dirty", True): self.rebuild_work_display() def work_key_for(self, filename: str): """work_key of a song (from its current artist+title), or None if absent.""" row = self.conn.execute( "SELECT artist, title FROM songs WHERE filename = ?", (filename,)).fetchone() if not row: return None amap = self._alias_map_if_exists() return self._work_key(amap.get((row[0] or "").lower(), row[0]), row[1]) def set_chart_preferred(self, work_key: str, filename: str) -> None: """Pick the keeper chart of a work. Incremental: re-flips is_group_representative within the work's (non-split) group only — group_size is unchanged — so no full rebuild.""" with self._lock: self.conn.execute( "INSERT INTO chart_group_pref (work_key, preferred_filename, updated_at) " "VALUES (?, ?, datetime('now')) " "ON CONFLICT(work_key) DO UPDATE SET " "preferred_filename = excluded.preferred_filename, updated_at = excluded.updated_at", (work_key, filename)) if not self._work_display_dirty: members = [r[0] for r in self.conn.execute( "SELECT filename FROM work_display WHERE effective_work_key = ?", (work_key,)).fetchall()] if filename in members: self.conn.execute( "UPDATE work_display SET is_group_representative = " "CASE WHEN filename = ? THEN 1 ELSE 0 END " "WHERE effective_work_key = ?", (filename, work_key)) else: # pref target isn't a current member (orphan/split) — reconcile # on the next lazy rebuild rather than leave it half-applied. self._work_display_dirty = True self.conn.commit() def clear_chart_preferred(self, work_key: str) -> None: """Reset a work to auto-pick; lazy full rebuild.""" with self._lock: self.conn.execute("DELETE FROM chart_group_pref WHERE work_key = ?", (work_key,)) self._work_display_dirty = True self.conn.commit() def split_chart(self, filename: str) -> None: """'These aren't the same' — give a chart a unique split_key so it stands alone as a singleton work. Lazy full rebuild (the old group's membership + sizes shift).""" wk = self.work_key_for(filename) or filename with self._lock: self.conn.execute( "INSERT INTO chart_group_split (filename, split_key, updated_at) " "VALUES (?, ?, datetime('now')) " "ON CONFLICT(filename) DO UPDATE SET " "split_key = excluded.split_key, updated_at = excluded.updated_at", (filename, f"{wk}#split#{filename}")) self._work_display_dirty = True self.conn.commit() def unsplit_chart(self, filename: str) -> None: """Undo a split — the chart rejoins its work. Lazy full rebuild.""" with self._lock: self.conn.execute("DELETE FROM chart_group_split WHERE filename = ?", (filename,)) self._work_display_dirty = True self.conn.commit() def work_charts(self, work_key: str) -> dict: """Every chart in a work (P5b) — the Charts drawer's data. Members are the work's CURRENT (non-split) group: work_display rows whose effective_work_key matches. Each carries its effective title/artist, arrangements, tuning, format, best accuracy, and the representative/preferred flags so the drawer can label 'Preferred — your pick' vs 'Preferred (auto)'.""" self._ensure_work_display() amap = self._alias_map_if_exists() pref_row = self.conn.execute( "SELECT preferred_filename FROM chart_group_pref WHERE work_key = ?", (work_key,)).fetchone() pref_fn = pref_row[0] if pref_row else None rows = self.conn.execute( "SELECT wd.filename, wd.is_group_representative, s.title, s.artist, s.album, s.year, " "s.arrangements, s.tuning_name, s.tuning, s.format, " "(SELECT MAX(best_accuracy) FROM song_stats st WHERE st.filename = wd.filename AND st.plays > 0) " "FROM work_display wd JOIN songs s ON s.filename = wd.filename " "WHERE wd.effective_work_key = ? " "ORDER BY wd.is_group_representative DESC, s.title COLLATE NOCASE, s.filename", (work_key,)).fetchall() charts = [] for fn, is_rep, title, artist, album, year, arr_json, tuning_name, tuning, fmt, best in rows: try: arrangements = _ensure_smart_names(json.loads(arr_json) if arr_json else []) except Exception: arrangements = [] charts.append({ "filename": fn, "title": title or fn, "artist": amap.get((artist or "").lower(), artist) or "", "album": album or "", "year": year or "", "arrangements": arrangements, "tuning_name": tuning_name or "", "tuning": tuning or "", "format": fmt or "archive", "best_accuracy": best, "is_representative": bool(is_rep), "is_preferred": (fn == pref_fn), }) return { "work_key": work_key, "count": len(charts), "preferred_filename": pref_fn, # Whether the keeper is your explicit pick or the auto-pick — drives the # drawer's "Preferred — your pick" vs "Preferred (auto)" label. "preferred_source": "user" if pref_fn else "auto", "charts": charts, } def chart_work(self, filename: str) -> dict: """The work a chart belongs to (P5d): its EFFECTIVE work_key (a split chart resolves to its own singleton key) + how many charts share it. Lets an opener resolve group membership for rows that didn't come from a grouped query — the tree view's rows ride the ungrouped artists endpoint, so they carry no chart_count/work_key annotation.""" key = self._canonical_song_filename(filename) self._ensure_work_display() row = self.conn.execute( "SELECT effective_work_key, group_size FROM work_display WHERE filename = ?", (key,)).fetchone() if not row: return {"filename": key, "work_key": None, "chart_count": 0, "is_split": False} split = self.conn.execute( "SELECT 1 FROM chart_group_split WHERE filename = ?", (key,)).fetchone() return {"filename": key, "work_key": row[0], "chart_count": row[1], "is_split": bool(split)} # Predicate that narrows a query to one representative chart per work — the # keyset-safe grouping filter (see query_page / query_stats). _GROUP_REP_PREDICATE = " AND filename IN (SELECT filename FROM work_display WHERE is_group_representative = 1)" def query_page(self, q: str = "", page: int = 0, size: int = 24, sort: str = "artist", direction: str = "asc", favorites_only: bool = False, format_filter: str = "", artist_filter: str = "", album_filter: str = "", arrangements_has: list[str] | None = None, arrangements_lacks: list[str] | None = None, stems_has: list[str] | None = None, stems_lacks: list[str] | None = None, has_lyrics: int | None = None, tunings: list[str] | None = None, mastery: list[str] | None = None, tags_has: list[str] | None = None, user_difficulty_in: list[str] | None = None, match_states: list[str] | None = None, genre: list[str] | None = None, after: str | None = None, group: bool = False, naming_mode: str = "legacy") -> tuple[list[dict], int]: """Server-side paginated search. Returns (songs, total_count). `after` is an opaque keyset cursor (the last row of the previous page). When supplied and the sort can keyset, the page is fetched with a WHERE-seek instead of OFFSET — O(page), independent of depth. Unknown sorts / bad cursors fall back to OFFSET, so it's always safe. `group` collapses a work's charts to one card (P5a): it adds a single `WHERE is_group_representative = 1` predicate over the materialized work_display, so the total counts WORKS not charts and the keyset seek / sort / A–Z all stay correct over the representative subset. Each grouped row carries `chart_count` (the ⚑ N). Filter law under grouping (P5e, §7.1): work-identity (artist/album/q) + practice-state (favorites/mastery/tags/difficulty) predicates stay on the representative row (identity ≈ the work; practice-state anchors on the preferred chart), while CHART-INTRINSIC predicates (format/ arrangements/stems/lyrics/tuning) match if ANY member of the work does — and when the representative itself doesn't match, the row carries a `display_chart` override so the card can show/play the matching one.""" where, params = self._build_where( q=q, favorites_only=favorites_only, format_filter=format_filter, artist_filter=artist_filter, album_filter=album_filter, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, has_lyrics=has_lyrics, tunings=tunings, mastery=mastery, tags_has=tags_has, user_difficulty_in=user_difficulty_in, match_states=match_states, genre=genre, naming_mode=naming_mode, include_intrinsic=not group, ) ifrag, iparams = "", [] if group: self._ensure_work_display() ifrag, iparams = self._build_intrinsic_where( "m", format_filter=format_filter, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode) mfrag, mparams = self._grouped_member_match(ifrag, iparams) where += mfrag params += mparams where += self._GROUP_REP_PREDICATE sort_map = { # Artist sorts order WITHIN an artist by title (the tree view's # artist -> album -> title feel) instead of raw filename — the # "list is organised, cards look random" report. Direction is # baked per entry (the legacy `dir=desc` append would otherwise # land on the title term); title stays ascending under Z->A. "artist": "artist COLLATE NOCASE ASC, title COLLATE NOCASE ASC", "artist-desc": "artist COLLATE NOCASE DESC, title COLLATE NOCASE ASC", "title": "title COLLATE NOCASE", "title-desc": "title COLLATE NOCASE DESC", "recent": "mtime DESC", # Tuning sort uses musical distance from E Standard # (feedBack#22 — was alphabetical). `tuning_sort_key` is # the sum of per-string offsets, so |sort_key| is the # magnitude of the down/up-tune. ABS ascending puts E # Standard (0) first, then ±2 (Drop D, F Standard), then # ±6 (Eb Standard, F# Standard), and so on. Within a # magnitude tier we break ties by signed key ASC so the # negative (down-tuned) variant comes before the positive # (up-tuned) one — Eb Standard before F Standard, matching # how the app groups its tuning list. Final tiebreak by # name keeps the order fully deterministic. # # Leading term pushes pre-migration / unscanned rows to # the bottom — without it ABS(0) collides with E # Standard's 0 and unindexed rows would sort first. # COALESCE on every column the clause references guards # against NULL values — SQLite's literal-constant ADD # COLUMN does backfill on most versions, but raw SQL # inserts that bypass `put()`, edge-case migration paths, # or future code that writes None could still leave NULLs # behind, and a NULL `tuning_name` in `(tuning_name = '')` # evaluates to NULL itself (which sorts ahead of 0 in # ASC), defeating the push-to-bottom intent. "tuning": ( "(COALESCE(tuning_name, '') = '') ASC, " "ABS(COALESCE(tuning_sort_key, 0)), " "COALESCE(tuning_sort_key, 0) ASC, " "COALESCE(tuning_name, '') COLLATE NOCASE" ), # Year sort (feedBack#128). Empty-year rows pushed to the # bottom for both directions; otherwise CAST so '2010' > # '2005' rather than alphabetic. "year": "(year = '') ASC, CAST(year AS INTEGER) ASC", "year-desc": "(year = '') ASC, CAST(year AS INTEGER) DESC", # Album track order: authored track number (disc, then track); songs # with no number fall to the bottom, ordered by title. Used by the # album detail view. Alpha-by-title is the fallback when unauthored. "track": "(track_number IS NULL) ASC, COALESCE(disc, 1), track_number, title COLLATE NOCASE", # Mastery = best accuracy across a song's arrangements, from the # separate song_stats table (so via a correlated subquery — this sort # drops to OFFSET paging, like tuning/year). Unscored ("not started") # songs push to the BOTTOM in both directions (the IS NULL term); # ascending is "needs practice first" (weakest measured first), # descending is "most mastered first". "mastery": ( "((SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) IS NULL) ASC, " "(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) ASC" ), "mastery-desc": ( "((SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) IS NULL) ASC, " "(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) DESC" ), } if group and sort in ("mastery", "mastery-desc"): # Sort law (§7.1): mastery aggregates MAX across the WHOLE group — # a song surfaces on any chart you've touched, even when the # preferred chart is unplayed. Mastery never keysets (OFFSET # paging), so the aggregate can't disturb a cursor. The recency # ("Recently Added") aggregate is deliberately NOT applied: mtime # IS a keyset sort, so its aggregate would need materializing into # work_display to stay cursor-safe — deferred until wanted (the # auto-pick's `newest` factor already surfaces new charts of # unplayed works; played works stay put by the sticky rule). _gm = ("(SELECT MAX(st.best_accuracy) FROM song_stats st " "JOIN work_display sw ON sw.filename = st.filename " "WHERE sw.effective_work_key = (SELECT w1.effective_work_key " "FROM work_display w1 WHERE w1.filename = songs.filename))") sort_map["mastery"] = f"({_gm} IS NULL) ASC, {_gm} ASC" sort_map["mastery-desc"] = f"({_gm} IS NULL) ASC, {_gm} DESC" # Fold the legacy `dir=desc` toggle into the canonical sort key BEFORE # the lookup, so the ORDER BY is built from the effective sort — mirrors # what `_effective_keyset_sort` does on the cursor side. Needed because # the artist clause now bakes in `ASC` (for the title secondary), so the # ` DESC` append below is suppressed and would otherwise silently ignore # `sort=artist&dir=desc` (return A→Z). Only artist/title fold (they have # `-desc` twins); tuning/year/mastery keep their own dir handling. eff = _effective_keyset_sort(sort, direction) order = sort_map.get(eff, "artist COLLATE NOCASE") # Legacy `dir=desc` toggle: only safe to append on simple sort # clauses that don't already encode a direction. Compound / # multi-term entries above (artist, tuning, year, year-desc) bake their # ASC/DESC into the clause, so a global ` DESC` append would # produce invalid SQL like `CAST(year AS INTEGER) ASC DESC`. # Skip the append in that case — clients flipping direction on # those sorts use the explicit `-desc` sort key instead. (For # artist/title the fold above already picked the `-desc` clause.) if direction == "desc" and " ASC" not in order and " DESC" not in order: order += " DESC" # Unique, deterministic tiebreak → a TOTAL order. Without it, rows with # an equal sort key can reshuffle between OFFSET pages (skip/dupe); it's # also what makes keyset seeking correct. order += ", filename" # Grouped reads filter through the materialized work_display (the # `is_group_representative=1` predicate). rebuild_work_display does # DELETE→INSERT→commit under self._lock, so a lock-free reader on # another thread (shared conn, check_same_thread=False) could land its # SELECT in the mid-rebuild window and see 0 rows. Hold self._lock # across the representative COUNT+SELECT so it can't overlap a rebuild. # _ensure_work_display already rebuilt above under its own lock (and # self._lock is NOT reentrant), so we must NOT nest it here. Ungrouped # reads stay lock-free (WAL) via nullcontext. read_guard = self._lock if group else contextlib.nullcontext() with read_guard: total = self.conn.execute(f"SELECT COUNT(*) FROM songs {where}", params).fetchone()[0] cols = ("SELECT filename, title, artist, album, year, duration, tuning, " "arrangements, has_lyrics, mtime, format, stem_count, stem_ids, " "tuning_name, tuning_offsets FROM songs ") cursor = _decode_cursor(after) if after else None eff_sort = _effective_keyset_sort(sort, direction) if cursor and eff_sort in _KEYSET_SORTS: # Keyset seek: rows strictly after the cursor in the total order # ` , filename ASC` (NULL-aware, so == OFFSET exactly). col, collate, primary_dir = _KEYSET_SORTS[eff_sort] seek, seek_params = _keyset_seek(col, collate, primary_dir, cursor[0], cursor[1]) seek_where = where + (" AND " if where else " WHERE ") + seek rows = self.conn.execute( f"{cols}{seek_where} ORDER BY {order} LIMIT ?", params + seek_params + [size], ).fetchall() else: rows = self.conn.execute( f"{cols}{where} ORDER BY {order} LIMIT ? OFFSET ?", params + [size, page * size], ).fetchall() estd = self._estd_set() favs = self.favorite_set() songs = [] for r in rows: songs.append({ "filename": r[0], "title": r[1], "artist": r[2], "album": r[3], "year": r[4], "duration": r[5], "tuning": r[6], "arrangements": _ensure_smart_names(json.loads(r[7]) if r[7] else []), "has_lyrics": bool(r[8]), "mtime": r[9], "format": r[10] or "archive", "stem_count": int(r[11] or 0), "stem_ids": json.loads(r[12]) if r[12] else [], "tuning_name": r[13] or "", "tuning_offsets": r[14] or "", "has_estd": r[0] in estd, "favorite": r[0] in favs, }) # Personal layer (difficulty + tags) rides along like `favorite`, so a # card can badge it without a second request. Notes stay OUT of the list # payload (they can be long) — fetch per-song via /user-meta. Batched to # avoid an N+1 over the page. fns = [s["filename"] for s in songs] udm = self.user_meta_map(fns) tgm = self.tags_map(fns) # Canonical artist at display (P4): re-label the card's artist through the # alias override so "ACDC" reads as "AC/DC". Display-only — the row's sort # position (raw artist) is untouched, so a card can show a canonical name # that differs from its A–Z bucket for cross-letter aliases; the full # sort/rail reindex under aliases is the P5a materialization pass. amap = self.alias_map() for s in songs: s["user_difficulty"] = udm.get(s["filename"]) s["tags"] = tgm.get(s["filename"], []) if amap: s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist")) # Grouped rows carry the ⚑ N (chart_count) + the work_key from the # materialized read-model, so the card can render the "N charts" chip and # address the Charts drawer (GET /api/work/{work_key}/charts) without a # second request — plus `is_split` (P5e) so the ⋮ menu can offer the # "Rejoin other versions" undo on a split-out chart. if group and fns: ph = ",".join("?" * len(fns)) wd = {r[0]: (r[1], r[2], r[3]) for r in self.conn.execute( "SELECT filename, group_size, work_key, effective_work_key " f"FROM work_display WHERE filename IN ({ph})", fns).fetchall()} splits = {r[0] for r in self.conn.execute( f"SELECT filename FROM chart_group_split WHERE filename IN ({ph})", fns).fetchall()} eff_by_fn = {} for s in songs: gs, wk, eff = wd.get(s["filename"], (1, None, None)) s["chart_count"] = gs s["work_key"] = wk s["is_split"] = s["filename"] in splits if eff: eff_by_fn[s["filename"]] = eff if ifrag: self._attach_display_charts(songs, eff_by_fn, ifrag, iparams) return songs, total def _attach_display_charts(self, songs: list[dict], eff_by_fn: dict, intrinsic_frag: str, intrinsic_params: list) -> None: """§7.1: when chart-intrinsic filters admit a work through a member the REPRESENTATIVE doesn't itself satisfy, the card 'switches its displayed chart to a matching one'. The row (sort keys, cursor identity, the mastery/favorite anchor) stays the representative's — only the display/play facts ride along under `display_chart`, so keyset paging and the practice-state anchor are untouched. `intrinsic_frag`/`params` are the member-aliased ('m') predicates already built by the caller.""" keys = sorted(set(eff_by_fn.values())) if not keys: return ph = ",".join("?" * len(keys)) rows = self.conn.execute( "SELECT mw.effective_work_key, m.filename, m.title, m.duration, m.tuning, " "m.arrangements, m.has_lyrics, m.mtime, m.format, m.stem_count, m.stem_ids, " "m.tuning_name, m.tuning_offsets " "FROM songs m JOIN work_display mw ON mw.filename = m.filename " f"WHERE mw.effective_work_key IN ({ph}){intrinsic_frag} " "ORDER BY mw.is_group_representative DESC, m.mtime DESC, m.filename", keys + list(intrinsic_params)).fetchall() best: dict = {} for r in rows: best.setdefault(r[0], r) # rep-first, then newest — one match per work for s in songs: m = best.get(eff_by_fn.get(s["filename"])) if not m or m[1] == s["filename"]: continue # the representative itself matches (or nothing does) s["display_chart"] = { "filename": m[1], "title": m[2] or m[1], "duration": m[3], "tuning": m[4], "arrangements": _ensure_smart_names(json.loads(m[5]) if m[5] else []), "has_lyrics": bool(m[6]), "mtime": m[7], "format": m[8] or "archive", "stem_count": int(m[9] or 0), "stem_ids": json.loads(m[10]) if m[10] else [], "tuning_name": m[11] or "", "tuning_offsets": m[12] or "", } def query_artists(self, letter: str = "", q: str = "", favorites_only: bool = False, page: int = 0, size: int = 50, format_filter: str = "", artist_filter: str = "", album_filter: str = "", arrangements_has: list[str] | None = None, arrangements_lacks: list[str] | None = None, stems_has: list[str] | None = None, stems_lacks: list[str] | None = None, has_lyrics: int | None = None, tunings: list[str] | None = None, naming_mode: str = "legacy") -> tuple[list[dict], int]: """Get artists grouped by letter with their albums and songs. Returns (artists, total_artists).""" where, params = self._build_where( q=q, favorites_only=favorites_only, format_filter=format_filter, artist_filter=artist_filter, album_filter=album_filter, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode, ) # Canonicalize artists at display when aliases exist (P4): dedupe / group / # letter / order on the EFFECTIVE artist so "ACDC" + "AC/DC" list as one # entry. With no aliases, `art_expr` stays the plain (indexed) `artist` # column, so the common case pays zero subquery cost. has_aliases = self.conn.execute("SELECT 1 FROM artist_alias LIMIT 1").fetchone() is not None art_expr = self._EFFECTIVE_ARTIST_SQL if has_aliases else "artist" if letter == "#": where += f" AND ({art_expr}) NOT GLOB '[A-Za-z]*'" elif letter: where += f" AND UPPER(SUBSTR(({art_expr}), 1, 1)) = ?" params.append(letter.upper()) # Get paginated distinct (effective) artists total_artists = self.conn.execute( f"SELECT COUNT(DISTINCT ({art_expr}) COLLATE NOCASE) FROM songs {where}", params ).fetchone()[0] artist_rows = self.conn.execute( f"SELECT DISTINCT ({art_expr}) COLLATE NOCASE as a FROM songs {where} ORDER BY a LIMIT ? OFFSET ?", params + [size, page * size] ).fetchall() artist_names = [r[0] for r in artist_rows] if not artist_names: return [], total_artists # Fetch songs for these (effective) artists only placeholders = ",".join(["?"] * len(artist_names)) song_where = f"{where} AND ({art_expr}) COLLATE NOCASE IN ({placeholders})" song_params = params + artist_names rows = self.conn.execute( f"SELECT filename, title, ({art_expr}) as artist, album, year, duration, tuning, arrangements, has_lyrics, " f"format, stem_count, stem_ids, tuning_name " f"FROM songs {song_where} ORDER BY ({art_expr}) COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE", song_params ).fetchall() # Group into artist -> album -> songs from collections import OrderedDict estd = self._estd_set() favs = self.favorite_set() artists = OrderedDict() for r in rows: artist = r[2] or "Unknown Artist" album = r[3] or "Unknown Album" akey = artist.lower() if akey not in artists: artists[akey] = {"name": artist, "albums": OrderedDict()} bkey = album.lower() if bkey not in artists[akey]["albums"]: artists[akey]["albums"][bkey] = {"name": album, "songs": []} artists[akey]["albums"][bkey]["songs"].append({ "filename": r[0], "title": r[1], "artist": r[2], "album": r[3], "year": r[4], "duration": r[5], "tuning": r[6], "arrangements": _ensure_smart_names(json.loads(r[7]) if r[7] else []), "has_lyrics": bool(r[8]), "format": r[9] or "archive", "stem_count": int(r[10] or 0), "stem_ids": json.loads(r[11]) if r[11] else [], "tuning_name": r[12] or "", "has_estd": r[0] in estd, "favorite": r[0] in favs, }) # Pick most common name variant per artist/album result = [] for akey, aval in artists.items(): albums = [] for bkey, bval in aval["albums"].items(): albums.append({"name": bval["name"], "songs": bval["songs"]}) result.append({"name": aval["name"], "album_count": len(albums), "song_count": sum(len(a["songs"]) for a in albums), "albums": albums}) return result, total_artists def query_albums(self, q="", favorites_only=False, format_filter="", artist_filter="", album_filter="", arrangements_has=None, arrangements_lacks=None, stems_has=None, stems_lacks=None, has_lyrics=None, tunings=None, mastery=None, match_states=None, genre=None, naming_mode="legacy", page=0, size=120): """Distinct (artist, album) groups with a track count + a representative cover song, for the album-condensed browse (paged by album). Rows with no album name are excluded -- they can't form an album card. Same filters as query_page.""" where, params = self._build_where( q=q, favorites_only=favorites_only, format_filter=format_filter, artist_filter=artist_filter, album_filter=album_filter, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, has_lyrics=has_lyrics, tunings=tunings, mastery=mastery, match_states=match_states, genre=genre, naming_mode=naming_mode, ) awhere = where + " AND album IS NOT NULL AND album != ''" total = self.conn.execute( f"SELECT COUNT(*) FROM (SELECT 1 FROM songs {awhere} " f"GROUP BY artist COLLATE NOCASE, album COLLATE NOCASE)", params ).fetchone()[0] rows = self.conn.execute( f"SELECT artist, album, COUNT(*) AS n, MIN(filename) AS cover " f"FROM songs {awhere} " f"GROUP BY artist COLLATE NOCASE, album COLLATE NOCASE " f"ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE LIMIT ? OFFSET ?", params + [size, page * size] ).fetchall() return ([{"artist": r[0] or "Unknown Artist", "album": r[1] or "Unknown Album", "count": int(r[2] or 0), "cover": r[3]} for r in rows], total) def query_stats(self, favorites_only: bool = False, q: str = "", format_filter: str = "", artist_filter: str = "", album_filter: str = "", arrangements_has: list[str] | None = None, arrangements_lacks: list[str] | None = None, stems_has: list[str] | None = None, stems_lacks: list[str] | None = None, has_lyrics: int | None = None, tunings: list[str] | None = None, match_states: list[str] | None = None, sort: str = "artist", want_sort_letters: bool = False, group: bool = False, naming_mode: str = "legacy") -> dict: """Aggregate stats for the letter bar. Accepts the same filter params as query_page so the letter counts stay synchronized with the grid when filters are active. `group` (P5a) restricts every count to one representative chart per work (the same predicate query_page uses), so `total_songs` and the jump-rail `sort_letters` count WORKS not charts and stay in lockstep with the grouped grid. `sort` selects the column the v3 jump rail's `sort_letters` breakdown keys on (artist for artist sorts, title for title sorts) so the rail's present-letters match the grid's actual order; other sorts fall back to artist (the rail is hidden for them client-side anyway). The legacy `letters` field is always the artist breakdown, unchanged, for the dashboard + classic tree. `sort_letters` is computed (and the key included) ONLY when `want_sort_letters` is set — the jump rail opts in, while the dashboard / v2 tree read only `letters` and skip the extra per-letter aggregate scan.""" where, params = self._build_where( q=q, favorites_only=favorites_only, format_filter=format_filter, artist_filter=artist_filter, album_filter=album_filter, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, has_lyrics=has_lyrics, tunings=tunings, match_states=match_states, naming_mode=naming_mode, include_intrinsic=not group, ) if group: # Same filter law as query_page (§7.1): chart-intrinsic predicates # match-if-ANY-member, applied identically here so the letter-bar # counts stay in lockstep with the grouped grid. self._ensure_work_display() ifrag, iparams = self._build_intrinsic_where( "m", format_filter=format_filter, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode) mfrag, mparams = self._grouped_member_match(ifrag, iparams) where += mfrag params += mparams where += self._GROUP_REP_PREDICATE # Grouped stat counts filter through work_display (same # is_group_representative=1 predicate as query_page); hold self._lock # across these representative SELECTs so they can't observe a # mid-rebuild empty table (see query_page for the full rationale). # _ensure_work_display already rebuilt above under its own lock, so we # do NOT nest it here (self._lock is non-reentrant). Ungrouped reads # stay lock-free (WAL) via nullcontext. read_guard = self._lock if group else contextlib.nullcontext() with read_guard: total = self.conn.execute(f"SELECT COUNT(*) FROM songs {where}", params).fetchone()[0] # NOCASE collation here mirrors `query_artists` and the per- # letter `COUNT(DISTINCT artist COLLATE NOCASE)` below — without # it, an artist stored under two different casings would inflate # `total_artists` against the letter-bar breakdown the UI # renders next to it. artist_count = self.conn.execute( f"SELECT COUNT(DISTINCT artist COLLATE NOCASE) FROM songs {where}", params ).fetchone()[0] rows = self.conn.execute( f"SELECT UPPER(SUBSTR(artist, 1, 1)) as letter, COUNT(DISTINCT artist COLLATE NOCASE) " f"FROM songs {where} GROUP BY letter", params ).fetchall() letters = {} for letter, count in rows: count = int(count or 0) if count <= 0: continue key = str(letter or "") if key.isascii() and key.isalpha(): letters[key] = letters.get(key, 0) + count else: letters["#"] = letters.get("#", 0) + count result = {"total_songs": total, "total_artists": artist_count, "letters": letters} # Active-sort letter buckets for the v3 jump rail. Counts SONGS (the # grid's unit, unlike `letters` which counts distinct artists) per # first-letter bucket of the column the active sort keys on, so a tap # on a present letter always finds a card. Non-A–Z first chars bucket # under '#'. Only artist/title sorts are alphabetical; anything else # keys on artist here but the client hides the rail for it. Computed # only when the caller opts in, so non-rail callers skip the scan. if want_sort_letters: sort_col = "title" if sort in ("title", "title-desc") else "artist" # Same representative-SELECT lock guard as the counts above. with read_guard: sort_rows = self.conn.execute( f"SELECT UPPER(SUBSTR(COALESCE({sort_col}, ''), 1, 1)) AS letter, COUNT(*) " f"FROM songs {where} GROUP BY letter", params ).fetchall() sort_letters: dict[str, int] = {} for letter, count in sort_rows: count = int(count or 0) if count <= 0: continue key = str(letter or "") bucket = key if (key.isascii() and key.isalpha()) else "#" sort_letters[bucket] = sort_letters.get(bucket, 0) + count result["sort_letters"] = sort_letters return result class AudioEffectsMappingDB: """Core-owned public song/tone -> provider mapping index. Providers own the preset/chain rows addressed by provider_ref. Core owns the cross-provider routing index and the active mapping per song/tone. """ def __init__(self): CONFIG_DIR.mkdir(parents=True, exist_ok=True) self.db_path = str(CONFIG_DIR / "audio_effects.db") self.conn = sqlite3.connect(self.db_path, check_same_thread=False) self.conn.execute("PRAGMA journal_mode=WAL") self.conn.execute("PRAGMA foreign_keys=ON") self.conn.execute(""" CREATE TABLE IF NOT EXISTS audio_effect_mappings ( id INTEGER PRIMARY KEY AUTOINCREMENT, song_key TEXT NOT NULL, filename TEXT NOT NULL DEFAULT '', tone_key TEXT NOT NULL, provider_id TEXT NOT NULL, provider_ref TEXT NOT NULL, label TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'manual', metadata_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(song_key, tone_key, provider_id) ) """) self.conn.execute(""" CREATE TABLE IF NOT EXISTS audio_effect_active_mappings ( song_key TEXT NOT NULL, tone_key TEXT NOT NULL, mapping_id INTEGER NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (song_key, tone_key), FOREIGN KEY (mapping_id) REFERENCES audio_effect_mappings(id) ON DELETE CASCADE ) """) self.conn.execute( "CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_provider " "ON audio_effect_mappings(provider_id)" ) self.conn.execute( "CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_filename " "ON audio_effect_mappings(filename)" ) self.conn.commit() self._lock = threading.Lock() @staticmethod def _text(value, *, field: str, limit: int, allow_empty: bool = False) -> str: if value is None: text = "" elif not isinstance(value, str): raise ValueError(f"{field} must be a string") else: text = value.strip() if not text and not allow_empty: raise ValueError(f"{field} is required") if len(text) > limit: raise ValueError(f"{field} is too long") return text @staticmethod def _mapping_id(value) -> int | None: # Bind only values SQLite can store as an INTEGER; an out-of-range id is a # clean miss (404), not a 500 at bind time. if isinstance(value, int) and not isinstance(value, bool) and -(2 ** 63) <= value < 2 ** 63: return value return None @staticmethod def _field(data: dict, *keys): # Select the first present snake/camel alias by key, not by truthiness, so a # falsey non-string value (false/0) still reaches _text() and is rejected # instead of being silently swallowed by an `or` chain. for key in keys: if key in data: return data[key] return None @staticmethod def _metadata(value) -> str: if value is None: return "{}" if not isinstance(value, dict): raise ValueError("metadata must be an object") encoded = json.dumps(value, ensure_ascii=True, sort_keys=True) if len(encoded) > 8192: raise ValueError("metadata is too large") return encoded @staticmethod def _row(row) -> dict | None: if row is None: return None metadata = {} try: metadata = json.loads(row[8]) if row[8] else {} except Exception: metadata = {} return { "id": int(row[0]), "song_key": row[1], "filename": row[2] or "", "tone_key": row[3], "provider_id": row[4], "provider_ref": row[5], "label": row[6] or "", "source": row[7] or "manual", "metadata": metadata if isinstance(metadata, dict) else {}, "created_at": row[9] or "", "updated_at": row[10] or "", "active": bool(row[11]), } def _select_sql(self) -> str: return """ SELECT m.id, m.song_key, m.filename, m.tone_key, m.provider_id, m.provider_ref, m.label, m.source, m.metadata_json, m.created_at, m.updated_at, CASE WHEN a.mapping_id IS NULL THEN 0 ELSE 1 END AS active FROM audio_effect_mappings m LEFT JOIN audio_effect_active_mappings a ON a.song_key = m.song_key AND a.tone_key = m.tone_key AND a.mapping_id = m.id """ def list(self, *, song_key: str = "", filename: str = "", tone_key: str = "", provider_id: str = "") -> list[dict]: clauses: list[str] = [] params: list[str] = [] song_key = self._text(song_key, field="song_key", limit=240, allow_empty=True) filename = self._text(filename, field="filename", limit=500, allow_empty=True) tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True) provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True) if song_key and filename: clauses.append("(m.song_key = ? OR m.filename = ?)") params.extend([song_key, filename]) elif song_key: clauses.append("m.song_key = ?") params.append(song_key) elif filename: clauses.append("(m.song_key = ? OR m.filename = ?)") params.extend([filename, filename]) if tone_key: clauses.append("m.tone_key = ?") params.append(tone_key) if provider_id: clauses.append("m.provider_id = ?") params.append(provider_id) sql = self._select_sql() if clauses: sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY m.song_key COLLATE NOCASE, m.tone_key COLLATE NOCASE, m.provider_id COLLATE NOCASE" with self._lock: rows = self.conn.execute(sql, params).fetchall() return [self._row(row) for row in rows] def get(self, mapping_id: int) -> dict | None: mapping_id = self._mapping_id(mapping_id) if mapping_id is None: return None with self._lock: row = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone() return self._row(row) def upsert(self, data: dict) -> dict: if not isinstance(data, dict): raise ValueError("mapping body must be an object") filename = self._text(data.get("filename", ""), field="filename", limit=500, allow_empty=True) song_key_raw = self._field(data, "song_key", "songKey") if song_key_raw is None or song_key_raw == "": song_key_raw = filename song_key = self._text(song_key_raw, field="song_key", limit=240) tone_key = self._text(self._field(data, "tone_key", "toneKey"), field="tone_key", limit=160, allow_empty=True) provider_id = self._text(self._field(data, "provider_id", "providerId"), field="provider_id", limit=96) provider_ref = self._text(self._field(data, "provider_ref", "providerRef"), field="provider_ref", limit=240) label = self._text(data.get("label", ""), field="label", limit=160, allow_empty=True) source = self._text(data.get("source", "manual"), field="source", limit=40, allow_empty=True) or "manual" metadata_json = self._metadata(data.get("metadata", {})) with self._lock: self.conn.execute( """ INSERT INTO audio_effect_mappings (song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(song_key, tone_key, provider_id) DO UPDATE SET -- Only overwrite filename when a non-empty one was supplied; an -- omitted/empty filename must preserve the stored value (it's an -- alternate lookup key for list(..., filename=...)). filename=CASE WHEN excluded.filename <> '' THEN excluded.filename ELSE audio_effect_mappings.filename END, provider_ref=excluded.provider_ref, label=excluded.label, source=excluded.source, metadata_json=excluded.metadata_json, updated_at=datetime('now') """, (song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json), ) row = self.conn.execute( "SELECT id FROM audio_effect_mappings WHERE song_key = ? AND tone_key = ? AND provider_id = ?", (song_key, tone_key, provider_id), ).fetchone() if row is None: raise ValueError("failed to create audio-effects mapping") mapping_id = int(row[0]) if data.get("active") is True: self.conn.execute( """ INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(song_key, tone_key) DO UPDATE SET mapping_id=excluded.mapping_id, updated_at=datetime('now') """, (song_key, tone_key, mapping_id), ) self.conn.commit() return self.get(mapping_id) def delete(self, mapping_id: int, *, provider_id: str = "") -> bool: mapping_id = self._mapping_id(mapping_id) if mapping_id is None: return False provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True) with self._lock: if provider_id: cur = self.conn.execute( "DELETE FROM audio_effect_mappings WHERE id = ? AND provider_id = ?", (mapping_id, provider_id), ) else: cur = self.conn.execute("DELETE FROM audio_effect_mappings WHERE id = ?", (mapping_id,)) self.conn.commit() return cur.rowcount > 0 def activate(self, mapping_id: int, *, provider_id: str = "") -> dict | None: mapping_id = self._mapping_id(mapping_id) if mapping_id is None: return None provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True) with self._lock: row = self.conn.execute( self._select_sql() + " WHERE m.id = ?", (mapping_id,), ).fetchone() mapping = self._row(row) if not mapping or (provider_id and mapping["provider_id"] != provider_id): return None self.conn.execute( """ INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(song_key, tone_key) DO UPDATE SET mapping_id=excluded.mapping_id, updated_at=datetime('now') """, (mapping["song_key"], mapping["tone_key"], mapping_id), ) self.conn.commit() selected = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone() return self._row(selected) def clear_active(self, *, song_key: str, tone_key: str) -> bool: song_key = self._text(song_key, field="song_key", limit=240) tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True) with self._lock: cur = self.conn.execute( "DELETE FROM audio_effect_active_mappings WHERE song_key = ? AND tone_key = ?", (song_key, tone_key), ) self.conn.commit() return cur.rowcount > 0 meta_db = MetadataDB() audio_effect_mappings = AudioEffectsMappingDB() class LocalLibraryProvider: id = "local" label = "My Library" kind = "local" capabilities = ( "library.read", "art.read", "song.play", "favorite.write", "metadata.write", ) def __init__(self, db: MetadataDB): self._db = db def query_page(self, **kwargs) -> tuple[list[dict], int]: return self._db.query_page(**kwargs) def query_artists(self, **kwargs) -> tuple[list[dict], int]: return self._db.query_artists(**kwargs) def query_albums(self, **kwargs) -> tuple[list[dict], int]: return self._db.query_albums(**kwargs) def query_stats(self, **kwargs) -> dict: return self._db.query_stats(**kwargs) def tuning_names(self) -> dict: # Group custom tunings on their raw offsets so distinct ones stay # distinct (tuning_name collapses them all to "Custom Tuning"); named # tunings keep grouping by name (stable across the rescan boundary, no # offsets/name split). `key` is the value the client sends back as the # filter selector — equal to the name for named tunings, the offsets # string for customs; offsets also feed the client's custom-pill label. with self._db._lock: rows = self._db.conn.execute( f"SELECT tuning_name, {_TUNING_GROUP_KEY_SQL} AS gkey, " "MIN(tuning_sort_key), COUNT(*), MIN(tuning_offsets) " "FROM songs WHERE title != '' AND COALESCE(tuning_name, '') != '' " "GROUP BY gkey COLLATE NOCASE " "ORDER BY ABS(COALESCE(MIN(tuning_sort_key), 0)), " "COALESCE(MIN(tuning_sort_key), 0) ASC, " "tuning_name COLLATE NOCASE" ).fetchall() return { "tunings": [ {"name": name, "key": gkey, "offsets": offs or "", "sort_key": int(sk or 0), "count": count} for name, gkey, sk, count, offs in rows ], } async def get_art(self, song_id: str): return await get_song_art(song_id) class LibraryProviderRegistry: # Methods required per declared capability — only validated when the # provider advertises the corresponding capability so action-only providers # (e.g. art.read + song.sync without library.read) don't need to implement # unused stubs. _CAPABILITY_METHODS: ClassVar[dict[str, tuple[str, ...]]] = { "library.read": ("query_page", "query_artists", "query_stats", "tuning_names"), "art.read": ("get_art",), "song.sync": ("sync_song",), } _ID_RE: ClassVar[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") def __init__(self): self._providers: dict[str, object] = {} # Capabilities inferred at registration for legacy providers that omit # the `capabilities` field. Merged with provider_capabilities() so that # runtime capability checks see the complete effective capability set. self._inferred_caps: dict[str, set[str]] = {} self._owner_plugin_ids: dict[str, str] = {} self._lock = threading.RLock() def register(self, provider: object, *, replace: bool = False, owner_plugin_id: str | None = None) -> object: provider_id = self.provider_id(provider) if not self._ID_RE.match(provider_id): raise ValueError( "library provider id must start with an alphanumeric character " "and contain only letters, digits, _, ., :, or -" ) if not self.provider_label(provider): raise ValueError("library provider label must be a non-empty string") # Use declared-only caps during validation — never include stale inferred # caps from a previous provider registered under the same id (replace=True). caps = self._declared_capabilities(provider) # Backward compatibility: providers that predate explicit capability # declarations may omit `capabilities` entirely. If the browse methods # are all present, infer `library.read` so they still work unchanged. # If capabilities are absent but the browse surface is also absent, # raise a clear error rather than letting the provider register and # then fail on every API call with a late 501. inferred: set[str] = set() if not caps: browse_methods = self._CAPABILITY_METHODS["library.read"] if all(callable(self.provider_method(provider, m)) for m in browse_methods): # Legacy provider without explicit capabilities — infer library.read # from the presence of all browse methods. Store in _inferred_caps # so that runtime capability checks see the full effective set. inferred = {"library.read"} caps = inferred else: raise TypeError( f"library provider {provider_id!r} must declare at least one capability " f"(or implement the {browse_methods!r} browse methods for backward compatibility)" ) for cap, methods in self._CAPABILITY_METHODS.items(): if cap not in caps: continue for method_name in methods: if not callable(self.provider_method(provider, method_name)): raise TypeError(f"library provider {provider_id!r} declares {cap!r} but is missing callable {method_name}()") with self._lock: if provider_id == "local" and provider_id in self._providers and self._providers[provider_id] is not provider: raise ValueError("the local library provider cannot be replaced") if provider_id in self._providers and not replace: raise ValueError(f"library provider {provider_id!r} is already registered") self._providers[provider_id] = provider # owner_plugin_id is attribution that flows into the browser # capability participant id. The scoped register_library_provider # wrappers force it to the trusted loading plugin id, so the spoof # vector is closed there. Here we only normalize: trim and require a # non-empty string. We deliberately do NOT apply the provider-id # grammar (_ID_RE) — plugin ids aren't constrained to it at load # time, so that would silently drop attribution for valid plugins. owner = owner_plugin_id.strip() if isinstance(owner_plugin_id, str) else "" owner = owner or None if owner: self._owner_plugin_ids[provider_id] = owner else: self._owner_plugin_ids.pop(provider_id, None) if inferred: self._inferred_caps[provider_id] = inferred else: self._inferred_caps.pop(provider_id, None) return provider def unregister(self, provider_id: str) -> bool: if provider_id == "local": raise ValueError("the local library provider cannot be unregistered") with self._lock: self._inferred_caps.pop(provider_id, None) self._owner_plugin_ids.pop(provider_id, None) return self._providers.pop(provider_id, None) is not None def get(self, provider_id: str = "local") -> object | None: with self._lock: return self._providers.get(provider_id or "local") def list(self) -> list[dict]: with self._lock: providers = list(self._providers.values()) return [self.describe(provider) for provider in providers] def describe(self, provider: object) -> dict: provider_id = self.provider_id(provider) with self._lock: owner_plugin_id = self._owner_plugin_ids.get(provider_id) return { "id": provider_id, "label": self.provider_label(provider), "kind": self.provider_field(provider, "kind", "local" if provider_id == "local" else "remote"), "capabilities": sorted(self.provider_capabilities(provider)), "owner_plugin_id": owner_plugin_id, "default": provider_id == "local", } def provider_field(self, provider: object, name: str, default=None): if isinstance(provider, dict): return provider.get(name, default) return getattr(provider, name, default) def provider_id(self, provider: object) -> str: provider_id = self.provider_field(provider, "id", "") if not isinstance(provider_id, str) or not provider_id: raise ValueError("library provider id must be a non-empty string") return provider_id def provider_label(self, provider: object) -> str: label = self.provider_field(provider, "label", self.provider_field(provider, "name", "")) if not isinstance(label, str): return "" return label.strip() def _declared_capabilities(self, provider: object) -> set[str]: """Return only the capabilities explicitly declared on the provider object.""" raw = self.provider_field(provider, "capabilities", ()) if raw is None: raw = () if isinstance(raw, str): raw = (raw,) if raw else () return {str(cap) for cap in raw if cap} def provider_capabilities(self, provider: object) -> set[str]: # Guard against a common plugin authoring mistake: passing a single string # instead of a list/tuple. Iterating a string produces individual characters, # none of which would match a valid capability name. declared = self._declared_capabilities(provider) # Merge with any capabilities inferred at registration time for legacy # providers that omit the `capabilities` field but implement browse methods. provider_id = self.provider_id(provider) with self._lock: inferred = self._inferred_caps.get(provider_id, set()) return declared | inferred def provider_method(self, provider: object, name: str): if isinstance(provider, dict): return provider.get(name) return getattr(provider, name, None) library_providers = LibraryProviderRegistry() _local_library_provider = LocalLibraryProvider(meta_db) library_providers.register(_local_library_provider) # Keys `_library_filter_args` (and a smart collection's stored `rules`) accept. _LIBRARY_FILTER_PARAM_KEYS = frozenset(( "q", "favorites", "format", "artist", "album", "arrangements_has", "arrangements_lacks", "stems_has", "stems_lacks", "has_lyrics", "tunings", )) # Rules mirror the raw /api/library query params (so the provider can feed them # straight through `_library_filter_args`, and the frontend can build a rule from # the same query string it already constructs). Multi-value filters are CSV # strings; `favorites` is 0/1; the rest are plain strings. _RULE_CSV_KEYS = frozenset(( "tunings", "arrangements_has", "arrangements_lacks", "stems_has", "stems_lacks", )) _RULE_STR_KEYS = frozenset(("q", "format", "artist", "album", "has_lyrics", "sort")) def _sanitize_collection_rules(raw) -> dict: """Normalize rules to the raw query-param format, keeping only known keys. A list for a multi-value filter is joined to CSV; `favorites` becomes 0/1. Unknown keys are dropped so a rule survives a filter-vocab change rather than 500-ing. Applied at API ingress AND when a provider loads a persisted row, so a hand-edited / imported bad value (e.g. an int where a string is expected, or a list for `sort`) can never crash a query.""" if not isinstance(raw, dict): return {} out: dict = {} for k, v in raw.items(): if k in _RULE_CSV_KEYS: if isinstance(v, list): vals = [str(x) for x in v if isinstance(x, (str, int)) and not isinstance(x, bool)] elif isinstance(v, str): vals = [s for s in (p.strip() for p in v.split(",")) if s] else: continue if vals: out[k] = ",".join(vals) elif k == "favorites": if v: out[k] = 1 elif k in _RULE_STR_KEYS: if isinstance(v, (str, int)) and not isinstance(v, bool): s = str(v).strip() if s: out[k] = s return out class SmartCollectionProvider: """A saved library filter, surfaced as a source (#636 item 2). Browse/stats delegate to the local DB with the collection's stored `rules` applied — so selecting it in the v3 source picker shows exactly that filtered slice with the whole Songs UI (paging, stats, A–Z rail, art) for free. P1: the rules ARE the query (live in-collection search is a P2 nicety). The matched songs are local rows, so `kind="local"` keeps the client's play/art paths on the local (not remote-sync) branch and art delegates straight through.""" kind = "local" capabilities = ("library.read", "art.read") def __init__(self, collection: dict, local: "LocalLibraryProvider"): self._local = local self.update(collection) def update(self, collection: dict) -> None: self.id = f"collection:{collection['id']}" self.collection_id = collection["id"] self.label = collection.get("name") or "Collection" # Re-sanitize on load: persisted JSON may predate the current vocab or # have been hand-edited; never let a bad value reach a query. self._rules = _sanitize_collection_rules(collection.get("rules") or {}) def _filter_kwargs(self) -> dict: return _library_filter_args(**{k: v for k, v in self._rules.items() if k in _LIBRARY_FILTER_PARAM_KEYS}) def _sort(self, fallback: str) -> str: # A collection may pin its own sort (e.g. "recently added"); query_page # falls back safely for an unknown value, so no validation needed here. return self._rules.get("sort") or fallback def query_page(self, *, page=0, size=24, sort="artist", direction="asc", naming_mode="legacy", **_ignore): return self._local._db.query_page( page=page, size=size, sort=self._sort(sort), direction=direction, naming_mode=naming_mode, **self._filter_kwargs()) def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy", **_ignore): return self._local._db.query_artists( letter=letter, page=page, size=size, naming_mode=naming_mode, **self._filter_kwargs()) def query_albums(self, *, page=0, size=120, naming_mode="legacy", **_ignore): return self._local._db.query_albums( page=page, size=size, naming_mode=naming_mode, **self._filter_kwargs()) def query_stats(self, *, sort="artist", want_sort_letters=False, naming_mode="legacy", **_ignore): return self._local._db.query_stats( sort=self._sort(sort), want_sort_letters=want_sort_letters, naming_mode=naming_mode, **self._filter_kwargs()) def tuning_names(self): return self._local.tuning_names() async def get_art(self, song_id: str): return await self._local.get_art(song_id) def _sync_collection_provider(collection: dict) -> None: """Register (or replace) the provider for one collection.""" library_providers.register( SmartCollectionProvider(collection, _local_library_provider), replace=True) def _unregister_collection_provider(pid: int) -> None: library_providers.unregister(f"collection:{pid}") # Boot scan: surface every saved collection as a source. for _c in meta_db.list_collections(): _sync_collection_provider(_c) def register_library_provider(provider: object, *, replace: bool = False, owner_plugin_id: str | None = None) -> object: return library_providers.register(provider, replace=replace, owner_plugin_id=owner_plugin_id) def unregister_library_provider(provider_id: str) -> bool: return library_providers.unregister(provider_id) class TuningProviderRegistry: """Registry for plugins that contribute custom tunings to the core tuning.read capability.""" _ID_RE: ClassVar[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") def __init__(self) -> None: self._providers: dict[str, callable] = {} self._lock = threading.Lock() def register(self, provider_id: str, get_tunings: callable) -> None: if not self._ID_RE.match(provider_id): raise ValueError(f"tuning provider id {provider_id!r} contains invalid characters") if not callable(get_tunings): raise TypeError("get_tunings must be callable") with self._lock: self._providers[provider_id] = get_tunings def unregister(self, provider_id: str) -> None: with self._lock: self._providers.pop(provider_id, None) def get_merged(self, reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> dict: """DEFAULT_TUNINGS scaled to reference_pitch, merged with all provider contributions.""" result: dict[str, dict[str, list[float]]] = apply_reference_pitch(DEFAULT_TUNINGS, reference_pitch) scale = reference_pitch / DEFAULT_REFERENCE_PITCH with self._lock: providers = list(self._providers.items()) for provider_id, get_tunings in providers: try: extra = get_tunings() or {} for instrument, names in extra.items(): if instrument not in result: result[instrument] = {} for name, freqs in names.items(): result[instrument][name] = [round(f * scale, 4) for f in freqs] except Exception: logger.exception("tuning provider %r raised during get_merged()", provider_id) return result tuning_providers = TuningProviderRegistry() def register_tuning_provider(provider_id: str, get_tunings: callable) -> None: tuning_providers.register(provider_id, get_tunings) def unregister_tuning_provider(provider_id: str) -> None: tuning_providers.unregister(provider_id) def _get_library_provider(provider: str = "local") -> object: library_provider = library_providers.get(provider or "local") if library_provider is None: raise HTTPException(status_code=404, detail=f"Unknown library provider: {provider}") return library_provider def _require_library_provider_capability(provider: object, capability: str) -> None: if capability in library_providers.provider_capabilities(provider): return provider_id = library_providers.provider_id(provider) raise HTTPException( status_code=501, detail=f"Library provider {provider_id!r} does not declare capability {capability!r}", ) _OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters", "after", "mastery", "match_states") def _filter_provider_kwargs(method: object, kwargs: dict) -> dict: """Drop kwargs that the method's signature does not declare. Provides backward-compat for third-party library providers whose query_page/query_artists/query_stats methods were written before naming_mode was added — calling them with the extra kwarg would raise TypeError and return a 500 to the client. When ``inspect.signature`` cannot introspect the method (rare: C extensions / built-ins / exotic callables), fall back to stripping only the kwargs we know were added later — older providers won't accept them, anything else stays so the call still works. """ try: sig = inspect.signature(method) # type: ignore[arg-type] for p in sig.parameters.values(): if p.kind == inspect.Parameter.VAR_KEYWORD: return kwargs # method accepts **kwargs, pass everything return {k: v for k, v in kwargs.items() if k in sig.parameters} except (ValueError, TypeError): return {k: v for k, v in kwargs.items() if k not in _OPTIONAL_NEW_PROVIDER_KWARGS} def _call_library_provider(provider: object, method_name: str, **kwargs) -> Any: method = library_providers.provider_method(provider, method_name) if not callable(method): provider_id = library_providers.provider_id(provider) raise HTTPException( status_code=501, detail=f"Library provider {provider_id!r} does not support {method_name}", ) try: return method(**_filter_provider_kwargs(method, kwargs)) except HTTPException: raise except Exception as exc: provider_id = library_providers.provider_id(provider) # A provider with an explicit kind="local" is treated as local even if # its id is not "local" (e.g. a kind="local" plugin variant). Otherwise # fall back to provider_id comparison so providers that omit `kind` are # still wrapped correctly — the safe default for unknown providers is to # surface an offline message rather than leaking raw exceptions. provider_kind = str(library_providers.provider_field(provider, "kind", "") or "") if provider_kind: is_remote = provider_kind not in ("", "local") else: is_remote = provider_id != "local" if is_remote: detail = f"This source appears to be offline ({provider_id})." message = str(exc).strip() if message: detail = f"{detail} {message}" raise HTTPException(status_code=503, detail=detail) from exc raise def _is_async_callable(obj: object) -> bool: """Return True if obj is an async function or a callable object with an async __call__. ``inspect.iscoroutinefunction`` only recognises bare coroutine functions; it returns False for class instances whose ``__call__`` method is defined as ``async def``. Checking both handles the common plugin pattern of wrapping an async method in a callable object. """ if inspect.iscoroutinefunction(obj): return True _call = getattr(obj, "__call__", None) return _call is not None and inspect.iscoroutinefunction(_call) async def _call_library_provider_async(provider: object, method_name: str, **kwargs) -> Any: method = library_providers.provider_method(provider, method_name) if _is_async_callable(method): # Async provider method — call directly on the event loop. try: return await method(**_filter_provider_kwargs(method, kwargs)) except HTTPException: raise except Exception as exc: provider_id = library_providers.provider_id(provider) provider_kind = str(library_providers.provider_field(provider, "kind", "") or "") if provider_kind: is_remote = provider_kind not in ("", "local") else: is_remote = provider_id != "local" if is_remote: detail = f"This source appears to be offline ({provider_id})." message = str(exc).strip() if message: detail = f"{detail} {message}" raise HTTPException(status_code=503, detail=detail) from exc raise # Synchronous provider method — run in a threadpool so the event loop stays free. return await run_in_threadpool(_call_library_provider, provider, method_name, **kwargs) def _safe_art_redirect_url(url: str) -> str | None: """Return the URL if it is safe to redirect to (http/https only), else None.""" from urllib.parse import urlparse if not url or not isinstance(url, str): return None try: parsed = urlparse(url) if parsed.scheme.lower() not in ("http", "https"): return None if not parsed.hostname: return None return url except Exception: return None def _library_art_response(result: Any) -> Response: if result is None: raise HTTPException(status_code=404, detail="Library provider returned no art") if isinstance(result, Response): return result if isinstance(result, (bytes, bytearray, memoryview)): return Response(content=bytes(result), media_type="image/png") if isinstance(result, str): safe_url = _safe_art_redirect_url(result) if safe_url is not None: return RedirectResponse(safe_url) # If the string looks like a URL (contains a scheme separator) but # didn't pass the http/https check, refuse it rather than treating # it as a filesystem path — a provider returning ftp:// or file:// # should get a 400, not a 500 from FileResponse failing on a URL. if "://" in result: raise HTTPException( status_code=400, detail="Library provider returned an unsupported URL scheme for art", ) if not Path(result).is_file(): raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path") return FileResponse(result) if isinstance(result, Path): if not result.is_file(): raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path") return FileResponse(str(result)) if isinstance(result, dict): url = result.get("url") or result.get("art_url") or result.get("artUrl") if isinstance(url, str) and url: safe_url = _safe_art_redirect_url(url) if safe_url is None: raise HTTPException(status_code=400, detail="Library provider returned an unsafe art URL") return RedirectResponse(safe_url) path = result.get("path") or result.get("file") if isinstance(path, (str, Path)): media_type = result.get("media_type") or result.get("content_type") if not Path(path).is_file(): raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path") return FileResponse(str(path), media_type=media_type) content = result.get("content") or result.get("bytes") if isinstance(content, (bytes, bytearray, memoryview)): media_type = result.get("media_type") or result.get("content_type") or "image/png" return Response(content=bytes(content), media_type=media_type) raise HTTPException(status_code=500, detail="Library provider returned unsupported art data") def _get_dlc_dir(cfg: dict | None = None) -> Path | None: # Only consider DLC_DIR if the env var was non-empty. `Path("")` collapses # to `.` and reports `.is_dir() == True`, which would silently shadow the # config.json fallback. Checking the raw env string preserves # `DLC_DIR=.` as a valid opt-in for cwd while keeping unset/empty out. if _DLC_DIR_ENV and DLC_DIR.is_dir(): return DLC_DIR if cfg is None: config_file = CONFIG_DIR / "config.json" if config_file.exists(): try: cfg = json.loads(config_file.read_text(encoding="utf-8")) except Exception: pass if isinstance(cfg, dict): raw = str(cfg.get("dlc_dir", "")).strip() if raw: p = Path(raw) if p.is_dir(): return p return None # ── Background metadata scan ────────────────────────────────────────────────── def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None: """Resolve `filename` under DLC_DIR and refuse anything that escapes. `filename` arrives from `:path` route params and can contain `..` segments. The Sloppak and archive paths happen to fail safely later because their loaders raise on missing/invalid files, but loose- folder format detection (`is_loose_song`) globs and parses XML on disk first, which lets a crafted path trigger filesystem reads outside DLC_DIR before any guard fires. Centralise the containment check so every filename-bound handler validates before touching the filesystem. Returns the validated resolved Path, or None if the path is empty or escapes the DLC root. """ return safe_join(dlc, filename) _SMART_TYPE_BASE: dict[str, int] = {"Lead": 0, "Rhythm": 10, "Bass": 20} def _arr_smart_sort_key(entry: dict) -> tuple[int, int]: """Sort key for arrangement entries ordered by smart naming priority. Order: Lead → Alt. Lead [1,2,…] → Bonus Lead [1,2,…] → Rhythm → Alt. Rhythm → Bonus Rhythm → Bass → Alt. Bass → Bonus Bass → other (stable fallback) """ sn = entry.get("smart_name") if not sn: return (99, 0) for label, base in _SMART_TYPE_BASE.items(): if sn == label: return (base, 0) alt_prefix = f"Alt. {label}" if sn == alt_prefix: return (base + 1, 0) if sn.startswith(alt_prefix + " "): suffix = sn[len(alt_prefix) + 1:] return (base + 1, int(suffix) if suffix.isdigit() else 0) bonus_prefix = f"Bonus {label}" if sn == bonus_prefix: return (base + 2, 0) if sn.startswith(bonus_prefix + " "): suffix = sn[len(bonus_prefix) + 1:] return (base + 2, int(suffix) if suffix.isdigit() else 0) return (99, 0) def _pick_smart_arrangement( arrangements: list, smart_names: list, pref: str, ) -> int: """Return the best arrangement index for `pref` using smart-name priority. Priority order: 1. Exact match — smart_name == pref (e.g. "Lead") 2. Alt. variants — "Alt. Lead", "Alt. Lead 1", ... 3. Bonus variants — "Bonus Lead", "Bonus Lead 1", ... 4. First arrangement in smart sort order (Lead > Rhythm > Bass > ...) Returns -1 when `pref` is empty / "Auto" or `arrangements` is empty (caller falls through to the existing most-notes fallback). """ pref = (pref or "").strip() if not pref or pref.lower() == "auto" or not arrangements: return -1 sorted_pairs = sorted( enumerate(smart_names), key=lambda x: _arr_smart_sort_key({"smart_name": x[1]}), ) alt_prefix = f"Alt. {pref}" bonus_prefix = f"Bonus {pref}" for i, sn in sorted_pairs: if sn == pref: return i for i, sn in sorted_pairs: if sn and (sn == alt_prefix or sn.startswith(alt_prefix + " ")): return i for i, sn in sorted_pairs: if sn and (sn == bonus_prefix or sn.startswith(bonus_prefix + " ")): return i if sorted_pairs: return sorted_pairs[0][0] return 0 def _sanitized_song_offset(song) -> float: """Return song.offset coerced to a finite float, or 0.0. Malformed loose-folder XMLs can put `NaN`/`Infinity` into ; Python's `float()` happily accepts those, but Starlette's JSON encoder then emits the literal `NaN` token which is invalid JSON and breaks the frontend's song_info parsing. """ try: v = float(getattr(song, "offset", 0.0)) except (TypeError, ValueError): return 0.0 return v if math.isfinite(v) else 0.0 def _sanitize_authors(manifest: dict | None) -> list[dict]: """Extract a display-safe contributor list from a feedpak manifest. The feedpak spec (§5.4) defines an OPTIONAL top-level `authors` list of objects `{name (required), role?, email?, url?}`. We surface only `name` and `role` to the highway — contact fields (email/url) are intentionally dropped from the on-screen credits. Malformed entries (non-dict, missing / blank name) are skipped; absent / non-list `authors` yields `[]`. """ if not isinstance(manifest, dict): return [] raw = manifest.get("authors") if not isinstance(raw, list): return [] out: list[dict] = [] for entry in raw: if not isinstance(entry, dict): continue name = entry.get("name") if not isinstance(name, str) or not name.strip(): continue role = entry.get("role") out.append({ "name": name.strip(), "role": role.strip() if isinstance(role, str) and role.strip() else None, }) return out def _stat_for_cache(f: Path) -> tuple[float, int]: """Return (mtime, size) for cache freshness checks. For loose-folder directories the directory's own mtime does not change when inner files (audio.wem / *.xml / manifest.json) are edited in place, so we aggregate over the contents. archives and sloppak files (zip form) use their own stat directly. Sloppak *directories* are aggregated too: the editor and the library Edit button rewrite their `manifest.yaml` / `arrangements/*.json` in place, which does NOT bump the directory's own mtime/size — so keying the cache on the bare directory stat would make metadata edits invisible to a rescan. """ # Aggregate inner stats for loose folders. We detect "loose-shape" # purely by file presence (xml + wem + optional manifest.json) so # this stays O(stat) on the hot path — `/api/song/{filename}` and # the background scan call this on every check, and we avoid # calling `is_loose_song` here because that would parse XML on # every cache lookup. if f.is_dir(): # Skip symlinks pointing outside the song folder — without this # an attacker-crafted custom song could keep a stale cache hot by # bumping the mtime of an unrelated file via a symlink. root = f.resolve() def _in_folder(p: Path) -> bool: try: p.resolve().relative_to(root) except (OSError, ValueError): return False return True xmls = [p for p in f.glob("*.xml") if _in_folder(p)] wems = [p for p in f.glob("*.wem") if _in_folder(p)] inner: list[Path] = [] if xmls and wems: inner = xmls + wems + [p for p in f.glob("manifest.json") if _in_folder(p)] else: # Sloppak directory: aggregate over the files that an in-place # metadata/arrangement edit actually touches. Stems (ogg) are # deliberately excluded — they don't change on a metadata edit and # stat-ing them on every cache lookup would be wasteful; a stem # add/remove rewrites manifest.yaml, which IS covered here. man = [ p for p in (f / "manifest.yaml", f / "manifest.yml") if p.exists() and _in_folder(p) ] if man: inner = man inner += [p for p in f.glob("arrangements/*.json") if _in_folder(p)] inner += [p for p in f.glob("drum_tab.json") if _in_folder(p)] if inner: # Tolerate files vanishing between glob() and stat() — # otherwise a concurrent edit/move in DLC_DIR can let an # OSError bubble out of _background_scan(), killing the # scan thread while `_scan_status["running"]` stays true. stats = [] for p in inner: try: stats.append(p.stat()) except OSError: continue if stats: return max(s.st_mtime for s in stats), sum(s.st_size for s in stats) st = f.stat() return st.st_mtime, st.st_size _SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0} _scan_status = dict(_SCAN_STATUS_INIT) _STARTUP_STATUS_INIT = { "running": True, "phase": "booting", "message": "Starting FeedBack server...", "current_plugin": "", "loaded": 0, "total": 0, "error": None, } _startup_status = dict(_STARTUP_STATUS_INIT) _startup_status_lock = threading.Lock() _startup_sse_subscribers: set[asyncio.Queue] = set() # threading.Lock (not asyncio.Lock) — also acquired from background threads # in _notify_startup_sse; held only for set mutations (microseconds). _startup_sse_lock = threading.Lock() _event_loop: asyncio.AbstractEventLoop | None = None _SSE_POLL_INTERVAL = 2.0 # seconds: idle wait between disconnect checks _SSE_KA_INTERVAL = 15.0 # seconds: interval between SSE keepalive data events def _set_startup_status(**updates): global _startup_status with _startup_status_lock: next_status = dict(_startup_status) next_status.update(updates) _startup_status = next_status snapshot = dict(next_status) _notify_startup_sse(snapshot) def _put_latest(q: asyncio.Queue, snapshot: dict) -> None: """Coalescing put: drain any stale snapshot then put the newest one. Because the queue is bounded to maxsize=1 and this function runs on the event loop, consecutive rapid updates replace the queued snapshot with the latest state rather than growing an unbounded backlog. """ while not q.empty(): try: q.get_nowait() except asyncio.QueueEmpty: break try: q.put_nowait(snapshot) except asyncio.QueueFull: pass # shouldn't happen after draining, but be defensive def _notify_startup_sse(snapshot: dict) -> None: loop = _event_loop if loop is None or loop.is_closed(): return with _startup_sse_lock: for q in _startup_sse_subscribers: try: loop.call_soon_threadsafe(_put_latest, q, snapshot) except RuntimeError: # Loop is closing (shutdown race); all remaining subscribers are # on the same loop and equally unreachable — break is correct. break def _get_startup_status(): with _startup_status_lock: return dict(_startup_status) def _make_scan_executor(): """Build the executor for the background metadata scan. A `spawn` ProcessPoolExecutor in production. `spawn` (not the platform default) is mandatory: _background_scan runs on a non-main daemon thread, and forking a multithreaded process from a non-main thread can deadlock on locks held by other threads at fork time (the default on Linux). `spawn` boots a clean interpreter that imports only scan_worker (+ its pure lib deps) to unpickle the worker — never this module — so workers don't re-run server.py's import-time side effects (reopening SQLite, attaching a second RotatingFileHandler, re-registering routes). Tests monkeypatch this to a ThreadPoolExecutor so the scan runs in-process and metadata extraction can be mocked. """ mp_ctx = multiprocessing.get_context("spawn") # Default to one worker per core so CPU-bound metadata parsing uses the # whole machine (the point of moving to processes). # FEEDBACK_MAX_SCAN_WORKERS (set by the Desktop launcher to cap memory # usage on low-RAM machines — e.g. 8 GB M2 MacBook Air) takes priority; # SCAN_MAX_WORKERS is a legacy override for Docker/bare installs. # A malformed override falls back to the core count rather than crashing. try: max_workers = int( getenv_compat("FEEDBACK_MAX_SCAN_WORKERS") or os.environ.get("SCAN_MAX_WORKERS") or (os.cpu_count() or 1) ) except ValueError: max_workers = os.cpu_count() or 1 # ProcessPoolExecutor raises ValueError on Windows when max_workers > 61 # (the WaitForMultipleObjects handle limit), so clamp there — otherwise # a high-core Windows host can't construct the pool and the scan never # starts. if sys.platform == "win32": max_workers = min(max_workers, 61) return concurrent.futures.ProcessPoolExecutor( max_workers=max(1, max_workers), mp_context=mp_ctx, ) _BUILTIN_DIAGNOSTIC_SUBDIR = "diagnostics-builtin" _BUILTIN_DIAGNOSTIC_SOURCES: list[tuple[str, str]] = [ ( "feedBack-diagnostic-basic-guitar.sloppak", "docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak", ), ] def _feedBack_server_root() -> Path: """Directory containing server.py (repo root in dev; resources/feedBack when bundled).""" return Path(__file__).resolve().parent def _builtin_diagnostic_filename() -> str: """Library filename (DLC-relative POSIX path) of the calibration sloppak — the onboarding challenge target (spec 010).""" return f"{_BUILTIN_DIAGNOSTIC_SUBDIR}/{_BUILTIN_DIAGNOSTIC_SOURCES[0][0]}" # Progression content (spec 010): bundled JSON under data/progression/ (paths, # quest pools, shop catalog). Loaded lazily-once; invalid entries are logged # warnings, never fatal. FEEDBACK_PROGRESSION_DATA overrides the root (tests). _progression_content: dict | None = None _progression_content_lock = threading.Lock() def _get_progression_content() -> dict: global _progression_content if _progression_content is None: with _progression_content_lock: if _progression_content is None: import progression as progression_mod root = getenv_compat("FEEDBACK_PROGRESSION_DATA") or ( _feedBack_server_root() / "data" / "progression" ) content, warnings = progression_mod.load_content(root) for warning in warnings: log.warning("progression content: %s", warning) _progression_content = content return _progression_content def _copy_builtin_packs( root: Path, dest_dir: Path, sources: list[tuple[str, str]], label: str, update_existing: bool = True, ) -> int: """Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``. ``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when bundled). A pack is copied when its destination is missing. Never deletes user files; refuses to follow a symlinked seed directory or destination and refuses to clobber a non-regular destination (any would let a copy escape ``dest_dir`` or destroy user data). Logs and continues on error. ``label`` prefixes every log line. ``update_existing`` controls what happens when a *regular* destination file already exists: when True (diagnostic seed) a bundle copy newer than the destination refreshes it; when False (one-time starter content) an existing file is always left as-is so the user's copy is never overwritten. Returns the number of ``sources`` that are present at their destination afterwards (freshly seeded, refreshed, or already current) — so callers can tell whether every pack made it. A skip (missing source, symlink/non-regular refusal, copy error) does not count. """ # Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it # and copies would land at the link target, outside the DLC tree. The # per-file symlink guard below cannot catch this. if dest_dir.is_symlink(): log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name) return 0 dest_dir.mkdir(parents=True, exist_ok=True) # Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for # dest_dir *after* the check above cannot redirect the per-file stat / # temp-create / replace outside the DLC tree (parent-directory TOCTOU). # os.replace accepts dir_fd on POSIX even though it isn't listed in # os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms # without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops. dir_fd = None if ( hasattr(os, "O_NOFOLLOW") and hasattr(os, "O_DIRECTORY") and os.open in os.supports_dir_fd and os.rename in os.supports_dir_fd ): try: dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY) except OSError as exc: log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc) return 0 try: present = 0 for dest_name, rel_source in sources: source = root / rel_source if not source.is_file(): log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source) continue # lstat the destination without following symlinks. Pinned by dir_fd # this resolves within the real seed dir, immune to a parent swap. try: if dir_fd is not None: dstat = os.lstat(dest_name, dir_fd=dir_fd) else: dstat = os.lstat(dest_dir / dest_name) dest_exists = True dest_islink = stat.S_ISLNK(dstat.st_mode) except FileNotFoundError: dest_exists = False dest_islink = False except OSError as exc: log.warning("%s: cannot stat %s: %s", label, dest_name, exc) continue # Refuse to seed through a symlink at the destination name. if dest_islink: log.warning("%s: destination is a symlink, skipping %s", label, dest_name) continue # A non-regular destination (directory, fifo, …) the user placed # there: never clobber it, and never count it as present — otherwise # a one-time seed would mark itself done without a real pack on disk. if dest_exists and not stat.S_ISREG(dstat.st_mode): log.warning("%s: destination is not a regular file, skipping %s", label, dest_name) continue if dest_exists: # A regular file is already there. One-time seeds (starter # content) must never overwrite the user's copy; refreshing # seeds (diagnostics) replace it only when the bundle is newer. if not update_existing: log.info("%s: already present %s", label, dest_name) present += 1 continue try: src_mtime = source.stat().st_mtime except OSError as exc: log.warning("%s: cannot stat source %s: %s", label, source, exc) continue if src_mtime <= dstat.st_mtime: log.info("%s: already present %s", label, dest_name) present += 1 continue action = "updated" else: action = "seeded" if _write_builtin_pack(source, dest_dir, dest_name, dir_fd): present += 1 log.info("%s: %s %s -> %s", label, action, source.name, dest_name) else: log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name) return present finally: if dir_fd is not None: os.close(dir_fd) def _write_builtin_pack( source: Path, dest_dir: Path, dest_name: str, dir_fd: int | None, ) -> bool: """Atomically write ``source`` to ``dest_name`` inside ``dest_dir``. Writes to a temp file then ``os.replace()``s onto the final name so a symlink raced in at the destination is overwritten (rename semantics), not followed, and a crash never leaves a half-written pack. When ``dir_fd`` is given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd replace), closing the parent-directory TOCTOU; otherwise falls back to path-based temp+replace. Returns True on success. Never raises. """ # Unique per-attempt name (O_EXCL create) so a crash that orphans a temp # can't permanently block later seeds via an EEXIST collision. tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp" try: src_stat = source.stat() except OSError as exc: log.debug("builtin pack: cannot stat source %s: %s", source, exc) return False if dir_fd is not None: tmp_fd = None try: tmp_fd = os.open( tmp_name, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o644, dir_fd=dir_fd, ) with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf: tmp_fd = None # fdopen now owns the descriptor shutil.copyfileobj(sf, tf) os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd) # Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based # refresh check matches the shutil.copy2 fallback path. Best-effort. try: os.utime( dest_name, ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns), dir_fd=dir_fd, follow_symlinks=False, ) except OSError as exc: log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc) return True except OSError as exc: log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc) if tmp_fd is not None: try: os.close(tmp_fd) except OSError: pass try: os.unlink(tmp_name, dir_fd=dir_fd) except OSError: pass return False tmp = None try: fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp") os.close(fd) shutil.copy2(source, tmp) os.replace(tmp, dest_dir / dest_name) tmp = None return True except OSError as exc: log.debug("builtin pack write failed for %s: %s", dest_name, exc) return False finally: if tmp is not None: try: os.unlink(tmp) except OSError: pass def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None: """Copy bundled diagnostic sloppaks into DLC before library scan. Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak when the destination is missing or older than the repo/bundle source. Never deletes user files or touches manually copied paths (e.g. ``diagnostics-test/``). Re-seeds whenever the destination is missing so the diagnostic target is always available. Logs and continues on errors. """ try: if dlc is None: dlc = _get_dlc_dir() if dlc is None: log.debug("Builtin diagnostic seed: no DLC folder configured, skipping") return _copy_builtin_packs( _feedBack_server_root(), dlc / _BUILTIN_DIAGNOSTIC_SUBDIR, _BUILTIN_DIAGNOSTIC_SOURCES, "Builtin diagnostic seed", ) except Exception: log.warning("Builtin diagnostic seed: unexpected error", exc_info=True) # Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE, # on first run, as a welcome library so a fresh install isn't empty. Unlike the # diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if # the user deletes the starter song it stays gone. ``starter/`` is NOT in the # library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so # seeded packs surface as ordinary library songs. _BUILTIN_STARTER_SUBDIR = "starter" _BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [ ( "beethoven-fur_elise.feedpak", "content/starter/beethoven-fur_elise.feedpak", ), ( "star_spangled_banner.feedpak", "content/starter/star_spangled_banner.feedpak", ), ( "beethoven-ode_to_joy.feedpak", "content/starter/beethoven-ode_to_joy.feedpak", ), ] _STARTER_SEED_MARKER = ".starter-content-seeded" def _seed_builtin_starter_content(dlc: Path | None = None) -> None: """Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once. Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC folder configured seeds the packs and writes the marker; subsequent runs are no-ops, so a user who deletes the starter song does not get it back on the next launch. Symlink-safe; never deletes user files. Logs, never raises. """ try: marker = CONFIG_DIR / _STARTER_SEED_MARKER # Already seeded? The marker is a sentinel: any existing path there # (regular file, or a symlink/dir a user deliberately planted to opt # out) means "done" — lstat so we detect it without following a symlink. # Worst case of a planted marker is simply no starter content, never a # data write; the O_EXCL|O_NOFOLLOW create below refuses to write # *through* a symlink regardless. try: os.lstat(marker) return except FileNotFoundError: pass except OSError as exc: log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc) return if dlc is None: dlc = _get_dlc_dir() if dlc is None: # No DLC yet — leave the marker unwritten so we retry once a # library folder is configured. log.debug("Starter content seed: no DLC folder configured, skipping") return present = _copy_builtin_packs( _feedBack_server_root(), dlc / _BUILTIN_STARTER_SUBDIR, _BUILTIN_STARTER_SOURCES, "Starter content seed", update_existing=False, ) # Only mark seeding complete once every starter pack is actually in # place. If a source was missing or a copy failed, leave the marker # unwritten so the next launch retries rather than permanently skipping. if present < len(_BUILTIN_STARTER_SOURCES): log.info( "Starter content seed: %d/%d packs present, will retry next launch", present, len(_BUILTIN_STARTER_SOURCES), ) return # Record completion with an exclusive, no-follow create so a planted or # raced symlink at the marker path can't redirect the write outside # CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a # symlink, so we never write through one. try: CONFIG_DIR.mkdir(parents=True, exist_ok=True) flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0) fd = os.open(marker, flags, 0o644) try: os.write(fd, b"1\n") finally: os.close(fd) except FileExistsError: pass # already marked (or a non-regular path is squatting) — fine except OSError as exc: log.warning("Starter content seed: could not write marker %s: %s", marker, exc) except Exception: log.warning("Starter content seed: unexpected error", exc_info=True) def _background_scan(): """Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing. Never sets `_scan_status["running"] = False` — ownership of that flag lives in `_scan_runner` so a `_kick_scan()` racing this function's terminal write cannot observe a stale False and start a second runner. """ global _scan_status _scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "listing"} # Load config once so both the DLC-dir lookup and the platform filter # read from the same snapshot, avoiding a redundant parse of config.json. _cfg = _load_config(CONFIG_DIR / "config.json") or _default_settings() dlc = _get_dlc_dir(_cfg) if not dlc: _scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "idle", "error": "DLC folder not configured"} log.warning("Scan: no DLC folder configured") return _seed_builtin_diagnostic_sloppaks(dlc) _seed_builtin_starter_content(dlc) # Listing can fail on macOS without Full Disk Access, or on Docker if the # path isn't shared. Report the failure explicitly rather than silently # appearing to scan nothing. try: # Generated-content sloppaks that the highway WS must resolve by path # but that are NOT library songs. Two conventions share this carve-out: # - tutorials-builtin/ — lesson drills seeded by the tutorials plugin # (see plugins/tutorials/routes.py::_seed_builtin_packs). # - minigames-builtin/ — exercise charts generated on demand by # minigame plugins (e.g. Chord Sprint writes alternating-chord # drills here). Cached/reused per exercise, never browsed. # Both are kept out of the scan; _resolve_dlc_path still loads them by # path for playback. def _is_excluded_from_library(p: Path) -> bool: return "tutorials-builtin" in p.parts or "minigames-builtin" in p.parts # Sloppaks: match both file (zip) and directory form, across both the # `.feedpak` and legacy `.sloppak` suffixes. _cands = sorted(p for ext in sloppak_mod.SONG_EXTS for p in dlc.rglob(f"*{ext}")) sloppaks = [f for f in _cands if sloppak_mod.is_sloppak(f) and not _is_excluded_from_library(f)] # Loose song folders: any directory containing a non-preview *.wem + *.xml. # Skip directories that are actually sloppak bundles — those are # already in `sloppaks`; the dispatcher's sloppak-first precedence # would route them to the sloppak path anyway, but adding them # here would inflate the scan queue and over-count the total. loose_songs = [] seen_loose = set() sloppak_dirs = {p for p in sloppaks if p.is_dir()} for wem in sorted(dlc.rglob("*.wem")): if "preview" in wem.stem.lower(): continue if _is_excluded_from_library(wem): continue d = wem.parent if d in sloppak_dirs or d.name.lower().endswith(sloppak_mod.SONG_EXTS): continue if d not in seen_loose and loosefolder_mod.is_loose_song(d): loose_songs.append(d) seen_loose.add(d) except PermissionError as e: msg = (f"Permission denied reading {dlc}. " "On macOS: grant Full Disk Access to the app in System Settings → Privacy & Security. " "With Docker: share this path in Docker Desktop → Settings → Resources → File Sharing.") log.error("Scan failed: %s (%s)", msg, e) _scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "error", "error": msg} return except OSError as e: log.error("Scan failed listing %s: %s", dlc, e) _scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "error", "error": f"Unable to list {dlc}: {e}"} return all_songs = sloppaks + loose_songs log.info("Scan: listed %d sloppaks and %d loose folders in %s", len(sloppaks), len(loose_songs), dlc) current_files = {_relpath(f, dlc) for f in all_songs} # Clean up stale DB entries. delete_missing reports both deltas (rows pruned # + genuinely-new files) so the scan can surface an added/removed summary. _delta = meta_db.delete_missing(current_files) removed, added = _delta["removed"], _delta["added"] if removed: log.info("Removed %d stale DB entries", removed) # Figure out which need scanning to_scan = [] for f in all_songs: # Skip entries that vanish or become unreadable between listing # and stat. Without this, one concurrent move/delete in DLC_DIR # would crash the scan thread and leave `_scan_status["running"]` # stuck true with no path to recover. try: mtime, size = _stat_for_cache(f) except OSError as e: log.debug("scan: skipping %s (%s)", f, e) continue cache_key = _relpath(f, dlc) try: cached = meta_db.get(cache_key, mtime, size) except Exception as e: # Keep scanning even if a single metadata lookup fails. # The file will be re-scanned and cache repaired by put(). log.warning("scan cache lookup failed for %s: %s", cache_key, e) cached = None if not cached: to_scan.append((f, mtime, size, dlc)) elif cached.get("arrangements") and any( "smart_name" not in a for a in cached["arrangements"] ): # Row was scanned before smart naming was introduced — force a # rescan so the DB picks up authoritative path flags from the # manifest JSON and stores correct smart_name values. Don't # re-queue rows where smart_name is explicitly null: the writer # only emits that when compute_smart_names truly can't classify # the arrangement (e.g. a name outside the recognised set with # zero path flags), so rescanning would produce the same null # forever and never converge. to_scan.append((f, mtime, size, dlc)) if not to_scan: _scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed} log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs)) return # Refine: all discovered songs need scanning → treat as first-time import # (covers moved DLC folder / fully-stale DB as well as a genuinely empty DB). is_first_scan = bool(all_songs) and len(to_scan) == len(all_songs) _scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "scanning", "total": len(to_scan), "is_first_scan": is_first_scan} log.info("Library: %d sloppaks + %d loose folders, %d cached, %d to scan", len(sloppaks), len(loose_songs), len(all_songs) - len(to_scan), len(to_scan)) with _make_scan_executor() as executor: futures = {executor.submit(_scan_one, item): item[0].name for item in to_scan} for future in concurrent.futures.as_completed(futures): fname = futures[future] try: name, mtime, size, meta = future.result() meta_db.put(name, mtime, size, meta) except Exception as e: log.warning("scan failed for %s: %s", fname, e) _scan_status["done"] += 1 _scan_status["current"] = fname log.info("Scan complete: %d songs cached", len(to_scan)) _scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed} _scan_kick_lock = threading.Lock() _scan_rescan_pending = False # Handles to the running scan / enrichment worker threads. Both use the shared # MetadataDB connection, so teardown/shutdown MUST join them before closing that # connection — a daemon thread mid-query on a closed SQLite conn is a native # use-after-free that segfaults the process (seen flaky in CI). Set by # _kick_scan / _kick_enrich; joined by _join_background_db_threads(). _scan_thread: threading.Thread | None = None _enrich_thread: threading.Thread | None = None def _join_background_db_threads(timeout: float = 30.0) -> None: """Block until the background scan + enrichment workers finish (or timeout). A scan kicks enrichment on completion, so join the scan first — by the time it returns, _kick_enrich() has set _enrich_thread — then join enrichment.""" st = _scan_thread if st is not None and st.is_alive(): st.join(timeout) et = _enrich_thread if et is not None and et.is_alive(): et.join(timeout) def _kick_scan() -> bool: """Request a library rescan, single-flight + coalescing. Returns True if a new scan thread was started, False if one was already running. In the latter case a follow-up pass is queued and runs as soon as the current scan finishes so files landing mid-scan (e.g. an upload that finalizes after the scan has already listed DLC_DIR) are not lost until the next periodic pass. Multiple late-arriving requests coalesce into a single follow-up. """ global _scan_rescan_pending, _scan_thread with _scan_kick_lock: if _scan_status["running"]: _scan_rescan_pending = True return False # Mark running synchronously so a parallel _kick_scan() observes it # before the worker thread has a chance to reassign _scan_status. _scan_status["running"] = True _scan_thread = threading.Thread(target=_scan_runner, daemon=True) _scan_thread.start() return True def _scan_runner(): """Run _background_scan, then re-run if requests arrived mid-scan.""" global _scan_rescan_pending while True: try: _background_scan() except Exception: log.exception("background scan failed unexpectedly") with _scan_kick_lock: if not _scan_rescan_pending: _scan_status["running"] = False break _scan_rescan_pending = False _scan_status["running"] = True # Enrichment rides scan completion (library-metadata design §6): the scan # pool is a side-effect-free, no-network process pool by design, so # enrichment is a SEPARATE post-scan pass — non-blocking, the library is # usable immediately. The 5-minute periodic rescan re-kicks it, which is # the natural low-priority retry hook. _kick_enrich() # ── Metadata enrichment worker (P7 plumbing + P8 matcher) ───────────────────── # A single throttled daemon thread + queue, mirroring _kick_scan/_scan_runner # (single-flight + coalescing; NOT a pool — external lookups are rate-limited # to ~1/s, which makes a pool pointless). P7 shipped the lifecycle; P8 fills # in the matcher (_enrich_one): local cache → manifest mbid/isrc exact keys → # MusicBrainz text search, scored into auto/review/failed tiers by # lib/mb_match.py. Wrong-match is worse than slow (design §5): medium # confidence goes to the Match-Review queue, never straight to canonical. _enrich_kick_lock = threading.Lock() _enrich_pending_pass = False _enrich_status = {"running": False, "processed": 0, "last_pass_at": None} # Minimum spacing between EXTERNAL lookups (design: ≤1 req/s + local cache). _ENRICH_MIN_INTERVAL = 1.1 _enrich_last_fetch = 0.0 # Serializes throttling across the background daemon thread AND the sync # /api/enrichment/search route (FastAPI runs sync routes in a threadpool). _enrich_throttle_lock = threading.Lock() def _enrichment_art_dir() -> Path: """The size-capped art cache dir (populated by the Cover Art slice; the LRU cap policy lands with it). Under CONFIG_DIR so Settings backup/restore and the docker volume already cover it.""" d = CONFIG_DIR / "art_cache" d.mkdir(parents=True, exist_ok=True) return d def _enrich_throttle(): """Block until an external lookup is allowed. Matchers MUST call this before every network request — and must NOT hold meta_db._lock across the request (fetch outside the lock, write inside).""" global _enrich_last_fetch # Hold the lock across the read, sleep, and write so concurrent callers # serialize instead of all reading the same stale timestamp and firing # together (which would burst past MusicBrainz's 1 req/s limit). with _enrich_throttle_lock: wait = _ENRICH_MIN_INTERVAL - (time.monotonic() - _enrich_last_fetch) if wait > 0: time.sleep(wait) _enrich_last_fetch = time.monotonic() class EnrichTransportError(Exception): """Network-level enrichment failure — offline, DNS, MusicBrainz down or rate-limiting. Pauses the current pass (rows keep their state and no attempt is consumed); the next kick (scan-complete / the 5-min periodic rescan) retries naturally.""" _MB_API_ROOT = "https://musicbrainz.org/ws/2" _enrich_ua_cache: str | None = None def _enrich_user_agent() -> str: """MusicBrainz etiquette requires a real identifying User-Agent (app/version + contact URL); anonymous defaults get throttled/blocked.""" global _enrich_ua_cache if _enrich_ua_cache is None: version = "unknown" try: vf = Path(__file__).parent / "VERSION" if vf.exists(): version = vf.read_text().strip() or "unknown" except (OSError, UnicodeDecodeError): pass _enrich_ua_cache = f"feedBack/{version} (https://github.com/got-feedback/feedBack)" return _enrich_ua_cache def _enrich_network_enabled() -> bool: """False = the matcher runs local-only (hash stamping, cache copies) and never opens a socket. FEEDBACK_ENRICH_OFFLINE is the explicit user kill-switch (privacy / air-gapped installs); FEEDBACK_SKIP_STARTUP_TASKS marks the test/CI environment, where pytest must never reach the network no matter what a test triggers.""" return not (_env_flag("FEEDBACK_ENRICH_OFFLINE") or _env_flag("FEEDBACK_SKIP_STARTUP_TASKS")) def _mb_http_get(path: str, params: dict) -> dict | None: """The ONE place enrichment touches the network (tests fake exactly this seam). Throttled (≤1 req/s via _enrich_throttle), identified (real User-Agent), offline-guarded. Returns the parsed JSON body, or None for a 404 lookup; raises EnrichTransportError for anything network-shaped. NEVER call this while holding meta_db._lock — fetch outside, write inside.""" if not _enrich_network_enabled(): raise EnrichTransportError("enrichment network disabled") import requests # declared in requirements.txt; lazy so tests never need it _enrich_throttle() try: resp = requests.get( f"{_MB_API_ROOT}/{path.lstrip('/')}", params={**params, "fmt": "json"}, headers={"User-Agent": _enrich_user_agent()}, timeout=10, ) except requests.RequestException as e: raise EnrichTransportError(str(e)) from e if resp.status_code == 404: return None if resp.status_code == 503: # MusicBrainz signals rate-limit pressure with 503 — back the whole # pass off rather than hammering on. raise EnrichTransportError("musicbrainz 503 (rate limited)") if resp.status_code != 200: raise EnrichTransportError(f"musicbrainz HTTP {resp.status_code}") try: return resp.json() except ValueError as e: raise EnrichTransportError("bad JSON from musicbrainz") from e def _mb_search_recordings(artist, title, limit: int = 8) -> list[dict]: """Text search (tier 2–4): denoised Lucene query over /recording.""" query = mb_match.build_recording_query(artist, title) if not query: return [] body = _mb_http_get("recording", {"query": query, "limit": limit}) return mb_match.parse_search_response(body or {}) def _mb_lookup_recording(mbid: str) -> dict | None: """Direct lookup for a manifest-carried recording MBID (tier 0).""" body = _mb_http_get( f"recording/{mbid}", {"inc": "artist-credits+releases+release-groups+isrcs+genres"}) return mb_match.parse_recording_doc(body) if body else None def _mb_lookup_isrc(isrc: str) -> list[dict]: """Recordings registered under a manifest-carried ISRC (tier 1).""" body = _mb_http_get( f"isrc/{isrc}", {"inc": "artist-credits+releases+release-groups"}) if not body: return [] docs = body.get("recordings") or [] return [c for c in (mb_match.parse_recording_doc(d) for d in docs) if c] # Strict shapes for the manifest's optional identity keys (feedpak spec §5.1). # Validated before use — the mbid is interpolated into a URL path, so junk or # hostile manifest values must never reach the request line. _MBID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") _ISRC_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$") def _manifest_exact_ids(filename: str) -> dict: """Optional `mbid`/`isrc` from the pack manifest — the spec's additive identity keys. Feature-detected: packs published before that spec revision simply lack them and fall through to text matching. READ-only: enrichment never writes anything into pack files.""" try: dlc = _get_dlc_dir() if not dlc: return {} p = _resolve_dlc_path(dlc, filename) if p is None or not p.exists() or not sloppak_mod.is_sloppak(p): return {} manifest = sloppak_mod.load_manifest(p) or {} except Exception: return {} out = {} mbid = str(manifest.get("mbid", "") or "").strip().lower() if _MBID_RE.match(mbid): out["mbid"] = mbid isrc = str(manifest.get("isrc", "") or "").strip().upper() # Spec 1.14.0: the stored form is the bare 12-char code, but ISRCs # circulate hyphenated in the wild (US-ABC-24-00001) — the separators # are presentation, not part of the code, so a hand-authored display # form still matches (consumers SHOULD strip before comparing). isrc = isrc.replace("-", "").replace(" ", "") if _ISRC_RE.match(isrc): out["isrc"] = isrc return out # Failed-row retry backoff: 1 h after the first failed attempt, doubling per # attempt, capped at a week — a permanently-unmatchable obscure chart must # not re-hammer MusicBrainz on every scan kick. _ENRICH_BACKOFF_BASE = 3600.0 _ENRICH_BACKOFF_CAP = 7 * 86400.0 def _enrich_backoff_elapsed(attempts, last_attempt_at, now: float) -> bool: if not last_attempt_at: return True delay = min(_ENRICH_BACKOFF_BASE * (2 ** max(0, int(attempts or 1) - 1)), _ENRICH_BACKOFF_CAP) return (now - float(last_attempt_at)) >= delay # Review tier keeps a short ranked candidate list for the drawer; more than a # handful is noise the user has to scroll past. _ENRICH_MAX_CANDIDATES = 5 # ── Cover art (R3/P9) ───────────────────────────────────────────────────────── # The art cache dir (CONFIG_DIR/art_cache) holds two kinds of file: # {safe_name}.png / .gif — USER OVERRIDES (upload or URL-fetch; never # evicted, removed only with the song or by the # explicit remove-override route) # caa_{release_mbid}.jpg — COVER ART ARCHIVE fetches, keyed by release so # every chart of the same release shares one file; # size-capped LRU (evictions reset the enrichment # rows so a later pass may re-fetch) _CAA_CACHE_CAP_BYTES = 200 * 1024 * 1024 # Per-cover cap on a single CAA fetch. The 500px thumbnail is normally tens of # KB; this bounds any one response independently of the aggregate LRU cap so a # single oversized (or misbehaving) release can't blow up memory/disk. _CAA_MAX_BYTES = 10 * 1024 * 1024 # A release MBID is a UUID; before interpolating it into a cache-file path we # require a conservative token (alphanumerics, hyphen, underscore only) so no # separator or '.' can ever appear — blocks path traversal. Defence in depth: # cheap even though the DB only ever holds MusicBrainz UUIDs. (Distinct name # from the strict recording-MBID _MBID_RE above — this only gates a filename.) _CAA_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$") def _caa_http_get(release_id: str) -> bytes | None: """Fetch a release's front cover from the Cover Art Archive — the one network seam of the art layer (tests fake exactly this). Same etiquette as the MusicBrainz client: throttled, identified, offline-guarded. Returns the image bytes, None when the release has no cover (404), and raises EnrichTransportError for anything network-shaped.""" if not _enrich_network_enabled(): raise EnrichTransportError("enrichment network disabled") import requests _enrich_throttle() try: with requests.get( f"https://coverartarchive.org/release/{release_id}/front-500", headers={"User-Agent": _enrich_user_agent()}, timeout=15, allow_redirects=True, stream=True, ) as resp: if resp.status_code == 404: return None if resp.status_code != 200: raise EnrichTransportError(f"cover art archive HTTP {resp.status_code}") # Stream with a per-file cap so a huge response never fully downloads. data = b"" for chunk in resp.iter_content(65536): data += chunk if len(data) > _CAA_MAX_BYTES: # Not network-shaped: settle just this row as 'error' (the # art loop's generic handler) rather than pausing the pass. raise ValueError("cover art exceeds size cap") return data except requests.RequestException as e: raise EnrichTransportError(str(e)) from e def _caa_release_index(release_id: str) -> dict | None: """Fetch a release's Cover Art Archive INDEX (json — image METADATA, not image bytes): the cover picker's one network seam (tests fake exactly this). Same etiquette as _caa_http_get: throttled, identified, offline-guarded. Returns the parsed index dict, None when the archive has no art for the release (404), and raises EnrichTransportError for anything network-shaped.""" if not _enrich_network_enabled(): raise EnrichTransportError("enrichment network disabled") import requests _enrich_throttle() try: resp = requests.get( f"https://coverartarchive.org/release/{release_id}", headers={"User-Agent": _enrich_user_agent(), "Accept": "application/json"}, timeout=15, allow_redirects=True) if resp.status_code == 404: return None if resp.status_code != 200: raise EnrichTransportError(f"cover art archive HTTP {resp.status_code}") body = resp.json() return body if isinstance(body, dict) else None except requests.RequestException as e: raise EnrichTransportError(str(e)) from e except ValueError as e: # Non-JSON body — treat as a transport blip (nothing gets cached, a # later picker-open retries) rather than caching an empty index. raise EnrichTransportError(f"cover art archive returned non-JSON: {e}") from e # Per-release lock so two concurrent /art/candidates opens for the SAME # release serialise their read→fetch→write (the "index cached, no second # fetch" invariant). Different releases still fetch in parallel; the guard # lock only protects the tiny registry lookup. _caa_index_locks: dict[str, threading.Lock] = {} _caa_index_locks_guard = threading.Lock() def _caa_index_lock(release_id: str) -> threading.Lock: with _caa_index_locks_guard: lock = _caa_index_locks.get(release_id) if lock is None: lock = _caa_index_locks[release_id] = threading.Lock() return lock def _caa_index_cached(release_id: str) -> list[dict]: """A release's CAA index images through a TTL-less on-disk cache (`caa_index_{id}.json` beside the cover files — indexes are stable, and a 404 is cached as an empty index so a coverless release is never re-asked). Outside the network seam on purpose: tests fake _caa_release_index and still exercise this cache. Raises EnrichTransportError on a cache-miss network failure (the caller stops asking for further releases); malformed ids/bodies yield [].""" if not _CAA_ID_RE.match(str(release_id or "")): return [] cache_file = _enrichment_art_dir() / f"caa_index_{release_id}.json" # Hold the per-id lock across the check→fetch→write so a concurrent open # for the same release finds the freshly-written cache instead of racing a # second fetch. (The network fetch sleeps in _enrich_throttle under a # different lock — no deadlock; a different release is never blocked.) with _caa_index_lock(str(release_id)): if cache_file.is_file(): try: body = json.loads(cache_file.read_text(encoding="utf-8")) imgs = body.get("images") if isinstance(body, dict) else None if isinstance(imgs, list): return imgs except (OSError, ValueError): pass # unreadable/corrupt cache → refetch below body = _caa_release_index(release_id) if body is None or not isinstance(body.get("images"), list): body = {"images": []} try: cache_file.write_text(json.dumps(body), encoding="utf-8") except OSError: pass # cache is best-effort; the response still serves return body["images"] def _art_safe_name(filename: str) -> str: """The flattened cache-file stem the art routes key user overrides on (matches the legacy /art/upload naming, so old uploads keep working).""" return filename.replace("/", "_").replace(" ", "_") def _art_override_paths(filename: str) -> list[Path]: """Existing user-override art files for a song, GIF first (it wins — the animated local-only bonus outranks a stale PNG).""" stem = _art_safe_name(filename) return [p for p in (ART_CACHE_DIR / f"{stem}.gif", ART_CACHE_DIR / f"{stem}.png") if p.is_file()] def _song_pack_art_exists(filename: str) -> bool: """Whether the song carries its own art (sloppak cover / loose-folder image). Pack art always outranks a CAA fetch, so the art worker marks these and never spends a request on them.""" try: dlc = _get_dlc_dir() if not dlc: return False p = _resolve_dlc_path(dlc, filename) if p is None or not p.exists(): return False if sloppak_mod.is_sloppak(p): return sloppak_mod.read_cover_bytes(p) is not None if loosefolder_mod.is_loose_song(p): return loosefolder_mod.find_art(p) is not None except Exception: pass return False def _prune_caa_cache() -> None: """Keep the CAA side of the art cache under its size cap: evict the oldest caa_* files (mtime LRU) and reset the enrichment rows that pointed at them. User-override files are never touched.""" try: files = sorted(ART_CACHE_DIR.glob("caa_*.jpg"), key=lambda p: p.stat().st_mtime) total = sum(p.stat().st_size for p in files) evicted: list[str] = [] while files and total > _CAA_CACHE_CAP_BYTES: victim = files.pop(0) try: total -= victim.stat().st_size victim.unlink() evicted.append(str(victim)) except OSError: break if evicted: meta_db.clear_enrichment_art_paths(evicted) log.info("art cache: evicted %d cover(s) to stay under the cap", len(evicted)) except Exception: log.exception("art cache prune failed") def _enrich_art_one(row: dict) -> bool: """Resolve one matched song's cover-art situation (art worker, phase 3). Returns True when a cover was actually fetched. Every outcome writes an art_state so the row never re-queues: 'pack' — the song ships its own art (it wins; nothing to do) 'user' — an override exists (it wins; nothing to do) 'caa' — front cover cached (possibly deduped from an earlier fetch of the same release — no network on that path) 'none' — the Cover Art Archive has no cover for this release Network errors raise EnrichTransportError → the pass pauses and the row stays unevaluated for the next kick.""" fn, release_id = row["filename"], row["mb_release_id"] if not release_id or not _CAA_ID_RE.match(str(release_id)): # Malformed release id — never build a cache path from it. Settle the # row as 'error' so it isn't re-queued every pass. meta_db.set_enrichment_art(fn, None, "error") return False if _song_pack_art_exists(fn): meta_db.set_enrichment_art(fn, None, "pack") return False if _art_override_paths(fn): meta_db.set_enrichment_art(fn, None, "user") return False cache_file = _enrichment_art_dir() / f"caa_{release_id}.jpg" if cache_file.is_file(): meta_db.set_enrichment_art(fn, str(cache_file), "caa") return False data = _caa_http_get(release_id) if data is None: meta_db.set_enrichment_art(fn, None, "none") return False cache_file.write_bytes(data) meta_db.set_enrichment_art(fn, str(cache_file), "caa") _prune_caa_cache() return True _ENRICH_APPLY_FIELDS = { # Per-field auto-apply toggle → the candidate fields it governs. The # MusicBrainz ids + isrc are deliberately NOT here: they're identity, # not display — the art fetch and any future re-match need them stamped # even when every display field is toggled off. "enrich_apply_names": ("artist", "title", "album", "artist_sort"), "enrich_apply_year": ("year",), "enrich_apply_genres": ("genres",), } def _enrich_blocked_apply_keys(cfg: dict) -> frozenset: """The per-field auto-apply toggle keys that are currently OFF (suppressed). Its complement (`_ENRICH_APPLY_FIELDS` minus these) is what an automatic match may canonicalize.""" return frozenset(k for k in _ENRICH_APPLY_FIELDS if cfg.get(k, True) is False) def _enrich_apply_mask(cfg: dict) -> str: """Canonical marker of the suppressed apply keys, persisted on each automatic match so re-enabling a field re-queues the row for backfill (enrichment_pending) and a partial match can't seed siblings (enrichment_cache_lookup). '' = nothing suppressed (the default).""" return ",".join(sorted(_enrich_blocked_apply_keys(cfg))) def _enrich_field_filter(cfg: dict): """Build the cand filter for AUTOMATIC matches from the per-field auto-apply settings: strips the display fields whose toggle is off before they're stamped as canonical. Returns None when everything is on (the default) so the common path stays zero-copy. Review candidates and user-confirmed picks bypass this — a match the user confirms in the modal applies in full.""" blocked = {f for key in _enrich_blocked_apply_keys(cfg) for f in _ENRICH_APPLY_FIELDS[key]} if not blocked: return None return lambda cand: {k: v for k, v in cand.items() if k not in blocked} def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None, apply_mask: str = "") -> None: """The matcher (P8; replaces P7's no-op). Precedence per design §5: 1. local match-cache by content_hash — another chart of the same recording already matched/pinned → copy it, NO network; 2. manifest `mbid` (tier 0) / `isrc` (tier 1) exact keys → direct lookup, auto; 3. text search → scored tiers: auto (high) / review (medium — a human confirms before anything canonicalizes) / failed (low, retried on backoff). `auto_min` is the user's auto-apply confidence setting (None → the engine default); it moves only the auto/review boundary of step 3 — the per-field floors and exact-key tiers are unaffected. `field_filter` (from _enrich_field_filter) strips per-field-disabled display values from every AUTOMATIC stamp — all three steps here are automatic, so it applies to each; the review tier stores candidates unfiltered because accepting one is a user action. Never touches a `manual` row (the writer enforces it). `apply_mask` (the suppressed keys, from _enrich_apply_mask) is stamped on each AUTOMATIC match so a later re-enable re-queues the row for backfill and a partial match can't seed siblings. Network errors raise EnrichTransportError so the pass pauses instead of burning attempts while offline.""" fn, chash = row["filename"], row["content_hash"] cached = meta_db.enrichment_cache_lookup(chash, exclude_filename=fn) if cached: score = cached.pop("score", None) if field_filter: cached = field_filter(cached) meta_db.apply_enrichment_match(fn, chash, "matched", source="cache", score=score, cand=cached, apply_mask=apply_mask) return ids = _manifest_exact_ids(fn) if ids.get("mbid"): cand = _mb_lookup_recording(ids["mbid"]) if cand: meta_db.apply_enrichment_match(fn, chash, "matched", source="mbid", score=1.0, apply_mask=apply_mask, cand=field_filter(cand) if field_filter else cand) return # A 404'd mbid (typo'd manifest) falls through to the text tiers. if ids.get("isrc"): cands = mb_match.rank_candidates(row, _mb_lookup_isrc(ids["isrc"])) if cands: meta_db.apply_enrichment_match(fn, chash, "matched", source="isrc", score=1.0, apply_mask=apply_mask, cand=field_filter(cands[0]) if field_filter else cands[0]) return ranked = mb_match.rank_candidates(row, _mb_search_recordings(row.get("artist"), row.get("title"))) best = ranked[0] if ranked else None tier = mb_match.classify(row, best, best["score"], auto_min=auto_min) if best else "none" if tier == "auto": meta_db.apply_enrichment_match(fn, chash, "matched", source="text", score=best["score"], apply_mask=apply_mask, cand=field_filter(best) if field_filter else best) elif tier == "review": meta_db.apply_enrichment_match(fn, chash, "review", source="text", score=best["score"], candidates=ranked[:_ENRICH_MAX_CANDIDATES]) else: meta_db.apply_enrichment_match(fn, chash, "failed", source="text", score=(best["score"] if best else None), candidates=ranked[:_ENRICH_MAX_CANDIDATES] or None, bump_attempts=True) def _background_enrich(): """One bounded pass, two phases. Phase 1 stamps/refreshes identity-hash stubs for every song whose identity is new or changed — pure-local, so hashes stay fresh (and stale matches drop back to `unscanned`) even fully offline. Phase 2 runs the matcher over those rows plus any `failed` rows whose backoff has elapsed; a transport failure pauses it (state untouched, no attempt burned) and the next kick retries. Offline (kill-switch or the test env) skips phase 2 entirely. Never drains in a loop — a dead network would make that spin forever.""" _enrich_status["processed"] = 0 # User settings gate the BACKGROUND matcher only (the review modal's # manual search/fix stays available when it's off); read once per pass, # up front so the pending query can honour the per-field apply mask # (a re-enabled field re-queues its `matched` rows for backfill). cfg = _load_config(CONFIG_DIR / "config.json") or {} allowed_keys = frozenset(_ENRICH_APPLY_FIELDS) - _enrich_blocked_apply_keys(cfg) apply_mask = _enrich_apply_mask(cfg) try: pending = meta_db.enrichment_pending(limit=100000, allowed_keys=allowed_keys) except Exception: log.exception("enrichment: pending query failed") return for row in pending: try: meta_db.upsert_enrichment_stub(row["filename"], row["content_hash"]) except Exception as e: log.warning("enrichment stub failed for %s: %s", row.get("filename"), e) _enrich_status["processed"] += 1 _enrich_status["last_pass_at"] = time.time() if cfg.get("enrich_enabled", True) is False: if pending: log.info("Enrichment pass: %d rows stamped (matching disabled in Settings)", len(pending)) return try: auto_min = float(cfg.get("enrich_auto_threshold", 0.9)) except (TypeError, ValueError): auto_min = 0.9 if not _enrich_network_enabled(): if pending: log.info("Enrichment pass: %d rows stamped (network disabled — matching skipped)", len(pending)) return # Scraper options (R1), read from the same per-pass cfg: `mb_on` gates # the matcher (phase 2), `art_on` the cover-art fetch (phase 3 — the # Cover Art Archive is the only automatic art source today, so the # source toggle and the cover-art apply toggle both have to be on). mb_on = cfg.get("enrich_src_musicbrainz", True) is not False art_on = (cfg.get("enrich_src_caa", True) is not False and cfg.get("enrich_apply_art", True) is not False) field_filter = _enrich_field_filter(cfg) now = time.time() retriable = [] if mb_on: try: retriable = [r for r in meta_db.enrichment_failed_rows(limit=100000) if _enrich_backoff_elapsed(r.get("attempts"), r.get("last_attempt_at"), now)] except Exception: log.exception("enrichment: failed-row query failed") elif pending: log.info("Enrichment pass: %d rows stamped (MusicBrainz source disabled in Settings)", len(pending)) matched = 0 # A `failed` row with a changed identity hash can surface in BOTH lists; # de-dup by filename so each row consumes the rate budget only once. seen_filenames = set() queue = [] for row in (pending + retriable) if mb_on else []: fn = row.get("filename") if fn in seen_filenames: continue seen_filenames.add(fn) queue.append(row) for row in queue: try: _enrich_one(row, auto_min=auto_min, field_filter=field_filter, apply_mask=apply_mask) matched += 1 except EnrichTransportError as e: log.info("enrichment: network unavailable, pass paused (%s)", e) break except Exception as e: log.warning("enrichment failed for %s: %s", row.get("filename"), e) try: # Park the row on the failure backoff instead of retrying a # poisoned input every pass. meta_db.apply_enrichment_match( row["filename"], row["content_hash"], "failed", source="error", bump_attempts=True) except Exception: pass if mb_on and (pending or retriable): log.info("Enrichment pass: %d rows stamped, %d matched", len(pending), matched) # Phase 3 — cover art (R3/P9). For freshly-matched songs, resolve the art # situation once: songs with their own pack art (or a user override) are # marked and skipped; the rest fetch the release's front cover from the # Cover Art Archive into the size-capped cache. Same pause-on-transport- # error rule as matching — a dead network never burns a row's evaluation. # Rows skipped here stay art_state NULL, so re-enabling the toggles picks # them up on the next pass — nothing is permanently forfeited. if not art_on: return try: art_rows = meta_db.enrichment_art_pending(limit=100000) except Exception: log.exception("enrichment: art-pending query failed") return fetched = 0 for row in art_rows: try: fetched += 1 if _enrich_art_one(row) else 0 except EnrichTransportError as e: log.info("enrichment: network unavailable, art pass paused (%s)", e) break except Exception as e: log.warning("enrichment art failed for %s: %s", row.get("filename"), e) try: meta_db.set_enrichment_art(row["filename"], None, "error") except Exception: pass if art_rows: log.info("Enrichment art pass: %d evaluated, %d covers fetched", len(art_rows), fetched) def _kick_enrich() -> bool: """Request an enrichment pass, single-flight + coalescing (the _kick_scan contract): True = a worker thread was started, False = one is running and a follow-up pass was queued.""" global _enrich_pending_pass, _enrich_thread with _enrich_kick_lock: if _enrich_status["running"]: _enrich_pending_pass = True return False _enrich_status["running"] = True _enrich_thread = threading.Thread(target=_enrich_runner, daemon=True) _enrich_thread.start() return True def _enrich_runner(): global _enrich_pending_pass while True: try: _background_enrich() except Exception: log.exception("background enrichment failed unexpectedly") with _enrich_kick_lock: if not _enrich_pending_pass: _enrich_status["running"] = False return _enrich_pending_pass = False # ── Register plugin API endpoints (lightweight, before app starts) ─────────── from plugins import load_plugins, register_plugin_api register_plugin_api(app) # Plugin loading deferred to startup event (see below) to avoid blocking # server startup when many plugins are installed. @app.on_event("startup") async def startup_events(): # Safety net: re-apply the structlog pipeline in case the server was # started directly via `uvicorn server:app` (without main.py). When # running via `python main.py`, configure_logging() was already called # before uvicorn.run(..., log_config=None), so uvicorn never calls its # own dictConfig() and this call is effectively a no-op. When running # the uvicorn CLI directly, uvicorn applies LOGGING_CONFIG before the # ASGI startup hook fires, overwriting the uvicorn* handlers; this call # restores them for all messages after "Waiting for application startup". configure_logging() loop = asyncio.get_running_loop() global _event_loop _event_loop = loop # Test/CI escape hatch: tests that import the FastAPI app via TestClient # don't need plugin loading or the background library scan, and those # paths touch the user filesystem in ways that aren't safe under # parallel test runs. Drive startup straight to a terminal "complete" # phase so any frontend startup waiter that observes the lifespan also # unblocks cleanly (the SSE/poll client treats only `complete` and # `error` as terminal when `running` becomes false). if _env_flag("FEEDBACK_SKIP_STARTUP_TASKS"): log.info("[startup] Skipping plugin load and background scan") # Tests pop `server` from sys.modules across runs, but the `plugins` # module is not reloaded — so LOADED_PLUGINS can carry stale entries # from a previous test's startup, which `/api/plugins` would then # expose despite this branch reporting zero loaded plugins. Normal # startup clears it inside load_plugins; do the same here under the # same lock so this skip path matches that invariant. from plugins import LOADED_PLUGINS, PENDING_PLUGINS, PLUGINS_LOCK with PLUGINS_LOCK: LOADED_PLUGINS.clear() PENDING_PLUGINS.clear() _set_startup_status( running=False, phase="complete", message="Startup tasks skipped (FEEDBACK_SKIP_STARTUP_TASKS).", error=None, current_plugin="", loaded=0, total=0, ) return _set_startup_status( running=True, phase="starting", message="Core server ready. Starting plugin loader...", error=None, ) plugin_context = { "config_dir": CONFIG_DIR, "get_dlc_dir": _get_dlc_dir, # Pass the DLC-root resolver (not its result) so loose-folder # metadata keeps its dlc-relative artist/album inference while the # lookup stays lazy — archive/sloppak extraction never reads config. # Plugins still call this with just a path. "extract_meta": lambda p: _extract_meta_for_file(p, _get_dlc_dir), "meta_db": meta_db, "get_scan_status": lambda: dict(_scan_status), "get_art_cache_dir": lambda: ART_CACHE_DIR, "library_providers": library_providers, "register_library_provider": register_library_provider, "unregister_library_provider": unregister_library_provider, "register_tuning_provider": register_tuning_provider, "unregister_tuning_provider": unregister_tuning_provider, "get_sloppak_cache_dir": lambda: SLOPPAK_CACHE_DIR, "register_demo_janitor_hook": register_demo_janitor_hook, # Unified XP service (fee[dB]ack v0.3.0). Plugins that award XP # (minigames, tutorials, …) should feed the single core store via these # instead of keeping a private XP curve. `award_xp` returns the new # progress payload; `seed_xp` is a one-time migration of pre-unification # XP from a plugin's own store. "award_xp": lambda amount, source=None: (meta_db.award_xp(amount, source), meta_db.get_progress())[1], "get_xp_progress": lambda: meta_db.get_progress(), "seed_xp": lambda amount, marker="minigames": meta_db.seed_xp_once(amount, marker), # Reset one source's contribution to the unified total (e.g. a minigames # profile-reset). Returns the new progress payload. "reset_xp": lambda source: meta_db.reset_source_xp(source), # Progression engine (spec 010): the backend twin of the frontend # `progression` capability's record-event command. Backend plugin code # is trusted, so no type whitelist here (the HTTP intake enforces one); # returns the toast-ready summary {challenges_completed, # quests_completed, level_ups, calibration_completed, mastery_rank}. "record_progression_event": lambda event_type, payload=None: meta_db.record_progression_event( event_type, payload, _get_progression_content() ), } # Load plugins asynchronously so HTTP routes and the desktop window can # come up immediately while heavy plugin imports/install steps continue. _sync_mode = getenv_compat("FEEDBACK_SYNC_STARTUP", "").lower() in {"1", "true", "yes", "on"} def _load_plugins_background(): try: # Track all active plugin errors so that a `clear_error=True` # event from a fallback recovery correctly restores any *other* # plugin's still-unresolved failure rather than wiping the error # field entirely. # # Using a single "last error" pointer was insufficient: if plugin A # fails, then plugin B fails and later recovers, the recovery would # overwrite the pointer with B's id — and then B's `error=None` # clears the status to null even though A is still broken. # # With a dict (keyed by plugin_id, insertion-ordered) we can # remove B's entry on recovery and restore the most recent remaining # failure from A, giving an accurate picture of startup health. _active_errors: dict[str, str] = {} # plugin_id -> error text def _on_progress(event: dict): total = int(event.get("total") or 0) loaded = int(event.get("loaded") or 0) plugin_id = event.get("plugin_id") or "" message = event.get("message") or "Loading plugins..." phase = event.get("phase") or "plugins-loading" update: dict = dict( running=True, phase=phase, message=message, current_plugin=plugin_id, loaded=loaded, total=total, ) # Forward the error field only when the event explicitly # carries it. Two cases: # - Non-null string: record this plugin's failure and display it. # - Explicit null (clear_error=True in _emit_progress): # remove this plugin's failure entry, then restore the most # recently recorded still-active failure (if any) so # unresolved failures from other plugins remain visible. # An unscoped clear (no plugin_id) removes the unscoped # sentinel and applies the same restore logic. # Events that omit the key entirely leave the status unchanged, # preserving any earlier plugin error across the many # non-error progress events that follow normal setup steps. if "error" in event: err_val = event["error"] if err_val is not None: # Pop then re-insert so the key moves to the end of # insertion order even when this plugin already has an # entry. A plugin can emit more than one error during a # single load (requirements + routes), and dict.update() # on an existing key does NOT move it to the end, so # remaining[-1] could return a stale earlier message # after another plugin clears its own error. _active_errors.pop(plugin_id, None) _active_errors[plugin_id] = err_val update["error"] = err_val else: # Clear this plugin's error entry (fallback recovery or # unscoped clear), then surface the most recently added # remaining failure, or None if all have been resolved. _active_errors.pop(plugin_id, None) remaining = list(_active_errors.values()) update["error"] = remaining[-1] if remaining else None _set_startup_status(**update) def _route_setup_on_main(fn): """Schedule plugin route registration on the event-loop thread. FastAPI/Starlette router mutation is not thread-safe, so the actual setup() call is normally marshalled back onto the event loop via call_soon_threadsafe. The background thread blocks until the registration completes, raises, or a 60 s timeout elapses. In synchronous startup mode (_sync_mode=True) this function is called directly from the event-loop thread, so marshalling via call_soon_threadsafe + fut.result() would deadlock (the loop cannot drain the queued callback while it is blocked here). In that case fn() is invoked inline instead. On timeout (async mode only), startup continues normally. Any exception that eventually arrives is logged via a done-callback so it is never silently dropped. """ if _sync_mode: # Already on the event-loop thread — call directly. fn() return fut: concurrent.futures.Future = concurrent.futures.Future() # _state_lock makes the "check _cancelled + set _started" # transition in _do() atomic with the "read _started + set # _cancelled" transition in the timeout handler. Without this # lock the two threads can interleave: # # Thread A (_do): passes check-1, yields to event loop # Thread B (timeout): reads _started=False → _mid_flight=False # Thread A (_do): sets _started, passes check-2 → calls fn() # Thread B (timeout): sets _cancelled (too late) # Result: fn() runs AND fallback loads — concurrent mutation. # # With the lock, either _do() commits to running fn() before # the timeout can set _cancelled (in which case _mid_flight=True # and the fallback is skipped), or the timeout wins (sets # _cancelled=True and reads _started=False → _mid_flight=False, # then _do() sees _cancelled inside the lock and bails out). _state_lock = threading.Lock() _cancelled = threading.Event() _started = threading.Event() def _do(): with _state_lock: if _cancelled.is_set(): # Timeout already fired before we started; bail # to prevent a race with any fallback that may # have been activated by load_plugins(). if not fut.done(): fut.set_result(None) return _started.set() # Past the lock — committed to running fn(). try: fn() fut.set_result(None) except Exception as exc: fut.set_exception(exc) loop.call_soon_threadsafe(_do) try: fut.result(timeout=60) except concurrent.futures.TimeoutError as _te: _pid = getattr(fn, "_plugin_id", "unknown") # Read _started and set _cancelled atomically so _do() # can't slip through the lock and start fn() between the # two operations. with _state_lock: _mid_flight = _started.is_set() _cancelled.set() if _mid_flight: log.warning( "route registration for %r timed out after 60 s and " "setup() was already mid-flight; any routes registered " "before the timeout cannot be removed. The user-copy " "fallback will NOT be activated to prevent concurrent " "router mutation (Python threads cannot be interrupted " "mid-execution). Restart the server to recover.", _pid, ) # Signal to load_plugins() that fallback is unsafe # for this plugin — the original setup() is still # running and may add more routes concurrently. _te.setup_mid_flight = True else: log.warning( "route registration for %r timed out after 60 s; " "setup() had not started yet, so it has been cancelled " "and the user-copy fallback (if any) can proceed safely.", _pid, ) # Prevent the still-queued _do() from executing if it # hasn't started yet — avoids races with any fallback. # Note: _cancelled was already set inside _state_lock above. def _log_deferred(f: concurrent.futures.Future): try: exc = f.exception() except concurrent.futures.CancelledError: return if exc is not None: log.error("deferred route registration for %r raised: %s", _pid, exc) fut.add_done_callback(_log_deferred) raise # propagate to load_plugins() so it emits plugin-error and skips "Loaded routes" _set_startup_status( running=True, phase="plugins-loading", message="Loading plugins...", current_plugin="", loaded=0, total=0, error=None, ) load_plugins(app, plugin_context, progress_cb=_on_progress, route_setup_fn=_route_setup_on_main) # Self-heal a freshly recreated container: its filesystem reset to # the image-baked sheet (in-tree plugins only), but a mounted # FEEDBACK_PLUGINS_DIR may carry user-installed plugins whose # classes aren't in it. Run in its OWN daemon thread so the startup # status can flip to "complete" immediately rather than waiting on # the (up to 120s) Tailwind subprocess. No-op when there are no user # plugins or no Tailwind engine (e.g. desktop/native). def _startup_tailwind_rebuild(): try: import tailwind_rebuild if tailwind_rebuild.user_plugin_count() > 0: tailwind_rebuild.rebuild("startup-scan") except Exception: log.warning("startup tailwind rebuild failed", exc_info=True) # Skip entirely in sync-startup mode (used by tests): no background # thread AND no slow inline subprocess. The startup self-heal only # matters for a real async startup of a recreated container. if not _sync_mode: threading.Thread(target=_startup_tailwind_rebuild, daemon=True).start() status = _get_startup_status() _set_startup_status( running=False, phase="complete", message="Startup complete", current_plugin="", loaded=status.get("loaded", 0), total=max(status.get("total", 0), status.get("loaded", 0)), error=status.get("error"), ) except Exception as e: _set_startup_status( running=False, phase="error", message="Plugin startup failed", error=str(e), ) log.exception("plugin startup failed") if _sync_mode: # Caller requested synchronous startup (e.g. test environment). # Run the loader inline so startup is complete before the server's # startup handler returns — no polling or timing workarounds needed. _load_plugins_background() else: threading.Thread(target=_load_plugins_background, daemon=True).start() global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" and not _DEMO_JANITOR_STARTED: _DEMO_JANITOR_STARTED = True _DEMO_JANITOR_STOP.clear() def _janitor(): while not _DEMO_JANITOR_STOP.wait(timeout=3600): with _DEMO_JANITOR_HOOKS_LOCK: hooks = list(_DEMO_JANITOR_HOOKS) for hook in hooks: _run_janitor_hook(hook) _DEMO_JANITOR_THREAD = threading.Thread(target=_janitor, daemon=True, name="demo-janitor") _DEMO_JANITOR_THREAD.start() # Start background metadata scan startup_scan() @app.on_event("shutdown") def shutdown_events(): """Stop the demo-mode janitor thread (if running) on server shutdown.""" global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD, _event_loop _event_loop = None # prevent stale loop reference after shutdown if _DEMO_JANITOR_STARTED: _DEMO_JANITOR_STOP.set() thread = _DEMO_JANITOR_THREAD if thread is not None: thread.join(timeout=5) if thread.is_alive(): import warnings warnings.warn( "demo-janitor thread did not stop within 5 s; " "a registered hook may be blocking", RuntimeWarning, stacklevel=1, ) # Leave _DEMO_JANITOR_STARTED True so a new janitor is not # spawned by a subsequent startup while the old one is alive. return _DEMO_JANITOR_THREAD = None _DEMO_JANITOR_STARTED = False with _DEMO_JANITOR_HOOKS_LOCK: _DEMO_JANITOR_HOOKS.clear() def startup_scan(): """Start background metadata scan and periodic rescan on server start.""" _kick_scan() # Periodic rescan every 5 minutes rescan_thread = threading.Thread(target=_periodic_rescan, daemon=True) rescan_thread.start() def _periodic_rescan(): """Check for new files every 5 minutes.""" time.sleep(300) # Wait 5 minutes after startup while True: # _kick_scan() is a no-op (returns False, queues a pending pass) when # a scan is already running, so racing against the active scan is # safe — no second runner is spawned. _kick_scan() time.sleep(300) def _safe_http_url(raw): """Return `raw` stripped + trailing-slash-stripped if it parses as an http(s) URL with a non-empty host; else None. Used to validate operator-supplied `APP_SOURCE_URL` / `APP_LICENSE_URL` env vars before they reach `` in the UI. A bare prefix check like `startswith(("http://","https://"))` accepts malformed inputs such as `"https://"` (no host) or `"https:///foo"` (empty host) that still produce broken hrefs — and, when used as a base for the default `license_url`, garbage like `"https:///blob/main/LICENSE"`. """ from urllib.parse import urlsplit if not raw: return None s = raw.strip().rstrip("/") if not s: return None try: parsed = urlsplit(s) except ValueError: return None if parsed.scheme.lower() not in ("http", "https"): return None # `netloc` includes any `user:pass@` and `:port` — strings like # "http://:80/path" have non-empty netloc (":80") but no real # hostname. Validate `hostname` so only URLs with an actual host # are accepted. if not parsed.hostname: return None return s @app.get("/api/version") def get_version(): env_version = os.environ.get("APP_VERSION", "").strip() if env_version: version = env_version else: version_file = Path(__file__).parent / "VERSION" version = "unknown" if version_file.exists(): try: version = version_file.read_text().strip() except (OSError, UnicodeDecodeError): pass default_source_url = "https://github.com/got-feedback/feedBack" # APP_SOURCE_URL / APP_LICENSE_URL flow straight into in the UI, # so validate with urllib.parse rather than a bare prefix check — a prefix # check accepts malformed values like "https://" (no host) which produce # broken hrefs (and a constructed license_url like "https:///blob/main/LICENSE"). # _safe_http_url requires scheme in {http,https} AND a non-empty hostname # (not just netloc — that would still accept port-only authorities like # "http://:80/path"); fall back to the safe default otherwise. source_url = _safe_http_url(os.environ.get("APP_SOURCE_URL")) or default_source_url # APP_LICENSE_URL: explicit override for the LICENSE link. The default # constructed value (source_url + "/blob/main/LICENSE") is GitHub- # specific and assumes the repo's default branch is `main`; non-GitHub # hosts (GitLab, Gitea, self-hosted) need an explicit value. license_url = _safe_http_url(os.environ.get("APP_LICENSE_URL")) or (source_url + "/blob/main/LICENSE") return { "version": version, "source_url": source_url, "license_url": license_url, } @app.get("/api/scan-status") def scan_status(): return _scan_status @app.get("/api/enrichment/status") def enrichment_status(): """Enrichment pipeline state: worker flags + row counts by match_state. Ambient tool-state for the match-review UI (never a home-screen score — design §11); also what tests poke.""" return { "running": _enrich_status["running"], "processed": _enrich_status["processed"], "last_pass_at": _enrich_status["last_pass_at"], "states": meta_db.enrichment_state_counts(), "total_songs": meta_db.count(), } @app.get("/api/enrichment/song/{filename:path}") def api_enrichment_song(filename: str): """Read-only per-song match provenance for the Details drawer (launch polish): which canonical identity this chart matched and how. A tiny projection of the cache row — no candidates, no cache paths.""" row = meta_db.get_enrichment(filename) if not row: raise HTTPException(status_code=404, detail="no enrichment row") return {k: row.get(k) for k in ("match_state", "canon_artist", "canon_title", "match_source", "match_score")} @app.post("/api/enrichment/kick") def api_enrichment_kick(): """The Settings "Match now" button: request an enrichment pass without waiting for a scan to complete. Single-flight + coalescing like every other kick — spamming it queues at most one follow-up pass.""" return {"started": _kick_enrich()} @app.post("/api/enrichment/refresh/{filename:path}") def api_enrichment_refresh(filename: str): """The context menu's "Refresh metadata": reset THIS song's match to unscanned (canonical values + candidates cleared, backoff zeroed) and kick a pass so it re-matches immediately. An EXPLICIT user action, so it may discard a manual pin — the automation never does, but the user asking for a re-match is the one party who owns that pin.""" song = meta_db.enrichment_song_row(filename) if not song: raise HTTPException(status_code=404, detail="unknown song") h = meta_db.enrichment_content_hash( song["artist"], song["title"], song["album"], song["duration"]) meta_db.apply_enrichment_match(filename, h, "unscanned", allow_manual_overwrite=True) return {"ok": True, "started": _kick_enrich()} @app.get("/api/enrichment/review") def api_enrichment_review(limit: int = 200): """The Match-Review queue: songs whose text match landed in the medium- confidence review tier, each with its stored candidate list — the drawer renders straight from this, no MusicBrainz round-trip. Ordered by the user's enrich_review_order setting.""" limit = max(1, min(int(limit), 500)) cfg = _load_config(CONFIG_DIR / "config.json") or {} order = cfg.get("enrich_review_order", "missing_first") return { "songs": meta_db.enrichment_review_queue(limit=limit, order=order), "total_review": meta_db.enrichment_state_counts().get("review", 0), } @app.post("/api/enrichment/review/{filename:path}/accept") def api_enrichment_accept(filename: str, data: dict = Body(...)): """Accept one of the stored review candidates: the row becomes a user-pinned `manual` match (never auto-reset). Display-only, like every enrichment write — nothing touches the pack file.""" recording_id = str((data or {}).get("recording_id") or "") row = meta_db.get_enrichment(filename) if not row or row["match_state"] != "review": raise HTTPException(status_code=404, detail="no review row for this song") cand = next((c for c in (row.get("candidates") or []) if c.get("recording_id") == recording_id), None) if not cand: raise HTTPException(status_code=404, detail="candidate not in the stored list") if not meta_db.set_enrichment_manual(filename, cand, source="review"): raise HTTPException(status_code=404, detail="unknown song") return {"ok": True, "enrichment": meta_db.get_enrichment(filename)} @app.post("/api/enrichment/review/{filename:path}/reject") def api_enrichment_reject(filename: str): """"None of these" — clears any canonical values and parks the row as failed/rejected (never auto-retried; editing the song's metadata re-queues it). Valid from `review` or `matched`, never from `manual`.""" if not meta_db.set_enrichment_rejected(filename): raise HTTPException(status_code=404, detail="no rejectable match for this song") return {"ok": True, "enrichment": meta_db.get_enrichment(filename)} # The candidate fields a manual pick is allowed to carry — the payload comes # from our own /api/enrichment/search proxy, but the route re-sanitizes so a # hand-rolled client can't stuff arbitrary keys/types into the cache row. _CAND_STR_FIELDS = ("recording_id", "title", "artist", "artist_id", "artist_sort", "release_id", "album", "year", "isrc") def _sanitize_candidate(raw: dict) -> dict | None: if not isinstance(raw, dict): return None out = {k: str(raw.get(k) or "") for k in _CAND_STR_FIELDS} if not out["recording_id"] or not out["title"]: return None genres = raw.get("genres") or [] out["genres"] = [str(g) for g in genres if isinstance(g, str)][:5] \ if isinstance(genres, list) else [] return out @app.post("/api/enrichment/review/{filename:path}/pick") def api_enrichment_pick(filename: str, data: dict = Body(...)): """Fix-match / manual search-and-pick: pin a candidate the user found via /api/enrichment/search (not limited to the stored review list — this is the escape hatch for a wrong auto-match too). Sets `manual`, the highest-authority state.""" cand = _sanitize_candidate((data or {}).get("candidate")) if not cand: raise HTTPException(status_code=400, detail="candidate needs recording_id + title") if not meta_db.set_enrichment_manual(filename, cand, source="search"): raise HTTPException(status_code=404, detail="unknown song") return {"ok": True, "enrichment": meta_db.get_enrichment(filename)} @app.get("/api/enrichment/search") def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8, filename: str = ""): """Manual-search proxy to MusicBrainz (throttled + identified like the background matcher — a user typing in the drawer must not sidestep the rate limit). `filename` optionally scores results against that song's stored identity (year/duration corroboration) instead of just the typed text. Sync route on purpose: FastAPI runs it in the threadpool, so the throttle's sleep never blocks the event loop.""" if not (artist.strip() or title.strip()): raise HTTPException(status_code=400, detail="artist or title required") limit = max(1, min(int(limit), 25)) try: cands = _mb_search_recordings(artist, title, limit=limit) except EnrichTransportError as e: return JSONResponse({"error": "musicbrainz unavailable", "detail": str(e)}, status_code=503) ref = None if filename: ref = meta_db.enrichment_song_row(filename) if ref is None: ref = {"artist": artist, "title": title} return {"candidates": mb_match.rank_candidates(ref, cands)} @app.get("/api/startup-status") def startup_status(): return _get_startup_status() @app.get("/api/startup-status/stream") async def startup_status_stream(request: Request): queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=1) # Register before putting the initial snapshot. asyncio cooperative # scheduling guarantees _put_latest cannot run between add() and the # put() below: put() on an empty maxsize-1 queue never yields (CPython # fast path), so no event-loop iteration fires in between. Registering # first ensures a terminal status fired just after connect is never missed. with _startup_sse_lock: _startup_sse_subscribers.add(queue) await queue.put(_get_startup_status()) async def _gen(): since_ka = 0.0 try: while True: try: data = await asyncio.wait_for(queue.get(), timeout=_SSE_POLL_INTERVAL) except asyncio.TimeoutError: if await request.is_disconnected(): break since_ka += _SSE_POLL_INTERVAL if since_ka >= _SSE_KA_INTERVAL: yield 'data: {"type":"keepalive"}\n\n' since_ka = 0.0 continue yield f"data: {json.dumps(data)}\n\n" if not data.get("running", True): break since_ka = 0.0 # reset keepalive timer — a real event just went out # Check after each delivered message so that rapid-fire updates # don't prevent disconnect detection (the timeout path above only # fires when the queue is idle for the full _SSE_POLL_INTERVAL). if await request.is_disconnected(): break finally: with _startup_sse_lock: _startup_sse_subscribers.discard(queue) return StreamingResponse( _gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @app.post("/api/rescan") def trigger_rescan(): """Manually trigger a library rescan.""" if not _kick_scan(): return {"message": "Scan already in progress"} return {"message": "Rescan started"} @app.post("/api/rescan/full") def trigger_full_rescan(): """Clear cache and rescan everything.""" if _scan_status["running"]: return {"message": "Scan already in progress"} with meta_db._lock: # Force every file to re-scan by invalidating the mtime cache (get() # keys on mtime equality) WITHOUT emptying `songs` — keeping the rows # means the table is never transiently empty mid-scan, so the # existing-song stats/playlist read-filter stays correct throughout. # delete_missing() prunes anything genuinely gone at the end. meta_db.conn.execute("UPDATE songs SET mtime = -1") meta_db.conn.commit() if not _kick_scan(): return {"message": "Scan already in progress"} return {"message": "Full rescan started"} # ── Song upload ─────────────────────────────────────────────────────────────── _ALLOWED_SONG_EXTS = set(sloppak_mod.SONG_EXTS) _MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB — covers sloppaks bundled with stems # Per-request batch cap. Lets a user drop a whole album of sloppaks at once # without giving a hostile client a 1000-file DoS surface via Starlette's # default max_files=1000. The pre-parse Content-Length guard is sized as # _MAX_UPLOAD_FILES * _MAX_UPLOAD_BYTES + slack. _MAX_UPLOAD_FILES = 50 # Multipart Content-Length includes boundary markers + per-part headers, so a # file sitting right at _MAX_UPLOAD_BYTES would be rejected by an equality cap # on Content-Length. Add a generous slack for the multipart envelope; the real # file-size cap is enforced by the streaming check in _save_uploaded_song(). _MULTIPART_OVERHEAD_SLACK = 1024 * 1024 # 1 MiB # Serializes the mutating step of upload (os.replace into DLC_DIR) with # delete_song so the two endpoints can't interleave on the same path — # e.g. an upload finishing right after a concurrent delete shouldn't # resurrect a song the user just removed, and a delete arriving mid- # overwrite shouldn't strand a half-written file. threading.Lock (not # asyncio.Lock) because delete_song is sync (runs in the threadpool); # upload acquires it inside ``run_in_threadpool`` for the same reason. _song_io_lock = threading.Lock() def _commit_uploaded_song(tmp_path: Path, dest: Path, overwrite: bool, base: str): """Atomically move a validated temp upload into ``dest`` under ``_song_io_lock``. Returns ``None`` on success or an error result dict matching the upload endpoint's contract. Holds the lock across the directory re-check and the final ``os.replace`` so a concurrent delete or upload can't slip between them. Always cleans up the temp file on the error paths. """ with _song_io_lock: if dest.exists(): if not overwrite: # Lost the race against a concurrent upload of the same name. try: tmp_path.unlink() except OSError: pass return {"status": "exists", "filename": base, "error": "A file with this name already exists"} # Re-check directory state under the lock — the pre-check # may have raced an unrelated mkdir, and a sloppak directory # has to be removed before os.replace() can write over it. if dest.is_dir(): if not sloppak_mod.is_sloppak(dest): try: tmp_path.unlink() except OSError: pass return {"status": "exists", "filename": base, "error": "A directory with this name exists and is not " "a sloppak — refusing to overwrite"} shutil.rmtree(str(dest)) os.replace(str(tmp_path), str(dest)) return None def _invalidate_song_caches(cache_key: str) -> None: """Drop filename-keyed derived caches when a song at ``cache_key`` is replaced or removed. Sloppak's ``_source_cache`` and loose-folder audio IDs self-invalidate via stat checks; the caches purged here do not.""" # In-memory archive extraction cache (filename → tmp dir + Song). with _extract_cache_lock: stale = _extract_cache.pop(cache_key, None) if stale: shutil.rmtree(stale[0], ignore_errors=True) # Art cache — match the safe_name mapping used by get_song_art / # upload_song_art_b64 exactly so we hit the same on-disk file. safe_name = cache_key.replace("/", "_").replace(" ", "_") art_file = ART_CACHE_DIR / f"{safe_name}.png" try: art_file.unlink() except FileNotFoundError: pass except OSError: log.debug("failed to evict art cache for %s", cache_key, exc_info=True) # archive audio cache — audio_id is `Path(filename).stem.replace(" ", "_")` # without any stat digest, so a same-named replacement would serve the # previous file's converted audio. Loose-folder ids include a wem stat # digest and self-heal; sloppak streams stems directly and uses no # audio_id at all — both safely no-op here. audio_id = Path(cache_key).stem.replace(" ", "_") for d in (AUDIO_CACHE_DIR, STATIC_DIR): for ext in (".mp3", ".ogg", ".wav"): f = d / f"audio_{audio_id}{ext}" try: f.unlink() except FileNotFoundError: pass except OSError: log.debug("failed to evict audio cache file %s", f, exc_info=True) @app.post("/api/songs/upload") async def upload_song(request: Request): """Upload one or more .sloppak files into the configured DLC folder. Multipart body with one or more ``file`` fields (up to ``_MAX_UPLOAD_FILES`` per request). Query string: ``overwrite=1`` — replace existing files with the same name. Response shape (always HTTP 200 once we've gotten past request-level guards like DLC-not-configured / payload-too-large): ``{"results": [{"filename": "...", "status": "ok" | "exists" | "error", "error"?: "...", "size"?: N, "format"?: "sloppak"}, ...]}`` Per-file conflicts surface as ``status: "exists"`` so a batch upload can surface ALL conflicts at once instead of bailing on the first one. The client re-POSTs just the conflicting files with ``overwrite=1`` if the user opts in. The DLC directory is resolved via ``_get_dlc_dir()`` which honours the ``DLC_DIR`` env var first and falls back to ``dlc_dir`` in ``config.json`` — so uploads land in whichever folder the rest of the app already considers the library root, regardless of which mechanism configured it. """ dlc = _get_dlc_dir() if dlc is None: return JSONResponse( {"error": "DLC folder is not configured. Set DLC_DIR or configure it in Settings."}, status_code=503, ) if not os.access(str(dlc), os.W_OK): return JSONResponse( {"error": f"DLC folder {dlc} is not writable by the server process."}, status_code=500, ) # Pre-parse Content-Length guard — fail fast before reading any body. # Multipart Content-Length is file bytes + boundary + per-part headers, so # we can't use _MAX_UPLOAD_BYTES as an exact cap here (a file right at the # advertised max would be rejected before _save_uploaded_song() can apply # the real per-file byte cap). For batch uploads we allow up to # _MAX_UPLOAD_FILES files at _MAX_UPLOAD_BYTES each; the parser still # enforces per-part size via max_part_size and per-batch count via # max_files. The streaming check inside _save_uploaded_song() is the # authoritative per-file size cap. max_total = _MAX_UPLOAD_FILES * _MAX_UPLOAD_BYTES + _MULTIPART_OVERHEAD_SLACK cl = request.headers.get("content-length") if cl is not None: try: cl_int = int(cl) except ValueError: return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400) if cl_int < 0: return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400) if cl_int > max_total: return JSONResponse( {"error": f"Batch upload exceeds {_MAX_UPLOAD_FILES} files × " f"{_MAX_UPLOAD_BYTES // (1024 * 1024)} MB limit"}, status_code=413, ) overwrite = request.query_params.get("overwrite") == "1" # Tighten the parser to the handler's contract: up to _MAX_UPLOAD_FILES # file parts, no text parts (overwrite comes from query params). # Starlette's defaults of max_files=1000 / max_fields=1000 would # otherwise let a client force the parser to spool far more parts than # the endpoint is willing to process. form = await request.form( max_files=_MAX_UPLOAD_FILES, max_fields=0, max_part_size=_MAX_UPLOAD_BYTES, ) try: from starlette.datastructures import UploadFile as _StarletteUploadFile # form.getlist("file") returns all parts named "file" in submission # order. Filter to file parts only — Starlette would yield strings # for text parts, but we've capped max_fields=0 so any non-file part # is already a parser error before reaching here. uploads = [u for u in form.getlist("file") if isinstance(u, _StarletteUploadFile)] if not uploads: return JSONResponse( {"error": "Expected one or more files in multipart field 'file'"}, status_code=400, ) results = [] any_saved = False for upload in uploads: try: result = await _save_uploaded_song(upload, dlc, overwrite) results.append(result) if result.get("status") == "ok": any_saved = True except Exception as e: # Per-file failure must not abort the batch — record and # continue so the client gets a complete report. log.exception("upload failed for %r", getattr(upload, "filename", "?")) results.append({ "filename": Path(getattr(upload, "filename", "") or "").name or "?", "status": "error", "error": f"Upload failed: {e}", }) finally: try: await upload.close() except Exception: log.debug("failed to close upload file handle", exc_info=True) if any_saved: _kick_scan() return {"results": results} finally: try: await form.close() except Exception: log.debug("failed to close form", exc_info=True) async def _save_uploaded_song(upload: UploadFile, dlc: Path, overwrite: bool) -> dict: """Save one upload into ``dlc``. Returns a per-file result dict (never a JSONResponse) so batch uploads can aggregate. Shape: ok: ``{"status": "ok", "filename": base, "size": N, "format": "sloppak"}`` exists: ``{"status": "exists", "filename": base, "error": "..."}`` error: ``{"status": "error", "filename": base, "error": "..."}`` """ # Strip any path components a client may have included in the filename — # only the basename lands in the DLC root. Path traversal would otherwise # let a crafted upload escape the library directory. raw_name = upload.filename or "" base = Path(raw_name).name if not base or base in (".", "..") or "/" in base or "\\" in base: return {"status": "error", "filename": raw_name or "?", "error": "Invalid filename"} suffix = Path(base).suffix.lower() if suffix not in _ALLOWED_SONG_EXTS: return {"status": "error", "filename": base, "error": "Only .feedpak files are accepted"} dest = dlc / base if dest.exists(): if not overwrite: return {"status": "exists", "filename": base, "error": "A file with this name already exists"} # overwrite=1 must handle directory-form sloppaks (the scanner and # delete path both treat them as song entries). os.replace() can't # clobber a non-empty directory, so without the rmtree below the # whole upload would write to a temp file and then surface a late # 500 at the os.replace() call. Refuse other directories so an # unrelated folder isn't blown away by a same-named upload. if dest.is_dir() and not sloppak_mod.is_sloppak(dest): return {"status": "exists", "filename": base, "error": "A directory with this name exists and is not a sloppak — " "refusing to overwrite"} # Temp file in the DLC dir itself so os.replace is atomic (same filesystem). # Dot-prefix keeps it out of the rglob("*.sloppak") scan glob. fd, tmp_name = await run_in_threadpool( tempfile.mkstemp, dir=str(dlc), prefix=".upload-", suffix=".part" ) tmp_path = Path(tmp_name) bytes_read = 0 head = b"" error_result: dict | None = None try: try: tmpf = await run_in_threadpool(os.fdopen, fd, "wb") except BaseException: try: await run_in_threadpool(os.close, fd) except OSError: pass raise try: while True: chunk = await upload.read(1024 * 1024) if not chunk: break bytes_read += len(chunk) if bytes_read > _MAX_UPLOAD_BYTES: error_result = { "status": "error", "filename": base, "error": f"Upload exceeds {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB cap", } break if len(head) < 4: head += chunk[: 4 - len(head)] await run_in_threadpool(tmpf.write, chunk) finally: await run_in_threadpool(tmpf.close) if error_result is None: if bytes_read == 0: error_result = {"status": "error", "filename": base, "error": "Empty upload — file is 0 bytes"} elif suffix in _ALLOWED_SONG_EXTS: if head[:2] != b"PK": error_result = {"status": "error", "filename": base, "error": "Not a valid feedpak file (expected zip archive)"} else: # ZIP magic alone admits any renamed zip — verify the sloppak # loader can actually parse a manifest.yaml inside. Without # this, /api/songs/upload returns "ok" for files the rest of # the backend would refuse to scan or load. try: await run_in_threadpool(sloppak_mod.load_manifest, tmp_path) except Exception as e: error_result = {"status": "error", "filename": base, "error": f"Not a valid sloppak file: {e}"} if error_result is not None: try: await run_in_threadpool(tmp_path.unlink) except OSError: pass return error_result # Single sync helper so the lock is held for the whole commit — # ``async with _upload_lock`` would have released between every # ``run_in_threadpool`` and let a concurrent delete or upload slip # in between the dir check and the final ``os.replace``. commit_result = await run_in_threadpool( _commit_uploaded_song, tmp_path, dest, overwrite, base ) if commit_result is not None: return commit_result except BaseException: try: await run_in_threadpool(tmp_path.unlink) except OSError: pass raise # Even on a fresh (non-overwrite) upload, evict any stale entries left # over from a previous delete+re-upload of the same name. await run_in_threadpool(_invalidate_song_caches, base) log.info("Uploaded %s (%d bytes) to %s", base, bytes_read, dlc) return {"status": "ok", "filename": base, "size": bytes_read, "format": suffix.lstrip(".")} @app.delete("/api/song/{filename:path}") def delete_song(filename: str): """Remove a song from the DLC folder and clear its cache entries. Works for both formats: ``.sloppak`` files OR directories, and loose-folder songs (the directory containing the chart). The path is resolved through ``_resolve_dlc_path`` so URL-encoded ``..`` segments cannot escape the library root. """ dlc = _get_dlc_dir() if dlc is None: return JSONResponse({"error": "DLC folder not configured"}, status_code=503) resolved = _resolve_dlc_path(dlc, filename) if resolved is None: return JSONResponse({"error": "forbidden"}, status_code=403) if not resolved.exists(): return JSONResponse({"error": "File not found"}, status_code=404) if resolved == dlc.resolve(): return JSONResponse({"error": "Refusing to delete the DLC root"}, status_code=400) # Only delete actual song entries. Without this, DELETE /api/song/ArtistName # would recursively wipe a whole artist subfolder — far broader than the # UI's per-song contract. Sloppak detection wins over loose because a # sloppak dir can also contain WEM/XML (matches the scanner's precedence). is_sloppak = sloppak_mod.is_sloppak(resolved) is_loose = ( resolved.is_dir() and not is_sloppak and loosefolder_mod.is_loose_song(resolved) ) if not (is_sloppak or is_loose): return JSONResponse( {"error": "Not a song entry — only sloppaks " "or loose-folder songs can be deleted"}, status_code=400, ) # Hold ``_song_io_lock`` across the filesystem removal AND the DB/cache # eviction. Without it, an upload of the same filename could ``os.replace`` # a new file into place between our removal and DB delete, leaving the # new generation stranded with no library row; or the reverse, where # delete runs between an upload's directory check and its replace and # the upload then resurrects the song we just removed. with _song_io_lock: try: if resolved.is_dir(): shutil.rmtree(resolved) else: resolved.unlink() except OSError as e: log.error("Failed to delete %s: %s", resolved, e) return JSONResponse({"error": f"Delete failed: {e}"}, status_code=500) # Canonicalise the cache key the same way update_song_meta does so we # hit the row the scanner indexed under. try: cache_key = resolved.relative_to(dlc.resolve()).as_posix() except ValueError: cache_key = filename with meta_db._lock: meta_db.conn.execute("DELETE FROM songs WHERE filename = ?", (cache_key,)) meta_db.conn.execute("DELETE FROM favorites WHERE filename = ?", (cache_key,)) meta_db.conn.execute("DELETE FROM loops WHERE filename = ?", (cache_key,)) # Purge the v3 filename-keyed state too, so the deleted song stops # surfacing in stats / recent / continue / playlists immediately. meta_db.conn.execute("DELETE FROM song_stats WHERE filename = ?", (cache_key,)) meta_db.conn.execute("DELETE FROM playlist_songs WHERE filename = ?", (cache_key,)) # Personal difficulty / notes / tags for this song (we hold the # lock, so purge is lock-free). meta_db.purge_song_user_data(cache_key) # Multi-chart grouping (P5a): drop this chart's split + read-model rows, # and any preferred-chart pointer that named it (the work re-auto-picks). # work_key-keyed prefs for OTHER charts survive. Mark the read-model # dirty so the affected work regroups on the next grouped query. meta_db.conn.execute("DELETE FROM chart_group_split WHERE filename = ?", (cache_key,)) meta_db.conn.execute("DELETE FROM work_display WHERE filename = ?", (cache_key,)) meta_db.conn.execute("DELETE FROM chart_group_pref WHERE preferred_filename = ?", (cache_key,)) meta_db._work_display_dirty = True # Enrichment is never purged on rescan (delete_missing), only here # on the explicit per-song delete — the never-clobber contract. meta_db.conn.execute("DELETE FROM song_enrichment WHERE filename = ?", (cache_key,)) meta_db.conn.commit() # User art overrides go with the song (CAA cache files are keyed by # RELEASE and may be shared with other charts — the LRU owns those). for _p in _art_override_paths(cache_key): try: _p.unlink() except OSError: pass _invalidate_song_caches(cache_key) log.info("Deleted song %s", cache_key) # If a scan was mid-flight when we removed the row, it may already have # listed (and not yet processed) the file and will call ``meta_db.put()`` # for it after our DB delete — reinserting a ghost row. Coalesce a # follow-up pass via ``_kick_scan`` so the next scan's ``delete_missing()`` # purges that entry. Cheap no-op when no scan is running. if _scan_status["running"]: _kick_scan() return {"ok": True, "filename": cache_key} # ── Library API ─────────────────────────────────────────────────────────────── def _split_csv(raw: str) -> list[str]: """Parse a comma-separated query-string list. Empty / whitespace-only entries are dropped so `arrangements_has=` (no value) and `arrangements_has=,` both mean 'no filter'.""" if not raw: return [] return [s.strip() for s in raw.split(",") if s.strip()] def _normalize_tag(tag) -> str: """Canonical form for a personal practice tag: trimmed, lowercased, internal whitespace collapsed, length-capped. Lowercasing is what keeps "Rock"/"rock" from splitting into two tags. Non-strings → ''.""" if not isinstance(tag, str): return "" return " ".join(tag.strip().lower().split())[:60] def _parse_has_lyrics(raw: str) -> int | None: """Tri-state parse for has_lyrics. `1` → require, `0` → exclude, anything else (including empty) → no filter.""" if raw == "1": return 1 if raw == "0": return 0 return None def _library_filter_args(q: str = "", favorites: int = 0, format: str = "", artist: str = "", album: str = "", arrangements_has: str = "", arrangements_lacks: str = "", stems_has: str = "", stems_lacks: str = "", has_lyrics: str = "", tunings: str = "") -> dict: fmt = format if format in ("archive", "sloppak", "loose") else "" return { "q": q, "favorites_only": bool(favorites), "format_filter": fmt, "artist_filter": (artist or "").strip(), "album_filter": (album or "").strip(), "arrangements_has": _split_csv(arrangements_has), "arrangements_lacks": _split_csv(arrangements_lacks), "stems_has": _split_csv(stems_has), "stems_lacks": _split_csv(stems_lacks), "has_lyrics": _parse_has_lyrics(has_lyrics), "tunings": _split_csv(tunings), } @app.get("/api/library/providers") def list_library_providers(): """List registered library providers.""" return {"providers": library_providers.list()} @app.get("/api/library/providers/{provider_id}/songs/{song_id:path}/art") async def get_library_provider_song_art(provider_id: str, song_id: str): """Return album art for a song owned by a library provider.""" library_provider = _get_library_provider(provider_id) _require_library_provider_capability(library_provider, "art.read") result = await _call_library_provider_async(library_provider, "get_art", song_id=song_id) return _library_art_response(result) @app.post("/api/library/providers/{provider_id}/songs/{song_id:path}/sync") async def sync_library_provider_song(provider_id: str, song_id: str): """Ask a provider to sync a remote song into the local library/cache.""" library_provider = _get_library_provider(provider_id) _require_library_provider_capability(library_provider, "song.sync") result = await _call_library_provider_async(library_provider, "sync_song", song_id=song_id) if result is None: return {"ok": True} if isinstance(result, dict): return result return {"ok": True, "result": result} @app.get("/api/library") async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "artist", dir: str = "asc", favorites: int = 0, format: str = "", artist: str = "", album: str = "", arrangements_has: str = "", arrangements_lacks: str = "", stems_has: str = "", stems_lacks: str = "", has_lyrics: str = "", tunings: str = "", provider: str = "local", mastery: str = "", tags: str = "", user_difficulty: str = "", match: str = "", genre: str = "", after: str = "", group: int = 0, naming_mode: str = "legacy"): """Paginated library search through the selected library provider. `after` is an opaque keyset cursor (feedBack#636 item 3): pass back the `next_cursor` from the previous response to fetch the next page with a WHERE-seek instead of OFFSET. Providers that don't support it ignore it and page by OFFSET, so the client can always fall back.""" size = min(size, 100) library_provider = _get_library_provider(provider) _require_library_provider_capability(library_provider, "library.read") # Only the true local provider keysets: it's the one whose effective sort is # exactly the request `sort`. A smart collection may pin its own sort and # remote providers don't keyset — both must page by OFFSET, so never hand # them a cursor (a mismatched one would mis-seek). is_local = getattr(library_provider, "id", "") == "local" songs, total = await _call_library_provider_async( library_provider, "query_page", page=page, size=size, sort=sort, direction=dir, after=((after or None) if is_local else None), group=bool(group), naming_mode=naming_mode, mastery=_split_csv(mastery), tags_has=_split_csv(tags), user_difficulty_in=_split_csv(user_difficulty), match_states=_split_csv(match), genre=_split_csv(genre), **_library_filter_args( q=q, favorites=favorites, format=format, artist=artist, album=album, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, has_lyrics=has_lyrics, tunings=tunings, ), ) # The cursor to resume after this page (effective sort folds in dir=desc). next_cursor = (next_library_cursor(_effective_keyset_sort(sort, dir), songs[-1]) if (is_local and songs) else None) return {"songs": songs, "total": total, "page": page, "size": size, "next_cursor": next_cursor} # ── Multi-chart work grouping API (P5b) ────────────────────────────────────── # Read + manage the charts of a work (the P5d Charts drawer consumes this). The # grouping engine lives in MetadataDB (P5a); these are its HTTP surface. Local # library only. NOTE: a scoped "work changed" repaint broadcast for OTHER open # views is deferred to P5d — there's no server-side library event bus today, and # the drawer updates itself from these responses. @app.get("/api/work/{work_key:path}/charts") def api_get_work_charts(work_key: str): """All charts in a work + which is the keeper (your pick vs auto-pick).""" return meta_db.work_charts(work_key) @app.put("/api/work/{work_key:path}/preferred") def api_set_work_preferred(work_key: str, data: dict): """Set the keeper chart of a work: body {filename}. The filename must be a current member of the work. Returns the refreshed chart list.""" fn = (data.get("filename") or "").strip() if not fn: return JSONResponse({"error": "filename is required"}, 400) members = {c["filename"] for c in meta_db.work_charts(work_key)["charts"]} if fn not in members: return JSONResponse({"error": "filename is not a chart of this work"}, 400) meta_db.set_chart_preferred(work_key, fn) return meta_db.work_charts(work_key) @app.delete("/api/work/{work_key:path}/preferred") def api_reset_work_preferred(work_key: str): """Reset a work to auto-pick (drop the explicit preferred).""" meta_db.clear_chart_preferred(work_key) return meta_db.work_charts(work_key) @app.post("/api/chart/{filename:path}/split") def api_split_chart(filename: str): """'These aren't the same song' — split this chart out as its own singleton work. Under /api/chart (NOT /api/song) so the DELETE /api/song/{path} catch-all can't shadow it.""" key = meta_db._canonical_song_filename(filename) meta_db.split_chart(key) return {"ok": True, "filename": key} @app.post("/api/chart/{filename:path}/unsplit") def api_unsplit_chart(filename: str): """Undo a split — rejoin the chart to its work.""" key = meta_db._canonical_song_filename(filename) meta_db.unsplit_chart(key) return {"ok": True, "filename": key} @app.get("/api/chart/{filename:path}/work") def api_get_chart_work(filename: str): """Resolve a chart's work membership: {work_key, chart_count}. For openers on rows that came from an ungrouped query (the tree view) — grouped grid rows already carry both fields inline.""" return meta_db.chart_work(filename) @app.get("/api/chart/{filename:path}/fileinfo") def api_chart_fileinfo(filename: str): """The context menu's "Get info": where the file lives + what the pack contains. Under /api/chart — the GET /api/song/{path} catch-all would swallow a /api/song/…/fileinfo suffix. Read-only; demo-mode blocks it because it exposes filesystem paths.""" dlc = _get_dlc_dir() if not dlc: raise HTTPException(status_code=404, detail="not configured") p = _resolve_dlc_path(dlc, filename) if p is None: raise HTTPException(status_code=403, detail="forbidden") if not p.exists(): raise HTTPException(status_code=404, detail="not found") # Restrict to actual charts — sloppak or loose song. Without this the route # would stat ANY file the user happens to keep under DLC_DIR (e.g. notes), # leaking its path/size; the app only recognises these two song formats. is_pak = sloppak_mod.is_sloppak(p) is_loose = loosefolder_mod.is_loose_song(p) if not (is_pak or is_loose): raise HTTPException(status_code=404, detail="not a chart") st = p.stat() info = { "filename": filename, "path": str(p), "folder": str(p.parent), "format": "sloppak" if is_pak else "loose", # Directory-form songs report the tree's total (covers loose folders # and dir-form paks); zip-form paks report the archive size. Symlinked # entries are skipped so a link inside the folder can't pull in — or # leak the size of — a file outside it. "size": (st.st_size if p.is_file() else sum(f.stat().st_size for f in p.rglob("*") if f.is_file() and not f.is_symlink())), "mtime": st.st_mtime, } if is_pak: try: m = sloppak_mod.load_manifest(p) or {} except Exception: m = {} arrs = [str(a.get("name", a.get("id", ""))) for a in (m.get("arrangements") or []) if isinstance(a, dict)] stems = [str(s.get("id", "")) for s in (m.get("stems") or []) if isinstance(s, dict)] try: has_cover = sloppak_mod.read_cover_bytes(p, m) is not None except Exception: has_cover = False # The optional identity/catalog keys, listed only when present — the # Get-info panel's "what this pack carries vs what's missing" readout. identity = {k: m.get(k) for k in ("mbid", "isrc", "genres", "track", "disc", "album_artist", "feedpak_version", "language") if m.get(k) not in (None, "", [])} info["manifest"] = { "title": str(m.get("title", "")), "artist": str(m.get("artist", "")), "album": str(m.get("album", "")), "year": str(m.get("year", "") or ""), "arrangements": arrs, "stems": stems, "has_cover": has_cover, "has_lyrics": bool(m.get("lyrics")), "authors": [a.get("name", "") if isinstance(a, dict) else str(a) for a in (m.get("authors") or [])], "identity": identity, } # The enrichment verdict, so Get info can say "Matched (auto, 96%)" / # "Pinned by you" / "Not matched" alongside the file facts. row = meta_db.get_enrichment(filename) if row: info["match"] = {k: row.get(k) for k in ("match_state", "match_source", "match_score", "canon_artist", "canon_title", "canon_album", "canon_year")} return info @app.get("/api/library/albums") async def list_library_albums(q: str = "", page: int = 0, size: int = 120, favorites: int = 0, format: str = "", artist: str = "", album: str = "", arrangements_has: str = "", arrangements_lacks: str = "", stems_has: str = "", stems_lacks: str = "", has_lyrics: str = "", tunings: str = "", mastery: str = "", match: str = "", genre: str = "", provider: str = "local"): """Album-condensed browse: distinct (artist, album) groups with a track count and a representative cover song. Paged by album. Same filters as /api/library.""" size = min(size, 500) library_provider = _get_library_provider(provider) _require_library_provider_capability(library_provider, "library.read") albums, total = await _call_library_provider_async( library_provider, "query_albums", page=page, size=size, mastery=_split_csv(mastery), match_states=_split_csv(match), genre=_split_csv(genre), **_library_filter_args( q=q, favorites=favorites, format=format, artist=artist, album=album, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, has_lyrics=has_lyrics, tunings=tunings, ), ) return {"albums": albums, "total": total, "page": page, "size": size} @app.get("/api/library/artists") async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page: int = 0, size: int = 50, format: str = "", artist: str = "", album: str = "", arrangements_has: str = "", arrangements_lacks: str = "", stems_has: str = "", stems_lacks: str = "", has_lyrics: str = "", tunings: str = "", provider: str = "local", naming_mode: str = "legacy"): """Get artists grouped by letter with albums and songs (for tree view).""" size = min(size, 100) library_provider = _get_library_provider(provider) _require_library_provider_capability(library_provider, "library.read") artists, total = await _call_library_provider_async( library_provider, "query_artists", letter=letter, page=page, size=size, naming_mode=naming_mode, **_library_filter_args( q=q, favorites=favorites, format=format, artist=artist, album=album, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, has_lyrics=has_lyrics, tunings=tunings, ), ) return {"artists": artists, "total_artists": total, "page": page, "size": size} @app.get("/api/library/stats") async def library_stats(favorites: int = 0, q: str = "", format: str = "", artist: str = "", album: str = "", arrangements_has: str = "", arrangements_lacks: str = "", stems_has: str = "", stems_lacks: str = "", has_lyrics: str = "", tunings: str = "", provider: str = "local", match: str = "", sort: str = "artist", sort_letters: int = 0, group: int = 0, naming_mode: str = "legacy"): """Aggregate stats for the UI. Accepts the same filter params as /api/library so the letter bar mirrors the active grid filter set. `sort` selects the column the jump rail's `sort_letters` keys on; `sort_letters=1` opts into that breakdown (the rail), so non-rail callers skip the extra per-letter aggregate. `group=1` counts works not charts (mirrors the grouped grid).""" library_provider = _get_library_provider(provider) _require_library_provider_capability(library_provider, "library.read") return await _call_library_provider_async( library_provider, "query_stats", naming_mode=naming_mode, sort=sort, want_sort_letters=bool(sort_letters), group=bool(group), # The match facet rides the stats call too — the A–Z rail's letter # counts must agree with the grid under the facet or its cumulative # seek + sizer geometry break. match_states=_split_csv(match), **_library_filter_args( q=q, favorites=favorites, format=format, artist=artist, album=album, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, has_lyrics=has_lyrics, tunings=tunings, ), ) @app.get("/api/library/genres") def library_genres(provider: str = "local"): """Distinct non-empty genres for the filter facet. Genres are a local-library facet: they're populated from the feedpak `genres` field at scan time and live in the local meta DB. Local-backed providers (the local library and its smart collections, kind="local") share that DB, so they surface the same set. Remote providers don't expose genres here, so return an empty facet for them — the client then hides the filter rather than offering local genres that don't apply to the remote grid. Mirrors the local/remote gating used elsewhere for provider calls (see `_call_library_provider`).""" library_provider = _get_library_provider(provider) kind = str(library_providers.provider_field(library_provider, "kind", "") or "") is_remote = kind not in ("", "local") if kind else provider != "local" if is_remote: return {"genres": []} with meta_db._lock: rows = meta_db.conn.execute( "SELECT DISTINCT genre FROM songs WHERE genre IS NOT NULL AND genre != '' " "ORDER BY genre COLLATE NOCASE" ).fetchall() return {"genres": [r[0] for r in rows]} @app.get("/api/library/tuning-names") async def list_tuning_names(provider: str = "local"): """Distinct tuning names present in the library, with per-tuning counts. Powers the tuning multi-select. Sorted by `tuning_sort_key` so names appear in the same musical order the sort uses (feedBack#22) — E Standard first, then nearest neighbors.""" library_provider = _get_library_provider(provider) _require_library_provider_capability(library_provider, "library.read") return await _call_library_provider_async(library_provider, "tuning_names") @app.post("/api/favorites/toggle") def toggle_favorite(data: dict): """Toggle a song's favorite status.""" filename = data.get("filename", "") if not filename: return {"error": "No filename"} new_state = meta_db.toggle_favorite(filename) return {"favorite": new_state} # ── Personal per-song metadata (difficulty / notes / tags) ─────────────────── # The local, never-shared layer. Distinct from POST /api/song/{f}/meta, which # writes catalog fields (title/artist/album/year) BACK INTO the feedpak file; # these endpoints are DB-only and never touch the file. Likes stay the heart # (POST /api/favorites/toggle). @app.get("/api/song/{filename:path}/user-meta") def get_song_user_meta(filename: str): """Read {user_difficulty, notes, tags} for one song.""" return meta_db.get_song_user_meta(meta_db._canonical_song_filename(filename)) @app.put("/api/song/{filename:path}/user-meta") def put_song_user_meta(filename: str, data: dict): """Partial update. Send any of: `user_difficulty` (int 1–5, or null/"" to clear), `notes` (string, or null to clear), `tags` (a full-replace array of strings). Omitted keys are preserved. Returns the merged meta. Tag removal is a full-replace `tags` array (send the new set) rather than a granular DELETE sub-route, because `DELETE /api/song/{filename:path}` already owns every DELETE under /api/song and would shadow it.""" key = meta_db._canonical_song_filename(filename) kwargs: dict = {} if "user_difficulty" in data: v = data["user_difficulty"] if v is None or v == "": kwargs["user_difficulty"] = None else: # Reject bools (int subclass) and non-integral floats so 2.5 / true # can't silently truncate into a valid band. if isinstance(v, bool) or (isinstance(v, float) and not v.is_integer()): return JSONResponse({"error": "user_difficulty must be an integer 1–5 or null"}, 400) try: iv = int(v) except (TypeError, ValueError): return JSONResponse({"error": "user_difficulty must be an integer 1–5 or null"}, 400) if not (1 <= iv <= 5): return JSONResponse({"error": "user_difficulty must be 1–5 or null"}, 400) kwargs["user_difficulty"] = iv if "notes" in data: n = data["notes"] if n is None: kwargs["notes"] = None elif isinstance(n, str): kwargs["notes"] = n.strip()[:4000] else: return JSONResponse({"error": "notes must be a string or null"}, 400) tags = data.get("tags", "__absent__") if tags != "__absent__" and not isinstance(tags, list): return JSONResponse({"error": "tags must be an array of strings"}, 400) if not kwargs and tags == "__absent__": return JSONResponse({"error": "No fields to update"}, 400) if kwargs: meta_db.set_song_user_meta(key, **kwargs) if tags != "__absent__": meta_db.set_song_tags(key, tags) return meta_db.get_song_user_meta(key) @app.post("/api/songs/user-meta/batch") def batch_song_user_meta(data: dict): """Bulk personal-meta edit over a selection — one request instead of N×2 per-song round-trips (the batch bar's apply-to-all). DB-only; never touches files. Body: {"filenames": [...], # required, non-empty "set_difficulty": 1-5 | null, # optional: set on all / clear on all "add_tags": [...], # optional: add to all (never full-replace) "remove_tags": [...]} # optional: remove from all Omit `set_difficulty` entirely to leave each song's difficulty as-is (mixed-state "leave unchanged"). Returns {"updated": N, "tags": [...]} so the caller can refresh the tag-filter list without a second call.""" fns = data.get("filenames") if not isinstance(fns, list) or not fns: return JSONResponse({"error": "filenames must be a non-empty array"}, 400) if not all(isinstance(f, str) and f for f in fns): return JSONResponse({"error": "filenames must be non-empty strings"}, 400) kwargs: dict = {} if "set_difficulty" in data: v = data["set_difficulty"] if v is None or v == "": kwargs["set_difficulty"] = None else: if isinstance(v, bool) or (isinstance(v, float) and not v.is_integer()): return JSONResponse({"error": "set_difficulty must be an integer 1–5 or null"}, 400) try: iv = int(v) except (TypeError, ValueError): return JSONResponse({"error": "set_difficulty must be an integer 1–5 or null"}, 400) if not (1 <= iv <= 5): return JSONResponse({"error": "set_difficulty must be 1–5 or null"}, 400) kwargs["set_difficulty"] = iv add_tags = data.get("add_tags") remove_tags = data.get("remove_tags") for name, val in (("add_tags", add_tags), ("remove_tags", remove_tags)): if val is not None and not isinstance(val, list): return JSONResponse({"error": f"{name} must be an array of strings"}, 400) if "set_difficulty" not in data and not add_tags and not remove_tags: return JSONResponse({"error": "Nothing to apply"}, 400) keys = [meta_db._canonical_song_filename(f) for f in fns] n = meta_db.batch_user_meta(keys, add_tags=add_tags, remove_tags=remove_tags, **kwargs) return {"updated": n, "tags": meta_db.all_tags()} @app.get("/api/tags") def list_tags(): """All personal tags in use (over still-present songs), most-used first — powers the tag filter UI.""" return {"tags": meta_db.all_tags()} # ── Artist aliases / Tidy-up (P4) ──────────────────────────────────────────── # Canonicalize messy artist tags at DISPLAY ("ACDC" → "AC/DC") without touching # the feedpak files or the scanner-derived songs.artist. All DB-only. @app.get("/api/artist-aliases") def list_artist_aliases(): """Existing raw→canonical overrides (the Tidy-up 'current merges' list).""" return {"aliases": meta_db.list_artist_aliases()} @app.get("/api/artists/raw") def list_raw_artists(limit: int = 2000): """Distinct RAW artist names + song counts + current canonical — the Tidy-up picker (you merge raw variants into one canonical).""" return {"artists": meta_db.raw_artists(limit)} @app.post("/api/artist-aliases") def set_artist_alias(data: dict): """Upsert one override: {raw_name, canonical_name, mb_artist_id?}. A self-alias (raw == canonical) clears the row instead (un-merge).""" raw = (data.get("raw_name") or "").strip() canon = (data.get("canonical_name") or "").strip() if not raw or not canon: return JSONResponse({"error": "raw_name and canonical_name are required"}, 400) result = meta_db.set_artist_alias(raw, canon, (data.get("mb_artist_id") or None)) if not result.get("ok"): # Would form a cycle (raw → … → raw) — refuse rather than corrupt the chain. return JSONResponse( {"error": "alias would create a cycle", "raw_name": raw, "canonical_name": canon}, 409) return {"ok": True, "raw_name": raw, "canonical_name": result.get("canonical_name", canon)} @app.post("/api/artist-aliases/merge") def merge_artist_aliases(data: dict): """Merge several raw artist variants into one canonical: {raw_names: [...], canonical_name}. The canonical's own self-alias is skipped. Returns {merged: N}.""" canon = (data.get("canonical_name") or "").strip() raws = data.get("raw_names") if not canon: return JSONResponse({"error": "canonical_name is required"}, 400) if not isinstance(raws, list) or not raws: return JSONResponse({"error": "raw_names must be a non-empty array"}, 400) n = meta_db.merge_artists(raws, canon) return {"merged": n, "canonical_name": canon} @app.delete("/api/artist-aliases/{raw_name:path}") def delete_artist_alias(raw_name: str): """Remove one override so that raw artist stands on its own again.""" meta_db.remove_artist_alias(raw_name) return {"ok": True} # ── Artist pages (launch charrette PR-B) ────────────────────────────────────── # GET page = 100% local (renders offline, renders unmatched); GET links = the # ONE lazy MusicBrainz artist lookup, cached forever in artist_enrichment and # re-fetched only by the explicit refresh. Both links routes are demo-blocked # (they store server state + spend the shared MB rate limit). # MB artist url-relation types → the page's link slots (locked position 4: # whitelist only, links-only forever). Everything not listed is dropped. _ARTIST_URL_REL_SLOTS = { "official homepage": "official", "setlistfm": "tour", "concerts": "tour", "youtube": "video", "video channel": "video", "social network": "social", "bandcamp": "social", "soundcloud": "social", "wikipedia": "wikipedia", "wikidata": "wikipedia", } def _artist_links_from_mb(body: dict) -> tuple[dict, list]: """Whitelist an MB artist doc's url-relations into the page's link slots: {official, tour, video, social: [...], wikipedia}. Every URL passes the same http(s)-scheme gate as art redirects (_safe_art_redirect_url) so a hostile javascript:/data:/file: resource can never reach an href. First URL wins per single slot; social collects up to 5; wikipedia is preferred over wikidata when both exist. Also returns MB's genre names (capped).""" links: dict = {} social: list = [] wikidata_url = None for rel in (body or {}).get("relations") or []: if not isinstance(rel, dict): continue rtype = str(rel.get("type") or "").strip().lower() slot = _ARTIST_URL_REL_SLOTS.get(rtype) if not slot: continue url = rel.get("url") url = url.get("resource") if isinstance(url, dict) else url if _safe_art_redirect_url(url) is None: continue if slot == "social": if url not in social and len(social) < 5: social.append(url) elif rtype == "wikidata": wikidata_url = wikidata_url or url elif slot not in links: links[slot] = url if social: links["social"] = social if "wikipedia" not in links and wikidata_url: links["wikipedia"] = wikidata_url genres = [str(g.get("name")) for g in (body or {}).get("genres") or [] if isinstance(g, dict) and g.get("name")] return links, genres[:8] def _artist_links_payload(name: str, force: bool = False) -> dict: """Shared by GET links + POST refresh. Order of gates: the user's opt-in setting (external links are OFF by default — the dev-chat thread's call), then a known mb_artist_id (no id → nothing to look up), then the cache (unless force), then the offline guard, then ONE throttled fetch.""" cfg = _load_config(CONFIG_DIR / "config.json") or _default_settings() if cfg.get("artist_external_links") is not True: return {"links": {}, "matched": False, "disabled": True} canonical = meta_db._terminal_canonical((name or "").strip()) mbid = meta_db.artist_known_mb_id(meta_db._raw_variants_for(canonical)) mbid = (mbid or "").strip().lower() # The id is interpolated into the MB request path — same strict-shape rule # as the manifest identity keys (_MBID_RE), so a junk/hostile value stored # via a hand-rolled /pick body can never reach the request line. if not mbid or not _MBID_RE.match(mbid): return {"links": {}, "matched": False} if not force: cached = meta_db.get_artist_enrichment(mbid) if cached: return {"links": cached["url_rels"], "genres": cached["genres"], "matched": True, "cached": True, "mb_artist_id": mbid} if not _enrich_network_enabled(): return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid} try: body = _mb_http_get(f"artist/{mbid}", {"inc": "url-rels+genres+tags"}) except EnrichTransportError: return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid} links, genres = _artist_links_from_mb(body or {}) meta_db.put_artist_enrichment(mbid, links, genres) return {"links": links, "genres": genres, "matched": True, "cached": False, "mb_artist_id": mbid} @app.get("/api/artist/{name:path}/page") def api_artist_page(name: str): """The artist page's all-LOCAL payload — counts, albums, aliases, similar- in-library, mosaic art, play-all seed. Never touches the network; an unmatched or even unknown artist still returns a functional page.""" return meta_db.artist_page(name) @app.get("/api/artist/{name:path}/links") def api_artist_links(name: str): """External links for a matched artist — cached after the first call. Sync route on purpose (like /api/enrichment/search): FastAPI runs it in the threadpool so the MB throttle's sleep never blocks the event loop.""" return _artist_links_payload(name) @app.post("/api/artist/{name:path}/links/refresh") def api_artist_links_refresh(name: str): """Explicit re-fetch of the cached links (the page's manual Refresh).""" return _artist_links_payload(name, force=True) # ── Player profile / unified XP / streak (fee[dB]ack v0.3.0) ────────────────── def _list_bundled_avatars() -> list[str]: """Bundled default avatar filenames under static/v3/avatars/.""" d = STATIC_DIR / "v3" / "avatars" if not d.is_dir(): return [] exts = {".svg", ".png", ".webp"} return sorted( p.name for p in d.iterdir() if p.is_file() and p.suffix.lower() in exts and not p.name.startswith(".") ) @app.get("/api/profile") def api_get_profile(): profile = meta_db.get_profile() # Equipped cosmetics ride along (resolved to their payloads) so the theme # and avatar frame apply at boot without an extra request. Never let a # cosmetics/content problem break the profile read. cosmetics = {} try: shop = _get_progression_content()["shop"] for slot, item_id in meta_db.get_equipped().items(): item = shop.get(item_id) if item: cosmetics[slot] = {"item_id": item_id, "payload": item["payload"]} except Exception: log.warning("profile cosmetics enrich failed", exc_info=True) profile["cosmetics"] = cosmetics return profile def _clean_str(value) -> str: """Trim a request field to a string; non-strings (or missing) → ''. Lets the raw-`dict` POST handlers treat wrong-typed JSON (an int/list/etc. where a string was expected) as "empty" and answer 400, instead of raising AttributeError/TypeError → 500 on a later .strip()/`in`.""" return value.strip() if isinstance(value, str) else "" def _as_int(value) -> int: """Coerce a JSON value to an int, REJECTING bool and non-integral numbers so e.g. 1.9 / True don't silently truncate to 1. Accepts ints, integral floats (1.0), and integer-shaped strings ("5"); raises ValueError otherwise.""" if isinstance(value, bool): raise ValueError("bool is not an integer") if isinstance(value, int): return value if isinstance(value, float): if not math.isfinite(value) or not value.is_integer(): raise ValueError("non-integral float") return int(value) if isinstance(value, str): return int(value) # int("5") ok; int("1.9")/"nan"/"inf" raise ValueError raise ValueError("not an integer") @app.post("/api/profile") def api_set_profile(data: dict): """Set/update the player profile. Body: {display_name, avatar:{type,value}}. avatar.type is 'default' (value = bundled filename) or 'upload' (value = the /api/profile/avatar/ URL returned by the upload endpoint); omit avatar to keep the existing one (name-only edit).""" name = _clean_str(data.get("display_name")) if not (1 <= len(name) <= 32): return JSONResponse({"error": "Display name must be 1–32 characters."}, status_code=400) avatar = data.get("avatar") if avatar is None: avatar = {} # omitted → keep the current avatar (name-only edit) elif not isinstance(avatar, dict): return JSONResponse({"error": "avatar must be an object."}, status_code=400) atype = avatar.get("type") aval = _clean_str(avatar.get("value")) avatar_url = None if atype == "default": if aval not in _list_bundled_avatars(): return JSONResponse({"error": "Unknown default avatar."}, status_code=400) avatar_url = f"/static/v3/avatars/{aval}" elif atype == "upload": from safepath import safe_join fname = aval.rsplit("/", 1)[-1] if aval.startswith("/api/profile/avatar/") else "" target = safe_join(CONFIG_DIR / "avatars", fname) if fname else None if target is None or not target.is_file(): return JSONResponse({"error": "Uploaded avatar not found."}, status_code=400) avatar_url = f"/api/profile/avatar/{fname}" elif atype: return JSONResponse({"error": "Unknown avatar type."}, status_code=400) # atype None/missing → keep the current avatar (name-only edit). return meta_db.set_profile(name, avatar_url) @app.get("/api/profile/avatars") def api_list_avatars(): return [{"name": n, "url": f"/static/v3/avatars/{n}"} for n in _list_bundled_avatars()] @app.post("/api/profile/avatar") def api_upload_avatar(data: dict): """Upload a custom avatar as base64 (mirrors the album-art upload pattern). Re-encodes to a ≤512px PNG under CONFIG_DIR/avatars/.""" import base64 import io b64 = data.get("image", "") if not isinstance(b64, str) or not b64: return JSONResponse({"error": "No image data"}, status_code=400) if "," in b64: b64 = b64.split(",", 1)[1] try: raw = base64.b64decode(b64) except Exception: return JSONResponse({"error": "Invalid base64"}, status_code=400) if len(raw) > 6 * 1024 * 1024: return JSONResponse({"error": "Image too large (max 6 MB)."}, status_code=400) avatars_dir = CONFIG_DIR / "avatars" avatars_dir.mkdir(parents=True, exist_ok=True) try: from PIL import Image img = Image.open(io.BytesIO(raw)).convert("RGB") img.thumbnail((512, 512)) fname = f"upload-{secrets.token_hex(4)}.png" # token busts caches on change img.save(str(avatars_dir / fname), "PNG") except Exception as e: return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400) return {"url": f"/api/profile/avatar/{fname}"} @app.get("/api/profile/avatar/{name}") def api_get_avatar(name: str): from safepath import safe_join target = safe_join(CONFIG_DIR / "avatars", name) if target is None or not target.is_file(): return JSONResponse({"error": "not found"}, status_code=404) return FileResponse(str(target), media_type="image/png") @app.get("/api/profile/progress") def api_profile_progress(): """One call for the whole profile badge: {level, xp, xp_in_level, xp_to_next, current_streak, best_streak, last_active_date}.""" return meta_db.get_progress() @app.post("/api/xp/award") def api_award_xp(data: dict): """Award XP into the unified store. Body: {source, amount}. Returns the new progress payload. The single XP authority — song-play, minigames, and tutorials all feed this (no second curve).""" try: amount = _as_int(data.get("amount", 0)) # rejects bool / non-integral / inf except (TypeError, ValueError, OverflowError): return JSONResponse({"error": "amount must be an integer"}, status_code=400) # Upper-bound it: an unbounded value overflows SQLite's 64-bit INTEGER on # bind (→ 500) and no real run awards anywhere near this. if not (0 <= amount <= 10_000_000): return JSONResponse({"error": "amount must be between 0 and 10,000,000"}, status_code=400) meta_db.award_xp(amount) return meta_db.get_progress() # ── Progression (spec 010): mastery rank, challenges, quests, shop ─────────── def _goal_ui_progress(goal: dict, state: dict, streak: int, xp_total: int) -> tuple: """(count, target) for a challenge/quest progress bar. Count goals show n/target; threshold goals show how far the live stat is along the line.""" import progression as progression_mod gtype = goal.get("type") if gtype in progression_mod.COUNT_GOAL_TYPES: target = int(goal.get("target") or 1) count = target if state.get("completed") else min(int(state.get("count") or 0), target) return count, target if gtype == "streak_reached": target = int(goal.get("days") or 1) return (target if state.get("completed") else min(streak, target)), target if gtype == "db_earned": target = int(goal.get("amount") or 1) return (target if state.get("completed") else min(xp_total, target)), target return 0, 1 def _progression_overview() -> dict: """The full GET /api/progression payload (also the capability `inspect` result): rank, onboarding, per-path challenge checklists, quests, wallet.""" import progression as progression_mod from datetime import datetime as _dt content = _get_progression_content() now = _dt.now() meta_db.ensure_quest_period(content, now) state = meta_db.get_progression_state() player_paths = meta_db.get_player_paths() challenge_state = meta_db.get_challenge_state() wallet = meta_db.get_wallet() streak_progress = meta_db.get_progress() streak = int(streak_progress.get("current_streak") or 0) xp_total = wallet["lifetime_db"] keys = progression_mod.period_keys(now) def _path_order(pid): pdef = content["paths"].get(pid) or {} return (pdef.get("order") or 0, pid) paths_payload = [] for pid in sorted(player_paths, key=_path_order): pdef = content["paths"].get(pid) level = player_paths[pid] if not pdef: # Path selected under older content that no longer ships: keep its # rank contribution visible rather than silently dropping it. paths_payload.append({"id": pid, "name": pid, "icon": "", "level": level, "max_level": level, "next": None}) continue next_block = None active = progression_mod.active_challenges(content, pid, level) if active: level_def = next(e for e in pdef["levels"] if e["level"] == level + 1) challenges = [] completed_count = 0 for ch in active: st = challenge_state.get(ch["id"]) or {} count, target = _goal_ui_progress(ch["goal"], st, streak, xp_total) if st.get("completed"): completed_count += 1 challenges.append({ "id": ch["id"], "title": ch["title"], "description": ch["description"], "count": count, "target": target, "completed": bool(st.get("completed")), "completed_at": st.get("completed_at"), }) next_block = { "level": level + 1, "required": level_def["required"], "completed": completed_count, "challenges": challenges, } paths_payload.append({ "id": pid, "name": pdef["name"], "icon": pdef["icon"], "level": level, "max_level": progression_mod.path_max_level(content, pid), "next": next_block, }) available = [ {"id": pid, "name": pdef["name"], "icon": pdef["icon"]} for pid, pdef in sorted(content["paths"].items(), key=lambda kv: (kv[1].get("order") or 0, kv[0])) if pid not in player_paths ] quest_rows = meta_db.get_quest_rows(keys) quests_payload = {} for period_type in ("daily", "weekly"): pool = content["quests"][period_type]["pool"] quests = [] for row in quest_rows: if row["period_type"] != period_type: continue qdef = pool.get(row["quest_id"]) if not qdef: continue # removed from the pool mid-period: hide, keep the row count, target = _goal_ui_progress(qdef["goal"], row, streak, xp_total) quests.append({ "id": row["quest_id"], "title": qdef["title"], "description": qdef["description"], "reward_db": row["reward_db"], "count": count, "target": target, "completed": row["completed"], "completed_at": row["completed_at"], }) quests_payload[period_type] = { "period_key": keys[period_type], "resets_at": progression_mod.period_resets_at(period_type, now).isoformat(), "quests": quests, } return { "mastery_rank": progression_mod.mastery_rank(state["calibration_status"], player_paths), "onboarding": { "calibration_status": state["calibration_status"], "calibration_completed_at": state["calibration_completed_at"], "diagnostic_filename": _builtin_diagnostic_filename(), }, "paths": paths_payload, "available_paths": available, "quests": quests_payload, "wallet": wallet, } @app.get("/api/progression") def api_progression(): return _progression_overview() @app.post("/api/progression/paths") def api_progression_add_paths(data: dict): """Select instrument paths. Body: {add: [path_id, ...]}. Idempotent; removal is unsupported (Mastery Rank never decreases).""" add = data.get("add") if not isinstance(add, list) or not add: return JSONResponse({"error": "add must be a non-empty list of path ids"}, status_code=400) content = _get_progression_content() for pid in add: if not isinstance(pid, str) or pid not in content["paths"]: return JSONResponse({"error": f"unknown path: {pid!r}"}, status_code=400) meta_db.add_player_paths(add) return _progression_overview() @app.post("/api/progression/onboarding") def api_progression_onboarding(data: dict): """Onboarding calibration choice. Body: {action: "skip"} — completing the calibration needs no endpoint, it flows through the normal /api/stats path.""" if _clean_str(data.get("action")) != "skip": return JSONResponse({"error": "action must be 'skip'"}, status_code=400) # Spec invariant: onboarding requires picking at least one instrument path # before finishing, so skipping straight to rank 1 with no paths would # leave a rank that can never grow. Only enforced when the content bundle # actually defines paths — broken/empty content must never brick onboarding. if _get_progression_content()["paths"] and not meta_db.get_player_paths(): return JSONResponse( {"error": "select at least one instrument path before skipping calibration"}, status_code=400, ) meta_db.skip_calibration() return _progression_overview() # Externally postable progression events. song_completed is deliberately NOT # here: it is server-derived inside /api/stats so the scored-session authority # stays in one place. _PROGRESSION_EVENT_TYPES = {"minigame_run"} @app.post("/api/progression/events") def api_progression_events(data: dict): """Generic progression-event intake for plugins (capability `record-event`). Body: {type, payload}. Whitelisted types, scalar payload values only.""" etype = _clean_str(data.get("type")) if etype not in _PROGRESSION_EVENT_TYPES: return JSONResponse( {"error": f"event type must be one of {sorted(_PROGRESSION_EVENT_TYPES)}"}, status_code=400, ) payload = data.get("payload") if payload is None: payload = {} if not isinstance(payload, dict) or len(payload) > 16: return JSONResponse({"error": "payload must be a small object"}, status_code=400) clean = {} for key, value in payload.items(): if not isinstance(key, str) or len(key) > 64: return JSONResponse({"error": "payload keys must be short strings"}, status_code=400) if value is None: continue if isinstance(value, bool) or ( not isinstance(value, (int, float, str)) ) or (isinstance(value, float) and not math.isfinite(value)) or ( isinstance(value, str) and len(value) > 256 ): return JSONResponse({"error": "payload values must be short strings or finite numbers"}, status_code=400) clean[key] = value summary = meta_db.record_progression_event(etype, clean, _get_progression_content()) return {"ok": True, "progression": summary} @app.get("/api/shop") def api_shop(): content = _get_progression_content() owned = meta_db.get_owned_items() equipped = meta_db.get_equipped() items = [ {**item, "owned": iid in owned, "equipped": equipped.get(item["slot"]) == iid} for iid, item in sorted(content["shop"].items()) ] return {"items": items, "wallet": meta_db.get_wallet()} @app.post("/api/shop/buy") def api_shop_buy(data: dict): """Spend Decibels on a cosmetic. Atomic: balance check + spend + ownership in one transaction. Decibels are earned by playing only — never purchasable.""" item_id = _clean_str(data.get("item_id")) item = _get_progression_content()["shop"].get(item_id) if not item: return JSONResponse({"error": f"unknown item: {item_id!r}"}, status_code=400) status, wallet = meta_db.buy_shop_item(item) if status == "owned": return JSONResponse({"error": "already owned", "wallet": wallet}, status_code=409) if status == "insufficient": return JSONResponse({"error": "insufficient balance", "wallet": wallet}, status_code=402) return {"ok": True, "item_id": item_id, "wallet": wallet} @app.post("/api/shop/equip") def api_shop_equip(data: dict): """Equip an owned cosmetic into its slot. Body: {slot, item_id|null} (null unequips, restoring the default look).""" import progression as progression_mod slot = _clean_str(data.get("slot")) if slot not in progression_mod.SHOP_SLOTS: return JSONResponse({"error": f"slot must be one of {sorted(progression_mod.SHOP_SLOTS)}"}, status_code=400) item_id = data.get("item_id") if item_id is not None: item_id = _clean_str(item_id) item = _get_progression_content()["shop"].get(item_id) if not item or item["slot"] != slot: return JSONResponse({"error": f"unknown item for slot {slot}: {item_id!r}"}, status_code=400) if item_id not in meta_db.get_owned_items(): return JSONResponse({"error": "item not owned"}, status_code=403) return {"ok": True, "equipped": meta_db.equip_item(slot, item_id)} # ── Per-song practice stats (fee[dB]ack v0.3.0) ─────────────────────────────── @app.post("/api/stats") def api_record_stats(data: dict): """Record a play. With `score`+`accuracy` → a scored session (plays += 1, best_* = max, last_* = new) plus unified-XP + streak side-effects. With only `lastPlayPosition`/`last_position` → a lightweight resume-position touch (no plays change) so Continue-Playing works for non-scored plays.""" filename = _clean_str(data.get("filename")) if not filename: return JSONResponse({"error": "filename required"}, status_code=400) # The recorder hands us URL-encoded filenames; canonicalize to the library # key so stored rows line up with `songs` (and so the arrangement-count bound # below resolves the real song). See MetadataDB._canonical_song_filename. filename = meta_db._canonical_song_filename(filename) arr_raw = data.get("arrangement", 0) if arr_raw is None: arrangement = 0 else: try: arrangement = _as_int(arr_raw) # rejects bool / non-integral (1.9) / inf except (TypeError, ValueError, OverflowError): return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400) # Reject (don't silently coerce to 0) so a malformed/out-of-range index # can't corrupt arrangement 0's stats; also keeps it bindable to INTEGER. if not (0 <= arrangement < 2**63): return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400) # Bound against the song's real arrangement count when it's a known library # song, so a bad index can't create fake arrangement buckets that poison the # per-song aggregate / Continue. Skipped when the song isn't in the library # yet (count unknown — dead-song reads are filtered anyway). _acount = meta_db.arrangement_count(filename) if _acount and arrangement >= _acount: return JSONResponse({"error": "arrangement out of range for this song"}, status_code=400) score = data.get("score") accuracy = data.get("accuracy") last_pos = data.get("lastPlayPosition", data.get("last_position")) if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) # A scored session needs BOTH score and accuracy. Exactly one provided is # ambiguous — don't silently fall through to the position-only branch. if (score is None) != (accuracy is None): return JSONResponse({"error": "score and accuracy must be provided together"}, status_code=400) if score is not None and accuracy is not None: # Reject booleans explicitly — float(True) would otherwise record a play. if isinstance(score, bool) or isinstance(accuracy, bool): return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400) # Reject NaN/Inf too: round(inf) raises OverflowError (→ 500), and a # stored Inf/NaN later breaks JSON serialization of /api/stats reads. try: score = float(score) accuracy = float(accuracy) if not (math.isfinite(score) and math.isfinite(accuracy)): raise ValueError("non-finite") score = int(round(score)) except (TypeError, ValueError, OverflowError): return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400) # A huge-but-finite score passes isfinite() yet overflows SQLite's # 64-bit INTEGER on bind (→ 500). Bound it to the int64 range. if not (0 <= score < 2**63): return JSONResponse({"error": "score out of range"}, status_code=400) # accuracy is a 0..1 fraction (the recorder's contract); reject # out-of-range values so they don't surface as >100% / negative in # /api/stats/best and the badge UI. if not (0 <= accuracy <= 1): return JSONResponse({"error": "accuracy must be between 0 and 1"}, status_code=400) # Validate the optional resume position in this branch too (the # position-only branch below already rejects non-finite). if last_pos is not None: try: last_pos = float(last_pos) if not math.isfinite(last_pos): raise ValueError("non-finite") except (TypeError, ValueError, OverflowError): return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) row = meta_db.record_session(filename, arrangement, score=score, accuracy=accuracy, last_position=last_pos) # Unified XP + streak side-effects — never let these drop the stat write. progress = None try: from xp import xp_for_run from datetime import date meta_db.award_xp(xp_for_run(score)) meta_db.record_active_day(date.today().isoformat()) progress = meta_db.get_progress() except Exception: log.warning("stats side-effects (xp/streak) failed", exc_info=True) # Progression engine (spec 010) — same never-drop-the-stat-write # contract. Scored sessions are the server-derived `song_completed` # authority (scored == note detection by construction); instrument is # resolved from library arrangement metadata, after the XP award so # db_earned goals see this run's Decibels. progression_summary = None try: import progression as progression_mod instrument = progression_mod.instrument_for_arrangement( meta_db.arrangement_entry(filename, arrangement) ) progression_summary = meta_db.record_progression_event( "song_completed", { "filename": filename, "instrument": instrument, "accuracy": accuracy, "score": score, "is_diagnostic": filename == _builtin_diagnostic_filename(), }, _get_progression_content(), ) except Exception: log.warning("stats side-effects (progression) failed", exc_info=True) return {"stats": row, "progress": progress, "progression": progression_summary} # Position-only touch. if last_pos is None: return JSONResponse( {"error": "provide score+accuracy (scored) or lastPlayPosition (resume)"}, status_code=400, ) try: pos = float(last_pos) if not math.isfinite(pos): raise ValueError("non-finite") row = meta_db.touch_position(filename, arrangement, pos) except (TypeError, ValueError, OverflowError): return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) # A resume session still counts as playing today: advance the streak (no XP — # that's scoring-only) so a non-scored practice day keeps the streak alive, # consistent with these sessions also surfacing in recent / continue. progress = None try: from datetime import date meta_db.record_active_day(date.today().isoformat()) progress = meta_db.get_progress() except Exception: log.warning("stats side-effects (streak) failed", exc_info=True) return {"stats": row, "progress": progress} @app.get("/api/stats/recent") def api_recent_stats(limit: int = 12): """Recently-played rows joined to song metadata for 'Jump back in'.""" from urllib.parse import quote out = [] for r in meta_db.recent_stats(limit): meta = meta_db.conn.execute( "SELECT title, artist, tuning_name FROM songs WHERE filename = ?", (r["filename"],), ).fetchone() title, artist, tuning_name = meta if meta else (None, None, None) out.append({ **r, "title": title or r["filename"], "artist": artist or "", "tuning_name": tuning_name or "", "art_url": f"/api/song/{quote(r['filename'])}/art", }) return out @app.get("/api/stats/best") def api_stats_best(): """{filename: best_accuracy} for all songs with a recorded best — one call to badge the library grid (defined before the {filename} catch-all).""" return meta_db.best_accuracy_map() @app.get("/api/stats/top") def api_top_stats(limit: int = 5): """Top scored songs (best first), joined to song metadata, for the profile 'Your best scores' panel (defined before the {filename} catch-all).""" from urllib.parse import quote out = [] for r in meta_db.top_stats(limit): meta = meta_db.conn.execute( "SELECT title, artist, tuning_name FROM songs WHERE filename = ?", (r["filename"],), ).fetchone() title, artist, tuning_name = meta if meta else (None, None, None) out.append({ **r, "title": title or r["filename"], "artist": artist or "", "tuning_name": tuning_name or "", "art_url": f"/api/song/{quote(r['filename'])}/art", }) return out @app.get("/api/library/practice-suggestions") def api_practice_suggestions(limit: int = 8): """Growth-edge 'practice next' shelf (P3): attempted-but-not-mastered songs ranked by difficulty-appropriateness × mastery-proximity, joined to song metadata. Replaces the recency-only 'Keep practicing' shelf ordering. Local library only — reads local practice stats.""" from urllib.parse import quote out = [] for r in meta_db.growth_edge_suggestions(limit): meta = meta_db.conn.execute( "SELECT title, artist, tuning_name FROM songs WHERE filename = ?", (r["filename"],), ).fetchone() title, artist, tuning_name = meta if meta else (None, None, None) out.append({ **r, "title": title or r["filename"], "artist": artist or "", "tuning_name": tuning_name or "", "art_url": f"/api/song/{quote(r['filename'])}/art", }) return out @app.get("/api/stats/{filename:path}") def api_song_stats(filename: str): return meta_db.get_song_stats(filename) # ── Playlists / Saved for Later / Continue-Playing (fee[dB]ack v0.3.0) ──────── def _playlist_cover_path(pid) -> Path | None: """Filesystem path of a playlist's optional custom cover image (PNG), stored under CONFIG_DIR. Returns None for a non-integer id.""" try: pid = int(pid) except (TypeError, ValueError): return None return CONFIG_DIR / "playlist_covers" / f"{pid}.png" def _playlist_cover_url(pid) -> str | None: cover = _playlist_cover_path(pid) if not cover or not cover.exists(): return None try: # Nanosecond mtime so a same-second replace/remove/re-upload still # changes the cache-bust token (int seconds could collide → stale image). mt = cover.stat().st_mtime_ns except OSError: mt = 0 return f"/api/playlists/{pid}/cover?v={mt}" @app.get("/api/playlists") def api_list_playlists(): lists = meta_db.list_playlists() for pl in lists: pl["cover_url"] = _playlist_cover_url(pl["id"]) return lists @app.post("/api/playlists") def api_create_playlist(data: dict): name = _clean_str(data.get("name")) if not (1 <= len(name) <= 100): return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400) # kind='album' = a curated album (§7.2): hand-picked works, a chosen chart # per slot, played front-to-back on the queue. Absent/None = a regular mix. kind = _clean_str(data.get("kind")) or None if kind not in (None, "album"): return JSONResponse({"error": "kind must be 'album' or omitted"}, status_code=400) return meta_db.create_playlist(name, kind=kind) @app.get("/api/playlists/{pid}") def api_get_playlist(pid: int): pl = meta_db.get_playlist(pid) if pl is None: return JSONResponse({"error": "not found"}, status_code=404) pl["cover_url"] = _playlist_cover_url(pid) return pl @app.patch("/api/playlists/{pid}") def api_rename_playlist(pid: int, data: dict): pl = meta_db.get_playlist(pid) if pl is None: return JSONResponse({"error": "not found"}, status_code=404) if pl["system_key"]: return JSONResponse({"error": "System playlists cannot be renamed."}, status_code=400) name = _clean_str(data.get("name")) if not (1 <= len(name) <= 100): return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400) meta_db.rename_playlist(pid, name) return meta_db.get_playlist(pid) @app.delete("/api/playlists/{pid}") def api_delete_playlist(pid: int): pl = meta_db.get_playlist(pid) if pl is None: return JSONResponse({"error": "not found"}, status_code=404) if pl["system_key"]: return JSONResponse({"error": "System playlists cannot be deleted."}, status_code=400) if not meta_db.delete_playlist(pid): # vanished under us (concurrent delete) return JSONResponse({"error": "not found"}, status_code=404) cover = _playlist_cover_path(pid) # drop any custom cover with the playlist if cover and cover.exists(): try: cover.unlink() except OSError: pass return {"ok": True} @app.post("/api/playlists/{pid}/songs") def api_add_playlist_song(pid: int, data: dict): if meta_db.get_playlist(pid) is None: return JSONResponse({"error": "not found"}, status_code=404) filename = _clean_str(data.get("filename")) if not filename: return JSONResponse({"error": "filename required"}, status_code=400) if meta_db.add_playlist_song(pid, filename) is None: # playlist vanished under us return JSONResponse({"error": "not found"}, status_code=404) pl = meta_db.get_playlist(pid) return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404) @app.patch("/api/playlists/{pid}/songs/{filename:path}") def api_update_playlist_slot(pid: int, filename: str, data: dict): """Edit one curated-album slot: {"arrangement": name|null} pins/clears the slot's arrangement; {"chart_filename": fn} swaps the slot to another chart of the same work (position + pin kept). Albums only — a mix has no slots.""" pl = meta_db.get_playlist(pid) if pl is None: return JSONResponse({"error": "not found"}, status_code=404) if pl.get("kind") != "album": return JSONResponse({"error": "Slot editing is for albums."}, status_code=400) kwargs = {} if "chart_filename" in data: new_fn = _clean_str(data.get("chart_filename")) if not new_fn: return JSONResponse({"error": "chart_filename must be a filename"}, status_code=400) kwargs["new_filename"] = new_fn if "arrangement" in data: arr = data.get("arrangement") if arr is not None and not (isinstance(arr, str) and 1 <= len(arr.strip()) <= 100): return JSONResponse({"error": "arrangement must be a name or null"}, status_code=400) kwargs["arrangement"] = arr.strip() if isinstance(arr, str) else None if not kwargs: return JSONResponse({"error": "nothing to update"}, status_code=400) if meta_db.update_playlist_slot(pid, filename, **kwargs) is None: return JSONResponse( {"error": "no such slot, or the chart isn't a version of this song"}, status_code=400) return meta_db.get_playlist(pid) @app.delete("/api/playlists/{pid}/songs/{filename:path}") def api_remove_playlist_song(pid: int, filename: str): if meta_db.get_playlist(pid) is None: return JSONResponse({"error": "not found"}, status_code=404) meta_db.remove_playlist_song(pid, filename) pl = meta_db.get_playlist(pid) return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404) @app.post("/api/playlists/{pid}/reorder") def api_reorder_playlist(pid: int, data: dict): pl = meta_db.get_playlist(pid) if pl is None: return JSONResponse({"error": "not found"}, status_code=404) order = data.get("order") if not isinstance(order, list) or not all(isinstance(f, str) for f in order): return JSONResponse({"error": "order must be a list of filenames"}, status_code=400) # Require an exact permutation of the playlist's current songs: a list with # duplicates, omissions, or extras would otherwise produce duplicate # positions / a partial reorder while still returning 200. current = [s["filename"] for s in pl["songs"]] if len(order) != len(current) or sorted(order) != sorted(current): return JSONResponse( {"error": "order must be a permutation of the playlist's current songs"}, status_code=400, ) meta_db.reorder_playlist(pid, order) return meta_db.get_playlist(pid) @app.post("/api/playlists/{pid}/cover") async def api_set_playlist_cover(pid: int, data: dict): """Set a playlist's custom cover from a base64 / data-URL image (PNG/JPG). Overrides the content-dependent (song-art) cover. Stored as a small PNG thumbnail under CONFIG_DIR/playlist_covers/.""" if meta_db.get_playlist(pid) is None: return JSONResponse({"error": "not found"}, status_code=404) import base64 import io b64 = data.get("image", "") # Guard the type before the `","` membership test — a non-string image # (e.g. {"image": 123} / null) would otherwise raise TypeError → 500. # Mirrors the avatar/song-art upload guard. if not isinstance(b64, str) or not b64: return JSONResponse({"error": "No image data"}, status_code=400) if "," in b64: b64 = b64.split(",", 1)[1] if not b64: return JSONResponse({"error": "No image data"}, status_code=400) try: img_data = base64.b64decode(b64) except Exception: return JSONResponse({"error": "Invalid base64"}, status_code=400) cover = _playlist_cover_path(pid) cover.parent.mkdir(parents=True, exist_ok=True) try: from PIL import Image img = Image.open(io.BytesIO(img_data)).convert("RGB") img.thumbnail((640, 640)) # covers stay small tmp = cover.with_suffix(".png.tmp") img.save(str(tmp), "PNG") tmp.replace(cover) except Exception as e: return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400) return {"ok": True, "cover_url": _playlist_cover_url(pid)} @app.get("/api/playlists/{pid}/cover") def api_get_playlist_cover(pid: int): cover = _playlist_cover_path(pid) if not cover or not cover.exists(): return JSONResponse({"error": "not found"}, status_code=404) # no-cache (revalidate) like song art, so a replaced cover is never served # stale — pairs with the mtime-ns cache-bust token on the URL. return FileResponse(str(cover), media_type="image/png", headers=_ART_CACHE_HEADERS) @app.delete("/api/playlists/{pid}/cover") def api_delete_playlist_cover(pid: int): cover = _playlist_cover_path(pid) if cover and cover.exists(): try: cover.unlink() except OSError: pass return {"ok": True} # ── Smart collections API (feedBack#636 item 2) ─────────────────────────────── # (rule schema + `_sanitize_collection_rules` are defined with the provider.) @app.get("/api/collections") def api_list_collections(): """Smart/dynamic collections (saved live library filters).""" return {"collections": meta_db.list_collections()} @app.post("/api/collections") def api_create_collection(data: dict): """Create a collection from a name + a set of library filter rules. It immediately appears as a source in the library provider picker.""" if not isinstance(data, dict): return JSONResponse({"error": "body must be an object"}, status_code=400) name = _clean_str(data.get("name")) if not name: return JSONResponse({"error": "name required"}, status_code=400) col = meta_db.create_collection(name, _sanitize_collection_rules(data.get("rules"))) _sync_collection_provider(col) return {"ok": True, "collection": col} @app.put("/api/collections/{pid}") def api_update_collection(pid: int, data: dict): """Rename a collection and/or replace its rules.""" if not isinstance(data, dict): return JSONResponse({"error": "body must be an object"}, status_code=400) name = _clean_str(data.get("name")) or None rules = _sanitize_collection_rules(data["rules"]) if "rules" in data else None col = meta_db.update_collection(pid, name=name, rules=rules) if col is None: return JSONResponse({"error": "collection not found"}, status_code=404) _sync_collection_provider(col) return {"ok": True, "collection": col} @app.delete("/api/collections/{pid}") def api_delete_collection(pid: int): """Delete a collection and unregister its provider.""" if not meta_db.is_collection(pid): return JSONResponse({"error": "collection not found"}, status_code=404) meta_db.delete_playlist(pid) _unregister_collection_provider(pid) return {"ok": True} @app.post("/api/saved/toggle") def api_toggle_saved(data: dict): """Add/remove a song on the reserved Saved-for-Later playlist.""" filename = _clean_str(data.get("filename")) if not filename: return JSONResponse({"error": "filename required"}, status_code=400) return {"saved": meta_db.toggle_saved(filename)} @app.get("/api/session/continue") def api_session_continue(): """The Continue-Playing card's song (most recent play) or null.""" return meta_db.continue_session() # ── Wishlist / "wanted" API (feedBack#636 item 4) ───────────────────────────── @app.get("/api/wanted") def api_list_wanted(): """The wishlist — songs the user wants but doesn't own yet (newest first).""" return {"wanted": meta_db.list_wanted()} @app.post("/api/wanted") def api_add_wanted(data: dict): """Add a not-owned song to the wishlist. `artist`/`title` are required (at least one non-empty); `source`/`source_ref`/`note` are optional. Idempotent on identity so producers (find_more ownership-diff, manual add) can re-post.""" if not isinstance(data, dict): return JSONResponse({"error": "body must be an object"}, status_code=400) artist = _clean_str(data.get("artist")) title = _clean_str(data.get("title")) if not artist and not title: return JSONResponse({"error": "artist or title required"}, status_code=400) row = meta_db.add_wanted( artist=artist, title=title, source=_clean_str(data.get("source")) or "manual", source_ref=_clean_str(data.get("source_ref")), note=_clean_str(data.get("note")), ) return {"ok": True, "wanted": row} @app.delete("/api/wanted/{wanted_id}") def api_remove_wanted(wanted_id: int): """Remove a wishlist entry by id.""" return {"ok": meta_db.remove_wanted(wanted_id)} # ── Loops API ──────────────────────────────────────────────────────────────── @app.get("/api/loops") def list_loops(filename: str): rows = meta_db.conn.execute( "SELECT id, name, start_time, end_time FROM loops WHERE filename = ? ORDER BY start_time", (filename,) ).fetchall() return [{"id": r[0], "name": r[1], "start": r[2], "end": r[3]} for r in rows] @app.post("/api/loops") def save_loop(data: dict): filename = data.get("filename", "") name = data.get("name", "").strip() start = data.get("start") end = data.get("end") if not filename or start is None or end is None: return {"error": "Missing fields"} if not name: count = meta_db.conn.execute( "SELECT COUNT(*) FROM loops WHERE filename = ?", (filename,) ).fetchone()[0] name = f"Loop {count + 1}" with meta_db._lock: meta_db.conn.execute( "INSERT INTO loops (filename, name, start_time, end_time) VALUES (?, ?, ?, ?)", (filename, name, float(start), float(end)) ) meta_db.conn.commit() return {"ok": True, "name": name} @app.delete("/api/loops/{loop_id}") def delete_loop(loop_id: int): with meta_db._lock: meta_db.conn.execute("DELETE FROM loops WHERE id = ?", (loop_id,)) meta_db.conn.commit() return {"ok": True} # ── Audio Effects Mapping API ─────────────────────────────────────────────── def _audio_effects_error(exc: Exception): return JSONResponse({"error": str(exc)}, status_code=400) @app.get("/api/audio-effects/mappings") def list_audio_effect_mappings( song_key: str = Query(""), filename: str = Query(""), tone_key: str = Query(""), provider_id: str = Query(""), ): try: return { "mappings": audio_effect_mappings.list( song_key=song_key, filename=filename, tone_key=tone_key, provider_id=provider_id, ) } except ValueError as exc: return _audio_effects_error(exc) @app.post("/api/audio-effects/mappings") def upsert_audio_effect_mapping(data: dict = Body(...)): try: mapping = audio_effect_mappings.upsert(data) except ValueError as exc: return _audio_effects_error(exc) return {"ok": True, "mapping": mapping} @app.delete("/api/audio-effects/mappings/{mapping_id}") def delete_audio_effect_mapping(mapping_id: int, provider_id: str = Query("")): try: deleted = audio_effect_mappings.delete(mapping_id, provider_id=provider_id) except ValueError as exc: return _audio_effects_error(exc) if not deleted: return JSONResponse({"error": "mapping not found"}, status_code=404) return {"ok": True} @app.post("/api/audio-effects/mappings/{mapping_id}/activate") def activate_audio_effect_mapping(mapping_id: int, data: dict = Body(default_factory=dict)): try: provider_id = data.get("provider_id") if "provider_id" in data else data.get("providerId") mapping = audio_effect_mappings.activate(mapping_id, provider_id="" if provider_id is None else provider_id) except ValueError as exc: return _audio_effects_error(exc) if not mapping: return JSONResponse({"error": "mapping not found"}, status_code=404) return {"ok": True, "mapping": mapping} @app.delete("/api/audio-effects/active-mapping") def clear_audio_effect_active_mapping(song_key: str = Query(...), tone_key: str = Query("")): try: cleared = audio_effect_mappings.clear_active(song_key=song_key, tone_key=tone_key) except ValueError as exc: return _audio_effects_error(exc) return {"ok": True, "cleared": cleared} # ── Settings API ────────────────────────────────────────────────────────────── # Serializes the read-modify-write in save_settings(). See the note there. _settings_lock = threading.Lock() def _default_settings(): """Fallback settings returned when config.json is missing or unreadable. Also used to seed a fresh cfg on first-run POSTs so a single-key write (e.g. the difficulty slider) can't silently wipe defaults that subsequent GETs would have exposed.""" # Same `_DLC_DIR_ENV` truthy check as `_get_dlc_dir`: an empty env # var collapses to `Path(".")` whose `.is_dir()` is True, so without # the explicit guard we'd surface `"."` to /api/settings — and any # partial-update POST would then persist that into config.json, # silently undoing the env-var fix on the next load. return { "dlc_dir": str(DLC_DIR) if (_DLC_DIR_ENV and DLC_DIR.is_dir()) else "", # fee[dB]ack v0.3.0 gameplay settings (tabbed settings page). Each # defaults to its neutral / off value so existing users see no # behaviour change until they opt in. countdown_before_song is wired # into the song-start path; miss_penalty / fail_behavior are persisted # but not yet consumed by scoring (stub rows on the Gameplay tab). "countdown_before_song": False, "miss_penalty": "none", "fail_behavior": "continue", # Achievements epic: opt-in to publishing earned Feats (name + Feat id # only) to the hosted wall. Default OFF — nothing leaves the device # until the user opts in. Read by the bundled achievements plugin to # gate its wall-sync enqueue. "achievements_enabled": False, # Amp-sim opt-in (issue feedBack-desktop#46). Whether the desktop app may # auto-load an in-app amp-sim / tone chain (NAM / IR / VST) for input # monitoring. Default OFF — "own-rig first": players monitoring through # their own external amp/rig never get a processed monitor (and never the # idle distorted buzz) until they opt in. Set during onboarding (desktop # only) and from the desktop Audio settings toggle; read by the desktop # renderer to gate its saved-chain restore. Inert on the pure-web build, # which has no native amp sims. "use_amp_sims": False, # Metadata matching (P8). `enrich_enabled` gates only the BACKGROUND # matcher — manual Fix-match/search in the review modal keeps working # when it's off (the media-server model: scraper off ≠ no manual fix); # the FEEDBACK_ENRICH_OFFLINE env var is the hard everything-off kill. # `enrich_auto_threshold` is the auto-apply confidence — matches at or # above it canonicalize automatically, below it queue for review. The # per-field floors in lib/mb_match.py always apply on top, so lowering # this can't make a wrong-artist cover auto-match. >1.0 (the "Always # review" option) sends every text match to review. "enrich_enabled": True, "enrich_auto_threshold": 0.9, # Scraper options (R1). Two axes, media-server style: sources say WHO # may be contacted (MusicBrainz = the matcher, Cover Art Archive = the # art fetch); the apply toggles say WHICH fields an AUTOMATIC match may # canonicalize. A match the user confirms in the review modal always # applies in full — these gate only what happens without them. All of # it is display-side cache; nothing here ever writes to a pack file. "enrich_src_musicbrainz": True, "enrich_src_caa": True, "enrich_apply_names": True, "enrich_apply_year": True, "enrich_apply_genres": True, "enrich_apply_art": True, # Review-queue ordering: missing_first = charts lacking album/year # surface first (they gain the most), artist = A–Z, recent = newest # files first. "enrich_review_order": "missing_first", # Artist pages (PR-B). The page itself is 100% local (renders from # your own library rows), so it defaults ON; the external-links row # (official site / tour dates / videos / social, one throttled # MusicBrainz artist lookup per matched artist) is opt-IN — default # OFF per the dev-chat thread. Links are links-only forever: always # the external browser, never media delivered in-app. "artist_pages_enabled": True, "artist_external_links": False, } def _load_config(config_file): """Read and parse config.json. Returns the parsed dict, or None if the file is missing, unreadable, invalid JSON, or parses to a non-dict (e.g. the file contains `[]` or `42`). Callers treat None as "fall back to defaults". Shared between GET and POST so both handle bad files the same way.""" if not config_file.exists(): return None try: # Explicit UTF-8: save_settings()/import write config.json as # UTF-8 bytes, so the read must not depend on the platform's # default text encoding (cp1252 on Windows would mojibake or # UnicodeDecodeError on a non-ASCII DLC path). parsed = json.loads(config_file.read_text(encoding="utf-8")) except Exception: return None return parsed if isinstance(parsed, dict) else None @app.get("/api/tunings") def get_tunings(): cfg = _load_config(CONFIG_DIR / "config.json") or {} ref = cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH) try: ref = float(ref) if not (430.0 <= ref <= 450.0): ref = DEFAULT_REFERENCE_PITCH except (TypeError, ValueError): ref = DEFAULT_REFERENCE_PITCH return {"referencePitch": ref, "tunings": tuning_providers.get_merged(ref)} @app.get("/api/settings") def get_settings(): cfg = _load_config(CONFIG_DIR / "config.json") return cfg if cfg is not None else _default_settings() @app.post("/api/settings") def save_settings(data: dict): # Partial-update: merge only keys present in the request body so # single-key POSTs (like the difficulty slider's oninput) don't # clobber unrelated settings on disk. # # Validation runs FIRST, outside _settings_lock. The dlc_dir branch # stats the folder and counts sloppak files, which can be slow on a # large or networked DLC dir — holding the lock across it would block # every other settings writer (dropdown/slider autosaves, imports). # So validation only resolves `updates` (the keys to merge); the # short read-merge-write critical section at the end takes the lock. config_file = CONFIG_DIR / "config.json" updates: dict = {} messages: list[str] = [] if "dlc_dir" in data: dlc_path = data["dlc_dir"] # null / missing is no-op (preserve on-disk value). Only an # explicit empty string means "clear". Non-string values are # rejected so Path(...) can't be surprised by non-str JSON. if dlc_path is None: pass elif not isinstance(dlc_path, str): return {"error": "dlc_dir must be a string path or empty"} elif dlc_path == "": updates["dlc_dir"] = "" else: if Path(dlc_path).is_dir(): updates["dlc_dir"] = dlc_path count = sum(1 for f in Path(dlc_path).iterdir() if f.suffix.lower() in sloppak_mod.SONG_EXTS) messages.append(f"DLC folder: {count} song files found") else: return {"error": f"DLC directory not found: {dlc_path}"} # Both of these are consumed downstream as strings (e.g. # demucs_server_url.rstrip('/')), so reject non-string shapes # here. Matches the dlc_dir pattern above: # null is no-op, empty string clears, non-string is a structured # error that preserves the on-disk value. for key in ("default_arrangement", "demucs_server_url"): if key in data: raw = data[key] if raw is None: pass elif not isinstance(raw, str): return {"error": f"{key} must be a string or empty"} else: updates[key] = raw if "master_difficulty" in data: # Coerce defensively — public endpoint, so `null`, `""`, or a # non-numeric string shouldn't 500 the request. float() accepts # both integer and float-shaped strings; anything else returns # a structured error like the dlc_dir branch above. raw = data["master_difficulty"] # Reject bool explicitly: Python makes bool a subclass of int, so # True/False would otherwise coerce to 1/0 and persist as a valid # difficulty. Caller almost certainly means "bad input". if isinstance(raw, bool): return {"error": "master_difficulty must be a number between 0 and 100"} try: updates["master_difficulty"] = max(0, min(100, int(float(raw)))) except (TypeError, ValueError, OverflowError): # OverflowError covers int(float("inf")) / int(float("1e309")) # which Python raises distinctly from ValueError. return {"error": "master_difficulty must be a number between 0 and 100"} if "av_offset_ms" in data: # Audio-output pipeline latency compensation. Positive values # mean audio is running ahead of visuals; the highway adds # this to its render clock to catch the visuals up. Clamped # to ±1000 ms to mirror the client-side slider — a direct # POST shouldn't be able to persist `1e9`. Same defensive # coercion shape as master_difficulty above (reject bool, # cover OverflowError, structured 4xx-style return on bad # input rather than 500). raw = data["av_offset_ms"] if isinstance(raw, bool): return {"error": "av_offset_ms must be a number between -1000 and 1000"} try: updates["av_offset_ms"] = max(-1000.0, min(1000.0, float(raw))) except (TypeError, ValueError, OverflowError): return {"error": "av_offset_ms must be a number between -1000 and 1000"} # fee[dB]ack v0.3.0 gameplay settings (tabbed settings page). null is a # no-op per the merge contract; bad shapes return a structured error # rather than 500. countdown_before_song is consumed by the song-start # count-in; miss_penalty / fail_behavior are persisted-only stubs. if "countdown_before_song" in data: raw = data["countdown_before_song"] if raw is not None: if not isinstance(raw, bool): return {"error": "countdown_before_song must be a boolean"} updates["countdown_before_song"] = raw if "achievements_enabled" in data: raw = data["achievements_enabled"] if raw is not None: if not isinstance(raw, bool): return {"error": "achievements_enabled must be a boolean"} updates["achievements_enabled"] = raw if "use_amp_sims" in data: raw = data["use_amp_sims"] if raw is not None: if not isinstance(raw, bool): return {"error": "use_amp_sims must be a boolean"} updates["use_amp_sims"] = raw if "enrich_enabled" in data: raw = data["enrich_enabled"] if raw is not None: if not isinstance(raw, bool): return {"error": "enrich_enabled must be a boolean"} updates["enrich_enabled"] = raw if "enrich_auto_threshold" in data: # Auto-apply confidence for the metadata matcher. 0.5–1.0 are real # thresholds; values just above 1.0 are the "Always review" option (a # capped score can equal exactly 1.0, so "never auto" must sit above # the cap). Same defensive coercion shape as av_offset_ms. raw = data["enrich_auto_threshold"] if raw is not None: if isinstance(raw, bool): return {"error": "enrich_auto_threshold must be a number between 0.5 and 1.01"} try: t = float(raw) except (TypeError, ValueError, OverflowError): return {"error": "enrich_auto_threshold must be a number between 0.5 and 1.01"} if not math.isfinite(t) or not (0.5 <= t <= 1.01): return {"error": "enrich_auto_threshold must be a number between 0.5 and 1.01"} updates["enrich_auto_threshold"] = t for _bool_key in ("enrich_src_musicbrainz", "enrich_src_caa", "enrich_apply_names", "enrich_apply_year", "enrich_apply_genres", "enrich_apply_art", # Artist pages (PR-B): page on/off + external-links opt-in. "artist_pages_enabled", "artist_external_links"): if _bool_key in data: raw = data[_bool_key] if raw is not None: if not isinstance(raw, bool): return {"error": f"{_bool_key} must be a boolean"} updates[_bool_key] = raw if "enrich_review_order" in data: raw = data["enrich_review_order"] if raw is not None: if not isinstance(raw, str) or raw not in ("missing_first", "artist", "recent"): return {"error": "enrich_review_order must be one of missing_first, artist, recent"} updates["enrich_review_order"] = raw if "miss_penalty" in data: raw = data["miss_penalty"] if raw is not None: if not isinstance(raw, str) or raw not in ("none", "low", "medium", "high"): return {"error": "miss_penalty must be one of none, low, medium, high"} updates["miss_penalty"] = raw if "fail_behavior" in data: raw = data["fail_behavior"] if raw is not None: if not isinstance(raw, str) or raw not in ("continue", "restart", "stop"): return {"error": "fail_behavior must be one of continue, restart, stop"} updates["fail_behavior"] = raw # fee[dB]ack v0.3.0 — tuner reference pitch + instrument selection. # These drive the topbar tuner/instrument badges and (when installed) the # note_detect scoring tuning tables. null is a no-op per the merge contract. if "reference_pitch" in data: raw = data["reference_pitch"] if raw is not None: if isinstance(raw, bool): return {"error": "reference_pitch must be a number between 430 and 450"} try: rp = float(raw) except (TypeError, ValueError, OverflowError): return {"error": "reference_pitch must be a number between 430 and 450"} # Reject non-finite rather than letting min/max silently clamp # NaN/Inf (and "nan"/"inf") to 430/450. if not math.isfinite(rp): return {"error": "reference_pitch must be a number between 430 and 450"} updates["reference_pitch"] = max(430.0, min(450.0, rp)) if "instrument" in data: raw = data["instrument"] if raw is not None: if not isinstance(raw, str) or raw not in ("guitar", "bass"): return {"error": "instrument must be 'guitar' or 'bass'"} updates["instrument"] = raw if "string_count" in data: raw = data["string_count"] if raw is not None: try: sc = _as_int(raw) # rejects bool / non-integral (4.9) / inf except (TypeError, ValueError, OverflowError): return {"error": "string_count must be an integer 4–8"} if sc < 4 or sc > 8: return {"error": "string_count must be an integer 4–8"} updates["string_count"] = sc if "tuning" in data: raw = data["tuning"] # Accept a tuning NAME (string ≤64) or a list of up to 8 semitone # offsets (ints −12..12). null is a no-op. if raw is not None: if isinstance(raw, str): if len(raw) > 64: return {"error": "tuning name too long"} updates["tuning"] = raw elif isinstance(raw, list): if len(raw) > 8 or any(isinstance(o, bool) or not isinstance(o, int) or o < -12 or o > 12 for o in raw): return {"error": "tuning offsets must be ≤8 integers between -12 and 12"} updates["tuning"] = raw else: return {"error": "tuning must be a name (string) or a list of semitone offsets"} CONFIG_DIR.mkdir(parents=True, exist_ok=True) # Critical section — the read-merge-write must be atomic. FastAPI runs # sync handlers in a threadpool, so two concurrent partial POSTs (e.g. # the two Settings dropdowns auto-saving back-to-back) could each read # the pre-write file and the second write would silently drop the # first's key. /api/settings/import shares _settings_lock for the same # reason. The seed-from-_default_settings() guards a missing/unreadable # /non-dict config.json so the merge can't TypeError and 500 the # endpoint. The write is atomic temp+rename so a concurrent reader # (export, get_settings, the _get_dlc_dir fallback) never sees a torn # file. with _settings_lock: cfg = _load_config(config_file) if cfg is None: cfg = _default_settings() cfg.update(updates) _atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8")) return {"message": ". ".join(messages) if messages else "Settings saved"} # Keys a client "Reset {category}" action may clear. Resetting removes the key # from config.json so the next GET falls back to the _default_settings() value # (or the frontend's own default when the key is then absent). Restricting to a # known set means a malformed or hostile body can't wipe unrelated config. _RESETTABLE_SETTINGS_KEYS = frozenset({ "default_arrangement", "demucs_server_url", "master_difficulty", "av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior", "reference_pitch", "instrument", "string_count", "tuning", "achievements_enabled", "use_amp_sims", }) @app.post("/api/settings/reset") def reset_settings(data: dict): """Clear the given settings keys back to their defaults — backs the per-category "Reset" buttons on the tabbed settings page. Unknown keys are ignored (not an error) so a newer client asking to reset a key an older server doesn't recognise degrades gracefully. Shares _settings_lock with save_settings()/import for the same read-merge-write atomicity reason.""" raw_keys = data.get("keys") if not isinstance(raw_keys, list): return {"error": "keys must be a list of setting names"} keys = [k for k in raw_keys if isinstance(k, str) and k in _RESETTABLE_SETTINGS_KEYS] config_file = CONFIG_DIR / "config.json" with _settings_lock: cfg = _load_config(config_file) if cfg is None: # Nothing persisted yet — already at defaults. return {"message": "Settings reset", "reset": []} removed = [k for k in keys if k in cfg] for k in removed: del cfg[k] _atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8")) return {"message": "Settings reset", "reset": removed} # ── Settings export/import (feedBack#113) ─────────────────────────────────── # Bumped only when the bundle JSON shape changes incompatibly. Importer # refuses anything but this exact value — version mismatches are warned # but not blocked, schema mismatches ARE blocked. SETTINGS_BUNDLE_SCHEMA = 1 def _running_version() -> str: """Same lookup chain `/api/version` uses, factored out so the export bundle records what shipped this file. Kept as a helper so future changes (e.g. baked-in version) only have to touch one site.""" env_version = os.environ.get("APP_VERSION", "").strip() if env_version: return env_version version_file = Path(__file__).parent / "VERSION" if version_file.exists(): try: return version_file.read_text().strip() except (OSError, UnicodeDecodeError): pass return "unknown" def _validate_server_config_types(cfg: dict) -> str | None: """Type-and-range gate for the server_config block of an import bundle, mirroring the per-key checks in `POST /api/settings`. The importer writes config.json verbatim, so without this gate a hand-edited bundle could persist a non-string `demucs_server_url` (which downstream code calls `.rstrip('/')` on and crashes) or an out-of-range `master_difficulty` (which bypasses the slider's clamp). Returns None on success, an error string on the first violation. Filesystem-existence checks (e.g. dlc_dir is_dir) are NOT performed here — restoring a bundle on a different machine legitimately may reference paths that don't exist locally yet, and the `POST /api/settings` interactive endpoint is the right place for that ergonomic check, not the bulk-restore path. Unknown keys are passed through so future settings (and per-plugin keys that may be added later) round-trip without code changes here.""" if "dlc_dir" in cfg: v = cfg["dlc_dir"] if v is not None and not isinstance(v, str): return "server_config.dlc_dir must be a string" for key in ("default_arrangement", "demucs_server_url"): if key in cfg: v = cfg[key] if v is not None and not isinstance(v, str): return f"server_config.{key} must be a string" if "master_difficulty" in cfg: v = cfg["master_difficulty"] # bool is an int subclass — reject explicitly so True/False # don't quietly persist as 1/0 difficulty values. if isinstance(v, bool) or not isinstance(v, (int, float)): return "server_config.master_difficulty must be a number between 0 and 100" if not (0 <= v <= 100): return "server_config.master_difficulty must be between 0 and 100" if "av_offset_ms" in cfg: v = cfg["av_offset_ms"] if isinstance(v, bool) or not isinstance(v, (int, float)): return "server_config.av_offset_ms must be a number between -1000 and 1000" if not (-1000 <= v <= 1000): return "server_config.av_offset_ms must be between -1000 and 1000" # fee[dB]ack v0.3.0 tuner/instrument keys — keep in sync with POST /api/settings. if "reference_pitch" in cfg: v = cfg["reference_pitch"] if v is not None and (isinstance(v, bool) or not isinstance(v, (int, float)) or not (430 <= v <= 450)): return "server_config.reference_pitch must be a number between 430 and 450" if "instrument" in cfg: v = cfg["instrument"] if v is not None and v not in ("guitar", "bass"): return "server_config.instrument must be 'guitar' or 'bass'" if "string_count" in cfg: v = cfg["string_count"] if v is not None and (isinstance(v, bool) or not isinstance(v, int) or not (4 <= v <= 8)): return "server_config.string_count must be an integer between 4 and 8" if "tuning" in cfg: v = cfg["tuning"] if v is not None: if isinstance(v, str): if len(v) > 64: return "server_config.tuning name too long" elif isinstance(v, list): if len(v) > 8 or any(isinstance(o, bool) or not isinstance(o, int) or o < -12 or o > 12 for o in v): return "server_config.tuning offsets must be ≤8 integers between -12 and 12" else: return "server_config.tuning must be a name (string) or a list of semitone offsets" return None class _UndeclaredFile(ValueError): """Raised when a relpath would otherwise be safe but isn't covered by the plugin's manifest allowlist. Distinct from the generic `ValueError` so the import handler can warn-and-skip this case without resorting to message-string matching (which would silently change behavior on a future error-text refactor).""" def _matches_allowlist(relpath: str, allowed: list[str]) -> bool: """Return True if `relpath` is covered by an entry in the manifest's `_export_paths`. Entries ending in `/` are directory rules (strict prefix-match); other entries are exact-file rules. Both `relpath` and `allowed` are POSIX strings already normalized through `_normalize_export_paths` on the loader side. Caller is expected to pass an already-normalized relpath — `_validate_relpath` enforces this so a bundle can't satisfy a prefix rule with a string that later normalizes to a different target.""" for allow in allowed: if allow.endswith("/"): # Strict prefix match only. We deliberately reject # `relpath == prefix.rstrip("/")` — a directory entry # never authorizes writing AT the directory itself, and # accepting that would let phase 2 try to `os.replace()` # over an existing directory and crash mid-apply. if relpath.startswith(allow): return True elif relpath == allow: return True return False def _validate_relpath(relpath: str, allowed: list[str], config_dir: Path) -> Path: """Resolve `relpath` to an absolute path under `config_dir`, raising on anything that smells like path-traversal, an absolute path, or a manifest-undeclared file. Layered defenses: 1. String-level: reject backslash, drive letter, absolute, and any `.` / `..` segment in the *raw* input — BEFORE any normalization. Critically, this catches the `allowed_dir/../config.json` shape: the raw string starts with `allowed_dir/`, so a naive prefix-match would accept it; if we then normalized first, the `..` would collapse away and the segment guard would have nothing to reject. By refusing pre-normalization any input containing a `.` or `..` segment, we make it impossible for a normalize-then- resolve pass to "launder" a hostile prefix into a different target. 2. Allowlist match against the now-known-clean relpath. Allowlist-miss raises `_UndeclaredFile` (a `ValueError` subclass) so the caller can distinguish "manifest changed between export and import" from "this looks like an attack" without string-matching the error message. 3. Realpath check: after resolving under config_dir, the target must still live inside config_dir. This catches symlinks- under-config_dir attacks where someone planted a symlink pointing out and tried to import a file "under" it. 4. Symlink rejection: even when a symlink (or symlinked directory component) resolves to a path that *still* lives inside config_dir, importing through it would let an allowlisted relpath redirect the write to a different in-config file — bypassing the manifest's intent. We probe every path component from `config_dir` down to the target using `lstat`, refusing if any link is set on the chain. This matches the documented "symlinks are never followed on import" guarantee. Returns the resolved absolute path (caller writes there in phase 2). """ if not isinstance(relpath, str) or not relpath or relpath != relpath.strip(): raise ValueError(f"illegal relpath: {relpath!r}") # Reject backslashes outright — manifest entries are POSIX, and # accepting `foo\bar` here on a platform whose Path treats `\` as # a separator would let a hostile bundle smuggle traversal past # the part-by-part check below. if "\\" in relpath: raise ValueError(f"relpath uses non-POSIX separator: {relpath!r}") # Absolute / drive-letter check before splitting. if relpath.startswith("/") or (len(relpath) >= 2 and relpath[1] == ":"): raise ValueError(f"relpath must be relative: {relpath!r}") raw_parts = relpath.split("/") # Empty parts catch `foo//bar` and a trailing `/`. `.` / `..` catch # both leading and embedded forms (`./x`, `a/./b`, `allow/../escape`). if any(part in ("", ".", "..") for part in raw_parts): raise ValueError(f"relpath contains illegal segment: {relpath!r}") # Defense-in-depth: any leading `.` segment (e.g. dotfile-disguised # paths like `.git/config`) is also rejected — config_dir isn't a # place plugins should be writing dotfiles, and accepting them here # would let one plugin claim a global filename like `.npmrc`. if raw_parts[0].startswith("."): raise ValueError(f"relpath starts with dotfile segment: {relpath!r}") if not _matches_allowlist(relpath, allowed): raise _UndeclaredFile( f"relpath not declared in plugin manifest: {relpath!r}" ) target = (config_dir / relpath).resolve() config_root = config_dir.resolve() # `target == config_root` would mean the relpath resolved to the # config dir itself, which can't be a file write target — reject. if target == config_root: raise ValueError(f"relpath resolves to config_dir itself: {relpath!r}") if config_root not in target.parents: raise ValueError(f"relpath escapes config_dir: {relpath!r}") # Walk every component from config_dir down to (but not including) # the target file, refusing if any is a symlink. The target itself # is checked too — a symlinked file inside config_dir could still # redirect the write to another in-config file, defeating the # manifest's allowlist intent. `lstat` is the right primitive: it # reports the link itself rather than the link's destination, so a # broken or self-referential symlink won't slip through. Missing # intermediate dirs are fine — `_atomic_write_file` mkdirs them # under config_dir, and a path that doesn't exist yet trivially # isn't a symlink. probe = config_dir for part in relpath.split("/"): probe = probe / part try: st = os.lstat(probe) except FileNotFoundError: # Component doesn't exist yet → can't be a symlink. Any # remaining components also don't exist, so we're done. break import stat as _stat if _stat.S_ISLNK(st.st_mode): raise ValueError( f"relpath traverses or targets a symlink: {relpath!r}" ) return target def _encode_file(abs_path: Path) -> dict: """Encode a single file for the export bundle. JSON files that parse cleanly use the `json` encoding so the bundle stays diff-friendly; everything else (sqlite, NAM models, IRs, binary blobs) falls back to base64. Symlinks are skipped at the caller — we never reach this helper for them.""" import base64 raw = abs_path.read_bytes() if abs_path.suffix.lower() == ".json": try: return {"encoding": "json", "data": json.loads(raw.decode("utf-8"))} except (UnicodeDecodeError, json.JSONDecodeError): # Fall through to base64 — file claimed `.json` but isn't # valid JSON; preserve bytes verbatim rather than refusing. pass return {"encoding": "base64", "data": base64.b64encode(raw).decode("ascii")} def _decode_entry(entry: dict) -> bytes: """Inverse of `_encode_file`. Raises ValueError on malformed entries so phase 1 of the importer can refuse the whole bundle without having written anything.""" import base64 if not isinstance(entry, dict): raise ValueError(f"file entry must be an object, got {type(entry).__name__}") encoding = entry.get("encoding") data = entry.get("data") if encoding == "base64": if not isinstance(data, str): raise ValueError("base64 entry: 'data' must be a string") try: return base64.b64decode(data, validate=True) except Exception as e: raise ValueError(f"base64 entry: invalid payload ({e})") if encoding == "json": # We re-serialize the parsed value with stable formatting. Round # trips with the original byte stream aren't guaranteed (key # order, whitespace), but the file's *meaning* is preserved. try: return json.dumps(data, indent=2).encode("utf-8") except (TypeError, ValueError) as e: raise ValueError(f"json entry: cannot re-serialize ({e})") raise ValueError(f"unknown encoding: {encoding!r}") def _walk_export_paths(allowed: list[str], config_dir: Path) -> dict: """Expand a plugin's `_export_paths` against disk and return a `{relpath: encoded_entry}` dict. Missing files are silently skipped (intentional — manifests can list optional files). Symlinks are skipped with no entry. Directories are walked recursively; their contained files surface as POSIX-joined relpaths. Symlink policy is "skipped and never followed" at every depth: `os.walk(..., followlinks=False)` ensures we don't *recurse* into symlinked subdirectories, but we additionally drop any symlinked entry from `dirnames` (so its name isn't even reported to the caller, even though the walker wouldn't descend) and skip files whose path is itself a symlink. Without those extra filters, a planted symlink directory under an allowed prefix could leak data from outside `config_dir` into the export bundle. """ out: dict[str, dict] = {} for entry in allowed: is_dir = entry.endswith("/") rel = entry.rstrip("/") abs_target = config_dir / rel if abs_target.is_symlink(): continue if is_dir: if not abs_target.is_dir(): continue collected: list[Path] = [] for dirpath, dirnames, filenames in os.walk( str(abs_target), followlinks=False ): # Strip symlinked subdirs from `dirnames` in-place so # the walker neither yields their names nor descends. dirnames[:] = [ d for d in dirnames if not os.path.islink(os.path.join(dirpath, d)) ] for fname in filenames: full = os.path.join(dirpath, fname) if os.path.islink(full) or not os.path.isfile(full): continue collected.append(Path(full)) # Sort for deterministic bundle output (test fixtures and # diffs both rely on stable ordering). for child in sorted(collected): # POSIX-joined relpath relative to config_dir keeps the # bundle cross-platform — Windows-authored bundles can # be applied on Linux and vice versa. child_rel = child.relative_to(config_dir).as_posix() out[child_rel] = _encode_file(child) else: if not abs_target.is_file(): continue out[rel] = _encode_file(abs_target) return out def _atomic_write_file(target: Path, payload: bytes): """Write `payload` to `target` via a uniquely-named sibling temp file + os.replace. `os.replace` is atomic on both POSIX and Win32 — readers see either the old file or the new one, never a half-written state. The temp name is generated by `tempfile.mkstemp` so two concurrent imports (or two workers sharing the same config volume) can't race on the same `.tmp.import` path and clobber each other's in-flight writes. On any failure between mkstemp and the successful `os.replace`, we remove the temp file so a failed import doesn't leave `.tmp.import` litter under config_dir.""" target.parent.mkdir(parents=True, exist_ok=True) fd, tmp_name = tempfile.mkstemp( dir=str(target.parent), prefix=target.name + ".", suffix=".tmp.import", ) tmp = Path(tmp_name) # Hand fd to os.fdopen inside its own try, so a failure to wrap # the descriptor (rare — typically EMFILE / ENOMEM) doesn't leak # the raw fd. On Windows an open fd would also keep the temp file # locked and undeletable. Once `with` enters, the fdopen'd file # owns close responsibility. try: f = os.fdopen(fd, "wb") except Exception: os.close(fd) try: tmp.unlink() except OSError: pass raise try: with f: f.write(payload) os.replace(tmp, target) except Exception: try: tmp.unlink() except OSError: pass raise # Core (non-plugin) server-side state that the settings bundle backs up # alongside config.json. The library DB is the only state a rescan can't # rebuild (scores, favorites, playlists, play history); the art dirs hold # custom playlist covers + the user avatar. `web_library.db` is handled # specially (consistent snapshot on export, staged restore on import) — the # art dirs are walked like plugin export paths. NOTE: custom uploaded # *song* art currently lands in `art_cache/` commingled with the derived # (rebuildable) cache, so it is intentionally NOT bundled here to avoid # bloating the backup with regenerable thumbnails — splitting custom song # art into its own dir is a tracked follow-up (got-feedback/feedBack#636). _CORE_LIBRARY_DB = "web_library.db" _CORE_EXPORT_ART_DIRS = ("playlist_covers/", "avatars/") _CORE_IMPORT_ALLOWED = (_CORE_LIBRARY_DB,) + _CORE_EXPORT_ART_DIRS def _snapshot_library_db() -> dict | None: """A consistent, fully-checkpointed single-file copy of the live library DB, base64-encoded for the bundle. Uses the SQLite online-backup API so it is safe to call while the server is serving requests; the live write lock is held for the copy so no write lands mid-snapshot. Returns None if the DB or backup is unavailable (export proceeds without it).""" import base64 fd, tmp = tempfile.mkstemp(dir=str(CONFIG_DIR), prefix="._dbsnap.", suffix=".db") os.close(fd) try: dst = sqlite3.connect(tmp) try: with meta_db._lock: meta_db.conn.backup(dst) finally: dst.close() raw = Path(tmp).read_bytes() except (sqlite3.Error, OSError): log.warning("library DB snapshot for settings export failed", exc_info=True) return None finally: for suffix in ("", "-wal", "-shm"): try: Path(tmp + suffix).unlink() except FileNotFoundError: pass return {"encoding": "base64", "data": base64.b64encode(raw).decode("ascii")} def _sqlite_payload_integrity_ok(payload: bytes) -> bool: """Validate decoded DB bytes by materializing them to a temp file and running the same integrity probe used at restore time — so a corrupt or truncated snapshot is refused at import, before it's ever staged.""" fd, tmp = tempfile.mkstemp(dir=str(CONFIG_DIR), prefix="._dbcheck.", suffix=".db") try: with os.fdopen(fd, "wb") as f: f.write(payload) return _sqlite_file_integrity_ok(Path(tmp)) except OSError: return False finally: try: Path(tmp).unlink() except FileNotFoundError: pass def _core_server_files() -> dict | None: """`{relpath: encoded_entry}` for core server-side state in the bundle: a snapshot of the library DB plus any custom playlist covers / avatar. Returns None if the DB snapshot could not be produced — the caller must treat that as a hard export failure rather than silently shipping a backup that's missing the irreplaceable library state.""" snap = _snapshot_library_db() if snap is None: return None out: dict[str, dict] = dict(_walk_export_paths(list(_CORE_EXPORT_ART_DIRS), CONFIG_DIR)) out[_CORE_LIBRARY_DB] = snap return out @app.get("/api/settings/export") def export_settings(): """Build a settings bundle covering server config + opted-in plugin server-side files. Frontend layers in `local_storage` before triggering the download. See feedBack#113.""" import datetime from plugins import LOADED_PLUGINS, PLUGINS_LOCK config_file = CONFIG_DIR / "config.json" server_config = _load_config(config_file) if server_config is None: server_config = _default_settings() # Snapshot the library DB + custom art FIRST: if the irreplaceable state # can't be captured, abort with an error rather than hand back a bundle # that looks like a backup but silently omits it. core_files = _core_server_files() if core_files is None: return JSONResponse( {"ok": False, "error": "could not snapshot the library database; " "export aborted to avoid an incomplete backup"}, status_code=500, ) plugin_blocks: dict[str, dict] = {} with PLUGINS_LOCK: plugins_snapshot = list(LOADED_PLUGINS) for p in plugins_snapshot: allowed = p.get("_export_paths") or [] plugin_blocks[p["id"]] = {"files": _walk_export_paths(allowed, CONFIG_DIR)} # Capture the timestamp once so the bundle's `exported_at` and the # download filename's date prefix can't disagree if the request # crosses midnight UTC between the two formats. now = datetime.datetime.now(datetime.timezone.utc) bundle = { "schema": SETTINGS_BUNDLE_SCHEMA, "exported_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "feedBack_version": _running_version(), "server_config": server_config, "plugin_server_configs": plugin_blocks, "core_server_files": core_files, } filename = f"feedBack-settings-{now.strftime('%Y-%m-%d')}.json" return JSONResponse( bundle, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @app.post("/api/settings/import") def import_settings(bundle: dict): """Apply a previously exported settings bundle. Validates the entire bundle in phase 1 (no disk writes); only on full success does phase 2 commit each file via temp+rename. The frontend reads `local_storage` itself — server ignores it. See feedBack#113.""" from plugins import LOADED_PLUGINS, PLUGINS_LOCK if not isinstance(bundle, dict): return JSONResponse({"ok": False, "error": "bundle must be a JSON object"}, status_code=400) # ── Phase 1: validate everything before touching disk ──────────── schema = bundle.get("schema") if schema != SETTINGS_BUNDLE_SCHEMA: return JSONResponse( { "ok": False, "error": f"unsupported schema {schema!r}; this server speaks schema {SETTINGS_BUNDLE_SCHEMA}", }, status_code=400, ) server_config = bundle.get("server_config") if not isinstance(server_config, dict): return JSONResponse( {"ok": False, "error": "server_config must be an object"}, status_code=400, ) cfg_err = _validate_server_config_types(server_config) if cfg_err is not None: return JSONResponse( {"ok": False, "error": cfg_err}, status_code=400, ) plugin_blocks = bundle.get("plugin_server_configs") or {} if not isinstance(plugin_blocks, dict): return JSONResponse( {"ok": False, "error": "plugin_server_configs must be an object"}, status_code=400, ) warnings: list[str] = [] bundle_version = bundle.get("feedBack_version") running = _running_version() if bundle_version and bundle_version != running: warnings.append( f"version mismatch: bundle {bundle_version!r} vs running {running!r}; importing anyway" ) with PLUGINS_LOCK: by_id = {p["id"]: p for p in LOADED_PLUGINS} # Stage every (display_relpath, target_abs_path, payload) tuple before # writing. The relpath is what we surface in the `partial` field on a # mid-apply failure — absolute paths would leak the deployment's # config_dir layout, while the relpath is the same identifier the # bundle itself used and is portable across machines. staged: list[tuple[str, Path, bytes]] = [] applied_plugins: list[str] = [] for plugin_id, block in plugin_blocks.items(): if not isinstance(plugin_id, str) or not plugin_id: return JSONResponse( {"ok": False, "error": f"invalid plugin id key: {plugin_id!r}"}, status_code=400, ) plugin = by_id.get(plugin_id) if plugin is None: warnings.append(f"plugin {plugin_id!r} not loaded; skipping its files") continue if not isinstance(block, dict): return JSONResponse( {"ok": False, "error": f"plugin {plugin_id!r}: block must be an object"}, status_code=400, ) files = block.get("files") or {} if not isinstance(files, dict): return JSONResponse( {"ok": False, "error": f"plugin {plugin_id!r}: files must be an object"}, status_code=400, ) allowed = plugin.get("_export_paths") or [] skipped_for_plugin: list[str] = [] applied_for_plugin = False for relpath, file_entry in files.items(): try: target = _validate_relpath(relpath, allowed, CONFIG_DIR) except _UndeclaredFile: # Manifest-allowlist miss is a normal outcome of a # plugin update between export and import — warn-and- # skip so the rest of the bundle still applies. skipped_for_plugin.append(relpath) continue except ValueError as e: # Path-traversal / absolute-path / illegal-segment / # backslash / dotfile errors are hard failures: we # never want to apply a bundle that contains those, # even partially. Caught AFTER `_UndeclaredFile` # because that's a `ValueError` subclass — Python # would otherwise route it through this branch. return JSONResponse( { "ok": False, "error": f"plugin {plugin_id!r}, file {relpath!r}: {e}", }, status_code=400, ) try: payload = _decode_entry(file_entry) except ValueError as e: return JSONResponse( { "ok": False, "error": f"plugin {plugin_id!r}, file {relpath!r}: {e}", }, status_code=400, ) # Display key prefixes the plugin id so a partial-failure # report is unambiguous when two plugins happen to declare # files with the same relpath. display = f"{plugin_id}/{relpath}" staged.append((display, target, payload)) applied_for_plugin = True if skipped_for_plugin: warnings.append( f"plugin {plugin_id!r}: skipped {len(skipped_for_plugin)} file(s) " f"no longer declared in manifest: {skipped_for_plugin}" ) if applied_for_plugin: applied_plugins.append(plugin_id) # ── Core server-side files (library DB + custom art) ───────────── core_blocks = bundle.get("core_server_files") or {} if not isinstance(core_blocks, dict): return JSONResponse( {"ok": False, "error": "core_server_files must be an object"}, status_code=400, ) db_restore_staged = False applied_core: list[str] = [] for relpath, file_entry in core_blocks.items(): if not isinstance(relpath, str) or not relpath: return JSONResponse( {"ok": False, "error": f"core_server_files: invalid relpath key {relpath!r}"}, status_code=400, ) if relpath == _CORE_LIBRARY_DB: # Stage the DB beside the live one; the swap happens at next # startup (_apply_pending_db_restore), so we never overwrite a DB # the server holds open or strand a stale WAL against a fresh file. target = CONFIG_DIR / (_CORE_LIBRARY_DB + ".restore") db_restore_staged = True else: try: target = _validate_relpath(relpath, list(_CORE_IMPORT_ALLOWED), CONFIG_DIR) except _UndeclaredFile: warnings.append(f"core_server_files: skipped undeclared path {relpath!r}") continue except ValueError as e: return JSONResponse( {"ok": False, "error": f"core_server_files, file {relpath!r}: {e}"}, status_code=400, ) try: payload = _decode_entry(file_entry) except ValueError as e: return JSONResponse( {"ok": False, "error": f"core_server_files, file {relpath!r}: {e}"}, status_code=400, ) # Guard the DB payload: a truncated/corrupt file staged as the restore # would fail to open at startup and brick the app (after the live DB # is already gone). Reject anything that doesn't open + pass # quick_check before it's ever staged. if relpath == _CORE_LIBRARY_DB and not _sqlite_payload_integrity_ok(payload): return JSONResponse( {"ok": False, "error": "core_server_files: web_library.db is not a valid SQLite database"}, status_code=400, ) staged.append((f"core/{relpath}", target, payload)) applied_core.append(relpath) if db_restore_staged: warnings.append( "library database restored; restart FeedBack to load it " "(scores, favorites, playlists, and play history)" ) # ── Phase 2: commit ────────────────────────────────────────────── written: list[str] = [] try: for display, target, payload in staged: _atomic_write_file(target, payload) written.append(display) # Server config last so a write failure on a plugin file # doesn't leave config.json mismatched against the (untouched) # plugin state. Full-replace: caller is responsible for the # whole dict — this is restore semantics, not partial-update. CONFIG_DIR.mkdir(parents=True, exist_ok=True) # Share _settings_lock with save_settings() so a full-replace # import and a concurrent partial-update POST can't interleave # on config.json and drop each other's write. with _settings_lock: _atomic_write_file( CONFIG_DIR / "config.json", json.dumps(server_config, indent=2).encode("utf-8"), ) except OSError as e: # Phase-1 validation should have caught all foreseeable # failures; an OSError here means disk-level trouble (ENOSPC, # permission). We can't roll back already-replaced files # because we didn't snapshot them — surface what got written # (as relpaths, not absolute server paths) so the user knows # the state is partial without leaking deployment layout. # Disarm a staged DB restore THIS request wrote: a partial import must # NOT silently swap the library DB on the next restart. Gate on the # write actually having happened (display key in `written`) so we don't # delete a valid restore staged by a prior, not-yet-applied import. if f"core/{_CORE_LIBRARY_DB}" in written: try: (CONFIG_DIR / (_CORE_LIBRARY_DB + ".restore")).unlink() except FileNotFoundError: pass return JSONResponse( { "ok": False, "error": f"write failed mid-apply: {e}", "partial": written, }, status_code=500, ) return { "ok": True, "warnings": warnings, "applied": { "server_config": True, "plugins": applied_plugins, "core_files": applied_core, }, "restart_required": db_restore_staged, } # ── Diagnostic bundle export (feedBack#166) ────────────────────────── # # One-click "Export Diagnostics" in Settings produces a redacted zip # combining server logs, system info, hardware (CPU/GPU/RAM), plugin # inventory, and the browser-side console transcript + hardware probe. # The bundle format is specified in docs/diagnostics-bundle-spec.md. from fastapi import Body from diagnostics_bundle import build_bundle as _diag_build, preview_bundle as _diag_preview from diagnostics_hardware import collect as _diag_hardware def _diag_log_file() -> Path | None: raw = os.environ.get("LOG_FILE", "").strip() if not raw: return None return Path(raw) def _diag_plugins_roots() -> list[Path]: """Return all plugin root directories for orphan scanning. Includes both the built-in ``plugins/`` directory and ``FEEDBACK_PLUGINS_DIR`` when set, so user-installed plugins and orphans in the external dir are reflected in the bundle. """ roots: list[Path] = [] user_dir = getenv_compat("FEEDBACK_PLUGINS_DIR", "").strip() if user_dir: p = Path(user_dir) if p.is_dir(): roots.append(p) builtin = Path(__file__).parent / "plugins" if builtin not in roots: roots.append(builtin) return roots def _diag_coerce_bool(v, *, default: bool = True) -> bool: """Coerce a request-side value to bool, accepting both JSON booleans and string representations. - Falsy strings: ``"false"``, ``"0"``, ``"no"``, ``""`` → ``False`` - ``None`` → *default* - Everything else (including ``"true"``, ``"1"``) → ``True`` """ if v is None: return default if isinstance(v, bool): return v if isinstance(v, str): return v.strip().lower() not in ("false", "0", "no", "") return bool(v) def _diag_normalize_include(include: dict | None) -> dict: """Coerce request-side flags to the booleans build_bundle expects. Missing keys default to True so a bare {} request still produces the full bundle. Accepts both JSON booleans (``true``/``false``) and string representations so callers that serialize flags as strings behave consistently with the preview endpoint: - Falsy strings: ``"false"``, ``"0"``, ``"no"``, ``""`` → ``False`` - Everything else (including ``"true"``, ``"1"``, ``"yes"``) → ``True`` """ keys = ("system", "hardware", "logs", "console", "plugins") if not isinstance(include, dict): return {k: True for k in keys} return {k: _diag_coerce_bool(include.get(k), default=True) for k in keys} # Server-side caps on client-supplied payload sections. diagnostics.js # enforces a 500-entry / ~250 KB ring buffer on the browser side; these # bounds give generous headroom while still preventing a crafted POST from # forcing the server to allocate arbitrarily large in-memory bundles. _DIAG_MAX_CONSOLE_ENTRIES = 1000 # hard cap: truncate silently _DIAG_MAX_CONSOLE_BYTES = 2 * 1024 * 1024 # 2 MB hard cap on total console list _DIAG_MAX_CLIENT_PAYLOAD_BYTES = 2 * 1024 * 1024 # 2 MB per dict section _DIAG_MAX_CONTRIBUTIONS_BYTES = 4 * 1024 * 1024 # 4 MB aggregate cap for contributions def _diag_cap_console(v) -> list | None: """Return *v* if it is a list, truncated to _DIAG_MAX_CONSOLE_ENTRIES entries and _DIAG_MAX_CONSOLE_BYTES total. Entries are accumulated until either cap is reached; no partial-entry splitting occurs.""" if not isinstance(v, list): return None result = v[:_DIAG_MAX_CONSOLE_ENTRIES] # Also enforce a byte cap — the count cap alone does not bound memory when # entries contain arbitrarily large strings. try: out = [] total = 0 for entry in result: encoded = json.dumps(entry, separators=(",", ":")).encode("utf-8", errors="replace") if total + len(encoded) > _DIAG_MAX_CONSOLE_BYTES: break out.append(entry) total += len(encoded) return out except (TypeError, ValueError): return None def _diag_cap_dict(v) -> dict | None: """Return *v* if it is a dict whose JSON serialisation fits within _DIAG_MAX_CLIENT_PAYLOAD_BYTES, otherwise return None.""" if not isinstance(v, dict): return None try: encoded = json.dumps(v, separators=(",", ":")).encode("utf-8", errors="replace") except (TypeError, ValueError) as e: log.warning("diagnostics client payload is not JSON-serialisable, dropping: %s", e) return None if len(encoded) > _DIAG_MAX_CLIENT_PAYLOAD_BYTES: return None return v def _diag_cap_contributions(v, known_ids=None) -> dict | None: """Apply per-plugin and aggregate size caps on client_contributions. Unlike _diag_cap_dict(), which drops the whole dict when any plugin exceeds the limit, this function caps each plugin independently so one noisy plugin does not silence every other plugin's contribution. Parameters ---------- v: The raw contributions dict from the POST payload. known_ids: When provided, contributions from plugins not in this set are skipped *before* serialisation, preventing a malicious caller from forcing the server to JSON-encode hundreds of near-limit payloads that ``build_bundle()`` would later discard anyway. ``None`` means "accept all plugin ids" (used in tests / preview). """ if not isinstance(v, dict): return None result = {} total_bytes = 0 for pid, contribution in v.items(): if not isinstance(pid, str): continue # Filter unknown plugin ids early — before serialising — so a # crafted request cannot force large allocations for plugins that # build_bundle() would drop. if known_ids is not None and pid not in known_ids: continue try: encoded = json.dumps(contribution, separators=(",", ":")).encode("utf-8", errors="replace") except (TypeError, ValueError) as e: log.warning( "client_contributions[%r] is not JSON-serialisable, dropping: %s", pid, e ) continue if len(encoded) > _DIAG_MAX_CLIENT_PAYLOAD_BYTES: log.warning( "client_contributions[%r] exceeds %d bytes, dropping", pid, _DIAG_MAX_CLIENT_PAYLOAD_BYTES, ) continue if total_bytes + len(encoded) > _DIAG_MAX_CONTRIBUTIONS_BYTES: log.warning( "client_contributions aggregate size limit (%d bytes) reached, " "dropping remaining entries", _DIAG_MAX_CONTRIBUTIONS_BYTES, ) break result[pid] = contribution total_bytes += len(encoded) return result or None @app.post("/api/diagnostics/export") def export_diagnostics(payload: dict = Body(default_factory=dict)): """Build a diagnostic bundle and stream it back as a zip download. The browser layers in `client_console`, `client_hardware`, `client_ua`, and `local_storage` before posting; the server adds server logs, hardware, plugin inventory, and packages everything into a single zip. Errors during plugin diagnostics callables are caught and logged to the bundle's manifest `notes` rather than failing the export. """ from plugins import LOADED_PLUGINS, PLUGINS_LOCK redact = _diag_coerce_bool(payload.get("redact", True), default=True) include = _diag_normalize_include(payload.get("include")) client_console = _diag_cap_console(payload.get("client_console")) client_hardware = _diag_cap_dict(payload.get("client_hardware")) client_ua = _diag_cap_dict(payload.get("client_ua")) local_storage = _diag_cap_dict(payload.get("local_storage")) # Fetch the plugin list first so we can filter contributions to known # plugin ids before serialising — prevents a crafted request from # forcing large allocations for plugins build_bundle() would drop. with PLUGINS_LOCK: plugins_snapshot = list(LOADED_PLUGINS) known_ids = {p.get("id") for p in plugins_snapshot if isinstance(p.get("id"), str)} client_contributions = _diag_cap_contributions( payload.get("client_contributions"), known_ids=known_ids ) zip_bytes, filename, _manifest = _diag_build( feedBack_version=_running_version(), config_dir=CONFIG_DIR, dlc_dir=_get_dlc_dir(), log_file=_diag_log_file(), loaded_plugins=plugins_snapshot, include=include, redact=redact, client_console=client_console, client_hardware=client_hardware, client_ua=client_ua, local_storage=local_storage, client_contributions=client_contributions, log=log, plugins_root=_diag_plugins_roots(), ) return Response( content=zip_bytes, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @app.get("/api/diagnostics/preview") def preview_diagnostics( redact: bool = True, system: bool = True, hardware: bool = True, logs: bool = True, console: bool = True, plugins: bool = True, ): """Return what `/api/diagnostics/export` would produce, minus the actual file contents — file tree, sizes, schemas, redaction counts. Lets the Settings UI show the user what's about to be sent.""" from plugins import LOADED_PLUGINS, PLUGINS_LOCK include = { "system": system, "hardware": hardware, "logs": logs, "console": console, "plugins": plugins, } with PLUGINS_LOCK: plugins_snapshot = list(LOADED_PLUGINS) return _diag_preview( feedBack_version=_running_version(), config_dir=CONFIG_DIR, dlc_dir=_get_dlc_dir(), log_file=_diag_log_file(), loaded_plugins=plugins_snapshot, include=include, redact=redact, log=log, plugins_root=_diag_plugins_roots(), ) @app.get("/api/diagnostics/hardware") def diagnostics_hardware(): """Backend hardware probe (cross-platform). Reusable independently of the bundle export — handy for "what's my GPU" plugin queries.""" return _diag_hardware() # ── Plugin-provided routes are registered at startup via plugins/__init__.py ─ # (CustomsForge, Ultimate Guitar, etc. are loaded from plugins/ directory) def _if_none_match_hits(header: str | None, etag: str) -> bool: """True if an If-None-Match header matches `etag` (weak comparison). Handles the `*` wildcard and comma-separated lists, and ignores a weak `W/` prefix on either side — the standard semantics for a conditional GET. """ if not header: return False bare = etag.removeprefix("W/") for tok in header.split(","): t = tok.strip() if t == "*" or t.removeprefix("W/") == bare: return True return False # Album art is served with a strong validator (an ETag on the sloppak byte # path; FileResponse's own ETag/Last-Modified on the file paths) and revalidated # with `no-cache`. That keeps re-scroll cheap — a conditional GET returns a # bodyless 304 — without ever serving a stale cover. A long `immutable` max-age # was rejected: the frontend's `?v=` buster is only second-resolution, so # a same-second cover rewrite would keep the URL and pin the old bytes for the # cache lifetime. Validation cost is negligible for a localhost backend. _ART_CACHE_HEADERS = {"Cache-Control": "no-cache"} def _art_etag(path: Path) -> str | None: """Strong validator for an art file: nanosecond mtime + size (so a same-second rewrite still changes it). None if the file can't be stat'd.""" try: st = path.stat() return f'"{st.st_mtime_ns}-{st.st_size}"' except OSError: return None def _art_conditional(etag: str | None, request: Request | None): """Return (headers, not_modified) for an art response. `not_modified` is True when the client's If-None-Match already matches `etag` → caller should return a bodyless 304. Starlette's FileResponse emits an ETag but does NOT itself evaluate If-None-Match, so every art path routes through here to get real conditional handling.""" headers = dict(_ART_CACHE_HEADERS) if etag: headers["ETag"] = etag inm = request.headers.get("if-none-match") if request is not None else None return headers, bool(etag) and _if_none_match_hits(inm, etag) def _file_art_response(path: Path, media_type: str, request: Request | None): """FileResponse for an on-disk art file, with no-cache + ETag and a bodyless 304 when the client's validator still matches.""" headers, not_modified = _art_conditional(_art_etag(path), request) if not_modified: return Response(status_code=304, headers=headers) return FileResponse(str(path), media_type=media_type, headers=headers) @app.get("/api/song/{filename:path}/art") async def get_song_art(filename: str, request: Request = None, source: str = ""): """Serve album art for a song, walking the R3 override chain: 1. USER OVERRIDE (upload / URL-fetch, {safe_name}.gif|.png in the art cache) — art the user explicitly pinned outranks everything, pack art included. GIF is allowed HERE only: an animated cover is a local-only bonus; packs stay jpg/png/webp and nothing ever writes art into a pack file. 2. PACK ART — sloppak cover (single member read, no full unpack) or the loose folder's discovered image. 3. COVER ART ARCHIVE cache — fetched by the enrichment art worker for matched songs that lack pack art, keyed by release MBID. `?source=pack` narrows the chain to step 2 only (no override, no CAA): the cover picker's "Pack original" tile must show the pack's own art even while a user override is what the plain route serves. 404 when the song ships no art of its own. """ dlc = _get_dlc_dir() if not dlc: return JSONResponse({"error": "not configured"}, 404) song_path = _resolve_dlc_path(dlc, filename) if song_path is None: return JSONResponse({"error": "forbidden"}, 403) if not song_path.exists(): return JSONResponse({"error": "not found"}, 404) pack_only = source == "pack" # 1. User override — GIF first (it wins over a stale PNG override). if not pack_only: for cached in _art_override_paths(filename): mt = "image/gif" if cached.suffix == ".gif" else "image/png" return _file_art_response(cached, mt, request) # 2a. Sloppak: read the cover (manifest-declared or default) straight from # the package. For a zip-form sloppak this opens just the cover member — # NOT the whole archive — so the library grid never triggers a full unpack # of stems just to paint a thumbnail. if sloppak_mod.is_sloppak(song_path): # Read the cover (cheap — single member, no full unpack) and validate by # its CONTENT. A stat-based ETag would be wrong for directory-form # sloppaks: editing cover.jpg in place changes the file's mtime, not the # directory's, so a dir-stat ETag could emit a stale 304. Content hashing # is correct for both dir- and zip-form. Raw byte Response lacks # FileResponse's validators, so we attach the ETag + honor If-None-Match. try: art = await asyncio.to_thread(sloppak_mod.read_cover_bytes, song_path) except Exception: art = None if art is not None: data, mt = art etag = f'"{hashlib.sha1(data).hexdigest()}"' headers, not_modified = _art_conditional(etag, request) if not_modified: return Response(status_code=304, headers=headers) return Response(content=data, media_type=mt, headers=headers) # 2b. Loose folder: serve the discovered art file directly. # song_path is already validated against DLC_DIR by _resolve_dlc_path. elif loosefolder_mod.is_loose_song(song_path): art_path = loosefolder_mod.find_art(song_path) if art_path: # Re-resolve in case the matched file is a symlink — a crafted # custom song could put `album_art.jpg` as a symlink to anywhere on # disk. Insist the final target stays inside the song folder. art_resolved = art_path.resolve() try: art_resolved.relative_to(song_path) except ValueError: return JSONResponse({"error": "forbidden"}, 403) if art_resolved.is_file(): mt = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", }.get(art_resolved.suffix.lower(), "image/jpeg") return _file_art_response(art_resolved, mt, request) # 3. Cover Art Archive cache (the enrichment art worker's fetch). if not pack_only: row = meta_db.get_enrichment(filename) if row and row.get("art_state") == "caa" and row.get("art_cache_path"): caa = Path(row["art_cache_path"]) if caa.is_file(): return _file_art_response(caa, "image/jpeg", request) return JSONResponse({"error": "no art"}, 404) # ── Cover picker (PR-C): candidate assembly ─────────────────────────────────── # Enumerated ON OPEN, never at scan time (charrette §8), and NO image bytes # are fetched here — Cover Art Archive release INDEX jsons only (1-3 throttled # calls on a cache miss); the tiles' thumbnails load straight from the archive # in the client. Applying a pick never grows a new write path: the client # POSTs the chosen thumb URL to the EXISTING …/art/url route (the override # lane — never evicted, survives a re-match), "Pack original" DELETEs the # override, uploads keep the existing upload route. _ART_PICKER_MAX_CAA = 12 @app.get("/api/song/{filename:path}/art/candidates") def get_song_art_candidates(filename: str): """Everything the cover picker can offer for one song, without fetching a single image: the current cover (with its provenance), the pack original when the song ships art, and CAA candidates for the matched/manual release plus any distinct releases among the stored review candidates. Sync route on purpose (the CAA index fetch sleeps in the shared throttle — FastAPI runs `def` routes in the threadpool). One response, `pending` always False — the client shows a spinner for the request's own latency; offline / CAA-down just means an empty caa tail (the instant tiles keep working), never an error.""" from urllib.parse import quote dlc = _get_dlc_dir() song_path = _resolve_dlc_path(dlc, filename) if dlc else None if song_path is None or not song_path.exists(): raise HTTPException(status_code=404, detail="unknown song") row = meta_db.get_enrichment(filename) or {} has_pack = _song_pack_art_exists(filename) art_url = f"/api/song/{quote(filename)}/art" # What the plain art route would serve right now — the serve chain's # order (override > pack > CAA cache) restated as provenance. if _art_override_paths(filename): provenance = "yours" elif has_pack: provenance = "pack" elif row.get("art_state") == "caa" and row.get("art_cache_path"): provenance = "matched" else: provenance = "none" candidates: list[dict] = [{ "id": "current", "kind": "current", "label": "Current", "thumb_url": art_url, "provenance": provenance, }] if has_pack: candidates.append({ "id": "pack", "kind": "pack", "label": "Pack original", "thumb_url": art_url + "?source=pack", "provenance": "pack", }) # Releases worth asking the archive about: the matched/manual release # first (it seeds the best candidates), then any distinct release among # the stored review candidates (a review row has no mb_release_id of its # own — its releases live in the candidates JSON). # Only spend the shared CAA rate budget on rows whose match warrants it: # a matched/manual release seeds the best candidates, and a review row's # stored candidates are still live proposals. A failed/rejected (or # unscanned) row has no accepted match — asking would burn the budget and # surface releases already rejected as non-matches. The Current + Pack # tiles above serve regardless, so those songs still get a picker. rids: list[str] = [] if row.get("match_state") in ("matched", "manual", "review"): if row.get("match_state") in ("matched", "manual") and row.get("mb_release_id"): rids.append(str(row["mb_release_id"])) for cand in (row.get("candidates") or []): rid = str(cand.get("release_id") or "") if isinstance(cand, dict) else "" if rid and rid not in rids: rids.append(rid) caa_entries: list[dict] = [] for rid in rids: if len(caa_entries) >= _ART_PICKER_MAX_CAA: break try: imgs = _caa_index_cached(rid) except EnrichTransportError: # Offline / archive down — stop asking (each further miss would # only burn a timeout). The instant tiles still serve; a later # picker-open retries naturally (failures are never cached). break # Front covers first, approved before pending, otherwise index order # (the picker grammar is a RANKED list — §7/§9). def _rank(img): types = img.get("types") or [] is_front = bool(img.get("front")) or "Front" in types return (not is_front, not bool(img.get("approved"))) for img in sorted((i for i in imgs if isinstance(i, dict)), key=_rank): if len(caa_entries) >= _ART_PICKER_MAX_CAA: break thumbs = img.get("thumbnails") or {} if not isinstance(thumbs, dict): continue thumb = (thumbs.get("500") or thumbs.get("large") or thumbs.get("250") or thumbs.get("small")) if not thumb: continue types = [str(t) for t in (img.get("types") or []) if isinstance(t, str)] caa_entries.append({ "id": f"caa-{rid}-{img.get('id', '')}", "kind": "caa", "label": ", ".join(types) or "Cover", "thumb_url": str(thumb), "provenance": "matched", "types": types, "approved": bool(img.get("approved")), "release_id": rid, }) return {"candidates": candidates + caa_entries, "pending": False} @app.post("/api/song/{filename:path}/meta") def update_song_meta(filename: str, data: dict): """Update song metadata, persisting it back into the underlying file. The library scanner re-derives title/artist/album/year from the file (archive manifest Attributes / sloppak manifest.yaml) on every full rescan, so a DB-only edit reverts. We write the edit into the file first, then refresh the cache row (including mtime/size) to match. Loose-folder and unwritable songs fall back to a DB-only update (which still survives an incremental rescan via the mtime/size cache hit). """ # Canonicalise to the same key get_song_info uses so an update via # one URL form (e.g. with `..` segments) lands on the row that # later reads will see. dlc = _get_dlc_dir() cache_key = filename resolved = None if dlc: resolved = _resolve_dlc_path(dlc, filename) if resolved is None: return JSONResponse({"error": "forbidden"}, 403) try: cache_key = resolved.relative_to(dlc.resolve()).as_posix() except ValueError: pass fields = {k: data[k] for k in ("title", "artist", "album", "year") if k in data} if not fields: return {"error": "No fields to update"} # Normalise the year value so the DB and file stay in sync. The file # writer (songmeta) coerces empty/non-numeric years to 0, which the # scanner reads back as "". Store "" in the DB instead of a raw # non-numeric string so that if the mtime/size are updated (making the # row cache-fresh) the DB still matches what the scanner would derive. if "year" in fields: try: _yr_int = int(fields["year"]) except (TypeError, ValueError): _yr_int = 0 fields = {**fields, "year": str(_yr_int) if _yr_int else ""} # Persist into the file so the edit survives a full rescan. # Hold _song_io_lock across the existence check and file write so a # concurrent delete cannot remove the file between our check and the # repack's atomic replace, and so a concurrent upload cannot be clobbered # by our atomic rename. archive repack is slow — the lock is held longer # than a simple upload/delete, but correctness requires serialisation. persisted = False with _song_io_lock: if resolved is not None and resolved.exists(): try: import songmeta persisted = songmeta.write_song_metadata(resolved, fields) except Exception: log.warning("metadata file write failed for %s", cache_key, exc_info=True) with meta_db._lock: updates = [f"{field} = ?" for field in fields] params = list(fields.values()) if persisted: # The file changed — re-stat so an incremental rescan sees a # consistent cache row instead of re-reading the (now matching) # file. try: mtime, size = _stat_for_cache(resolved) updates += ["mtime = ?", "size = ?"] params += [mtime, size] except OSError: pass params.append(cache_key) meta_db.conn.execute( f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params ) meta_db.conn.commit() if persisted: _invalidate_song_caches(cache_key) # Coalesce a follow-up scan so a mid-flight scan's stale meta_db.put() # for this file can't win: if a scan is running _kick_scan() queues a # pending pass; if not it starts a fresh one. Unconditional to avoid a # race where the scan finishes between our DB commit and a guarded check. _kick_scan() return {"ok": True, "persisted": persisted} # ── Gap-fill: write CONFIRMED missing metadata into the pack (R4a) ──────────── # The agreed write-back contract (spec-alignment §7): opt-in + user-initiated # (nothing here runs in the background), adds ABSENT keys only (never replaces # an author-set value — the writer refuses, and existing manifest bytes are # preserved verbatim by appending), spec'd-keys allowlist, values only from a # CONFIRMED identity (an auto/exact match or a user pin — review-tier rows are # not eligible until a human confirms), atomic write + .bak. Single-song only; # batch write-back stays an open question with the spec chair. _GAP_FILL_KEYS = ("album", "year", "genres", "mbid", "isrc") def _gap_fill_manifest_absent(manifest: dict, key: str) -> bool: """A key is a GAP only when it's genuinely MISSING from the manifest. Gap-fill is append-only: the writer's never-clobber guard raises on ANY key already present, and appending a second `album:` line to a manifest that already carries `album: ''` would just create a duplicate YAML key. So a present-but-empty value (None / '' / [] / year 0) is NOT a gap the append-only writer can fill — offering it in the preview would only lead to a POST the writer refuses. Present-but-empty keys are therefore left to the metadata editor (which re-serializes and can replace in place).""" return key not in manifest def _gap_fill_proposals(cache_key: str, resolved) -> tuple[dict, str]: """What gap-fill could add for this song: (proposals, reason). Empty proposals explain themselves via reason — 'not-sloppak', 'no-match' (nothing confirmed yet), 'review' (a human hasn't confirmed the match), or 'nothing-missing'.""" if resolved is None or not resolved.exists() or not sloppak_mod.is_sloppak(resolved): return {}, "not-sloppak" row = meta_db.get_enrichment(cache_key) if not row or row.get("match_state") not in ("matched", "manual"): state = (row or {}).get("match_state") return {}, ("review" if state == "review" else "no-match") try: manifest = sloppak_mod.load_manifest(resolved) or {} except Exception: return {}, "not-sloppak" out = {} album = (row.get("canon_album") or "").strip() if album and _gap_fill_manifest_absent(manifest, "album"): out["album"] = album year = (row.get("canon_year") or "").strip() if year.isdigit() and int(year) and _gap_fill_manifest_absent(manifest, "year"): out["year"] = int(year) genres = [str(g) for g in (row.get("genres") or []) if isinstance(g, str) and g.strip()] if genres and _gap_fill_manifest_absent(manifest, "genres"): out["genres"] = genres # Identity keys (feedpak spec 1.14.0) — written in canonical form only. mbid = (row.get("mb_recording_id") or "").strip().lower() if _MBID_RE.match(mbid) and _gap_fill_manifest_absent(manifest, "mbid"): out["mbid"] = mbid isrc = (row.get("isrc") or "").strip().upper().replace("-", "").replace(" ", "") if _ISRC_RE.match(isrc) and _gap_fill_manifest_absent(manifest, "isrc"): out["isrc"] = isrc return out, ("" if out else "nothing-missing") @app.get("/api/song/{filename:path}/gap-fill") def get_song_gap_fill(filename: str): """Preview what "Write missing info to file" would add — the Details drawer renders its confirm list straight from this. Read-only.""" dlc = _get_dlc_dir() cache_key, resolved = filename, None if dlc: resolved = _resolve_dlc_path(dlc, filename) if resolved is None: return JSONResponse({"error": "forbidden"}, 403) try: cache_key = resolved.relative_to(dlc.resolve()).as_posix() except ValueError: pass proposals, reason = _gap_fill_proposals(cache_key, resolved) row = meta_db.get_enrichment(cache_key) or {} return { "eligible": bool(proposals), "reason": reason, "match_state": row.get("match_state"), "missing": [{"key": k, "value": v} for k, v in proposals.items()], } @app.post("/api/song/{filename:path}/gap-fill") def post_song_gap_fill(filename: str, data: dict): """Write the user-confirmed subset of the preview into the pack file. Proposals are recomputed under the io lock, so a key that gained an author value between preview and confirm is skipped, never replaced.""" keys = (data or {}).get("keys") if not isinstance(keys, list) or not keys: return JSONResponse({"error": "keys must be a non-empty list"}, 400) bad = [k for k in keys if k not in _GAP_FILL_KEYS] if bad: return JSONResponse( {"error": "unknown key(s): " + ", ".join(sorted(set(map(str, bad))))}, 400) dlc = _get_dlc_dir() cache_key, resolved = filename, None if dlc: resolved = _resolve_dlc_path(dlc, filename) if resolved is None: return JSONResponse({"error": "forbidden"}, 403) try: cache_key = resolved.relative_to(dlc.resolve()).as_posix() except ValueError: pass with _song_io_lock: proposals, reason = _gap_fill_proposals(cache_key, resolved) additions = {k: proposals[k] for k in _GAP_FILL_KEYS if k in keys and k in proposals} skipped = sorted(set(keys) - set(additions)) if not additions: return JSONResponse({"error": "nothing to write", "reason": reason, "skipped": skipped}, 409) try: import songmeta songmeta.gap_fill_sloppak(resolved, additions) except Exception: log.warning("gap-fill write failed for %s", cache_key, exc_info=True) return JSONResponse({"error": "write failed"}, 500) # Keep the cache row consistent with what the scanner would now derive # (same contract as the metadata editor above): sync the columns the # scan reads from the keys we appended, then re-stat so the row stays # cache-fresh. fields = {} if "album" in additions: fields["album"] = additions["album"] if "year" in additions: fields["year"] = str(additions["year"]) if "genres" in additions: fields["genre"] = additions["genres"][0] with meta_db._lock: updates = [f"{field} = ?" for field in fields] params = list(fields.values()) try: mtime, size = _stat_for_cache(resolved) updates += ["mtime = ?", "size = ?"] params += [mtime, size] except OSError: pass if updates: params.append(cache_key) meta_db.conn.execute( f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params) meta_db.conn.commit() _invalidate_song_caches(cache_key) _kick_scan() return {"ok": True, "written": additions, "skipped": skipped} def _save_art_override(filename: str, img_data: bytes) -> dict: """Persist a user art override into the art cache (R3). One override per song: GIF input is validated and kept VERBATIM as .gif (animation intact — the local-only bonus; it is never written into the pack file), everything else is normalized to RGB PNG via PIL. Saving either kind removes the other so the serve chain has exactly one user file to find.""" ART_CACHE_DIR.mkdir(parents=True, exist_ok=True) stem = _art_safe_name(filename) png_path = ART_CACHE_DIR / f"{stem}.png" gif_path = ART_CACHE_DIR / f"{stem}.gif" from PIL import Image import io as _io if img_data[:6] in (b"GIF87a", b"GIF89a"): try: probe = Image.open(_io.BytesIO(img_data)) probe.verify() # decodes headers/frames without keeping the image if probe.format != "GIF": raise ValueError("not a GIF") except Exception as e: return {"error": f"Invalid image: {e}"} gif_path.write_bytes(img_data) png_path.unlink(missing_ok=True) return {"ok": True, "kind": "gif"} try: img = Image.open(_io.BytesIO(img_data)).convert("RGB") img.save(str(png_path), "PNG") except Exception as e: return {"error": f"Invalid image: {e}"} gif_path.unlink(missing_ok=True) return {"ok": True, "kind": "png"} @app.post("/api/song/{filename:path}/art/upload") async def upload_song_art_b64(filename: str, data: dict): """Upload a custom cover as base64 (PNG/JPG/WebP → normalized PNG; GIF → kept animated, local-only). The override outranks pack art in the serve chain; remove it via DELETE …/art/override.""" import base64 # Reject art for a filename that doesn't resolve to a real song (mirrors the # url route's guard) — no writing stray override files for unknown keys. dlc = _get_dlc_dir() song_path = _resolve_dlc_path(dlc, filename) if dlc else None if song_path is None or not song_path.exists(): raise HTTPException(status_code=404, detail="unknown song") b64 = data.get("image", "") if not b64: return {"error": "No image data"} # Strip data URL prefix if present if "," in b64: b64 = b64.split(",", 1)[1] try: img_data = base64.b64decode(b64) except Exception: return {"error": "Invalid base64"} if len(img_data) > _ART_URL_MAX_BYTES: raise HTTPException(status_code=400, detail="image larger than 10 MB") return _save_art_override(filename, img_data) # Art-by-URL fetch cap — a cover, not a wallpaper pack. _ART_URL_MAX_BYTES = 10 * 1024 * 1024 def _url_host_is_internal(url: str) -> bool: """True when a user-supplied URL's host resolves to a loopback, private, link-local, reserved, multicast or unspecified address — an SSRF target we refuse to fetch on the user's behalf (e.g. 169.254.169.254 metadata, LAN services). Fails CLOSED: an unresolvable or unparseable host is treated as internal. Every resolved address must be public for the URL to pass.""" from urllib.parse import urlparse import socket host = urlparse(url).hostname if not host: return True try: infos = socket.getaddrinfo(host, None) except OSError: return True if not infos: return True for info in infos: raw = info[4][0].split("%", 1)[0] # strip any zone id try: ip = ipaddress.ip_address(raw) except ValueError: return True if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified): return True return False # Art-by-URL redirect budget. Cover hosts commonly answer with a redirect — # the Cover Art Archive (whose thumbs the cover picker applies through this # very route) 307s every image to archive.org — so redirects must work; 5 # hops is generous for any real CDN chain while still bounding the walk. _ART_URL_MAX_REDIRECTS = 5 def _fetch_art_url(url: str) -> bytes: """The one place art-by-URL touches the network (tests fake this seam). User-initiated, so not throttled like the background workers — but the same offline guard applies (pytest can never fetch), the host is checked against internal/reserved ranges (SSRF), redirects are followed MANUALLY with the scheme + internal-host guard re-applied to every hop (so a redirect can't smuggle the request to an internal target — a blanket no-redirect rule would break every Cover Art Archive pick, which always redirects to archive.org), and the size cap is enforced while streaming so a huge response never fully downloads. Residual, accepted: each hop's host is resolved here and again by requests, so a rebinding DNS name is a theoretical TOCTOU. Not closed with an IP-pinned connection because (a) this is a single-user, no-auth app (constitution §I) and the route is demo-blocked, so there is no untrusted submission path, and (b) no other in-tree client (MusicBrainz, CAA) pins either — a bespoke pinned+SNI adapter here would be inconsistent and disproportionate. The cheap guards above still stop the realistic vectors (direct internal URL, redirect-to-internal).""" if not _enrich_network_enabled(): raise EnrichTransportError("art fetch disabled (offline)") import requests from urllib.parse import urljoin, urlparse for _hop in range(_ART_URL_MAX_REDIRECTS + 1): # Re-validate EVERY hop, not just the user's original URL: the whole # point of handling redirects ourselves is that each target gets the # same scheme + SSRF gate before any request is made. if urlparse(url).scheme not in ("http", "https"): raise ValueError("url must be http(s)") if _url_host_is_internal(url): raise ValueError("url host is not allowed") try: with requests.get(url, timeout=15, stream=True, allow_redirects=False, headers={"User-Agent": _enrich_user_agent()}) as resp: if resp.status_code in (301, 302, 303, 307, 308): loc = resp.headers.get("Location") or "" if not loc: raise EnrichTransportError( f"HTTP {resp.status_code} without a Location") url = urljoin(url, loc) continue if resp.status_code != 200: raise EnrichTransportError(f"HTTP {resp.status_code}") data = b"" for chunk in resp.iter_content(65536): data += chunk if len(data) > _ART_URL_MAX_BYTES: raise ValueError("image larger than 10 MB") return data except requests.RequestException as e: raise EnrichTransportError(str(e)) from e raise EnrichTransportError("too many redirects") @app.post("/api/song/{filename:path}/art/url") def set_song_art_from_url(filename: str, data: dict): """Paste-a-link cover art (the media-server idiom): the server fetches the image and stores it as this song's local override — identical result to an upload, including the GIF-stays-local rule. http(s) only.""" url = str((data or {}).get("url") or "").strip() from urllib.parse import urlparse parsed = urlparse(url) if parsed.scheme not in ("http", "https") or not parsed.hostname: raise HTTPException(status_code=400, detail="url must be http(s)") dlc = _get_dlc_dir() song_path = _resolve_dlc_path(dlc, filename) if dlc else None if song_path is None or not song_path.exists(): raise HTTPException(status_code=404, detail="unknown song") try: img_data = _fetch_art_url(url) except EnrichTransportError as e: return JSONResponse({"error": "could not fetch image", "detail": str(e)}, status_code=502) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return _save_art_override(filename, img_data) @app.delete("/api/art/{filename:path}/override") def remove_song_art_override(filename: str): """Drop the user art override — the serve chain falls back to pack art, then the Cover Art Archive cache. Lives under /api/art (NOT /api/song) so the greedy DELETE /api/song/{path} catch-all can't shadow it — the same dodge the chart split/unsplit routes use.""" removed = False for p in _art_override_paths(filename): try: p.unlink() removed = True except OSError: pass if removed: # The art worker may have settled this row as 'user' (override present, # no pack art). Reset it so the next enrichment pass re-evaluates and the # CAA fallback resumes — otherwise a removed override strands the row # (enrichment_art_pending only re-queues art_state IS NULL) and the song # is left with no art at all. try: meta_db.set_enrichment_art(filename, None, None) except Exception: log.exception("art override delete: failed to reset enrichment state") return {"ok": True, "removed": removed} @app.get("/api/song/{filename:path}") async def get_song_info(filename: str): """Return song metadata, from cache or by extracting it from the song source.""" import asyncio dlc = _get_dlc_dir() if not dlc: return JSONResponse({"error": "DLC folder not configured"}, 404) song_path = _resolve_dlc_path(dlc, filename) if song_path is None: return JSONResponse({"error": "forbidden"}, 403) if not song_path.exists(): return JSONResponse({"error": "File not found"}, 404) # Canonicalise the cache key against the resolved path so two URL # forms of the same physical file (e.g. `Artist/song.sloppak` vs # `Artist/../Artist/song.sloppak`) converge on a single row instead # of fragmenting / shadowing each other in meta_db. try: cache_key = song_path.relative_to(dlc.resolve()).as_posix() except ValueError: cache_key = filename mtime, size = _stat_for_cache(song_path) cached = meta_db.get(cache_key, mtime, size) if cached: return cached # Extract in thread pool def _extract(): meta = _extract_meta_for_file(song_path, dlc) meta_db.put(cache_key, mtime, size, meta) return meta meta = await asyncio.get_event_loop().run_in_executor(None, _extract) return meta # ── Highway WebSocket ───────────────────────────────────────────────────────── # Filename-keyed extraction cache, retained so _invalidate_song_caches() has a # stable handle to purge on song replace/delete. Open formats (sloppak/loose) # self-invalidate via stat checks and never populate this, so it stays empty in # practice. _extract_cache = {} # filename -> (tmp_dir, song, timestamp) _extract_cache_lock = threading.Lock() @app.get("/api/sloppak/{filename:path}/file/{rel_path:path}") def serve_sloppak_file(filename: str, rel_path: str): """Serve a file from inside a sloppak (stems, cover, etc.).""" dlc = _get_dlc_dir() if not dlc: return JSONResponse({"error": "not configured"}, 404) # `filename` is an attacker-controlled `:path` param. Contain it under # DLC_DIR before it reaches the resolver, which does a bare # `dlc_root / filename`. Without this, `../../../etc` escapes the root # and the rel_path guard below validates `target` against the already- # escaped `src`, which trivially passes — yielding arbitrary file reads # (e.g. /api/sloppak/../../../../etc/file/passwd). Mirrors the guard # `get_song_art` applies to the same filename param. resolved = _resolve_dlc_path(dlc, filename) if resolved is None: return JSONResponse({"error": "forbidden"}, 403) # Confine the endpoint to actual sloppak bundles. Without this, a # contained-but-non-sloppak `filename` (e.g. `.` → DLC_DIR itself, or # any plain subdirectory) would make `resolve_source_dir` hand back a # directory and turn this into a read-any-file-under-DLC_DIR endpoint. # Mirrors get_song_art's `is_sloppak` dispatch. if not sloppak_mod.is_sloppak(resolved): return JSONResponse({"error": "not found"}, 404) # Canonicalise the cache key against the resolved path so equivalent # URL forms of the same sloppak (e.g. `A/../B/x.sloppak` vs # `B/x.sloppak`) converge on one `_source_cache` entry instead of # fragmenting / re-unpacking — mirrors get_song_info's keying. try: filename = resolved.relative_to(dlc.resolve()).as_posix() except ValueError: # safe_join already proved containment, so this is unreachable in # practice; fail closed rather than fall back to the raw param. return JSONResponse({"error": "forbidden"}, 403) src = sloppak_mod.get_cached_source_dir(filename) if src is None: try: src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR) except Exception: return JSONResponse({"error": "not found"}, 404) # Prevent path traversal within the sloppak. target = (src / rel_path).resolve() try: target.relative_to(src.resolve()) except ValueError: return JSONResponse({"error": "forbidden"}, 403) if not target.exists() or not target.is_file(): return JSONResponse({"error": "not found"}, 404) ext = target.suffix.lower() mt = { ".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".flac": "audio/flac", ".m4a": "audio/mp4", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".json": "application/json", }.get(ext) return FileResponse(str(target), media_type=mt) if mt else FileResponse(str(target)) @app.websocket("/ws/highway/{filename:path}") async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, naming_mode: str = "legacy"): """Stream song data for the highway renderer over WebSocket.""" await websocket.accept() structlog.contextvars.bind_contextvars(ws_conn_id=uuid.uuid4().hex[:8]) dlc = _get_dlc_dir() if not dlc: await websocket.send_json({"error": "DLC folder not configured"}) await websocket.close() return song_path = _resolve_dlc_path(dlc, filename) if song_path is None: await websocket.send_json({"error": "forbidden"}) await websocket.close() return if not song_path.exists(): await websocket.send_json({"error": "File not found"}) await websocket.close() return is_slop = sloppak_mod.is_sloppak(song_path) # Sloppak wins precedence: `_extract_meta_for_file()` and the # background scanner both treat a `.sloppak` directory as sloppak # even if it happens to contain WEM/XML. Gate is_loose on that # so the loose-only branches (audio_id, offset, audio conversion) # don't fire for sloppak bundles. is_loose = (not is_slop) and loosefolder_mod.is_loose_song(song_path) tmp = None owns_tmp = False loaded_slop = None # LoadedSloppak when is_slop _keepalive_active = True async def _send_keepalives(): while _keepalive_active: try: await asyncio.sleep(3) if _keepalive_active: await websocket.send_json({"type": "loading", "stage": "Loading..."}) except Exception: break try: await websocket.send_json({"type": "loading", "stage": "Extracting..."}) keepalive_task = asyncio.create_task(_send_keepalives()) try: loop = asyncio.get_running_loop() _ctx = contextvars.copy_context() if is_slop: SLOPPAK_CACHE_DIR.mkdir(parents=True, exist_ok=True) loaded_slop = await loop.run_in_executor( None, lambda: _ctx.run(sloppak_mod.load_song, filename, dlc, SLOPPAK_CACHE_DIR), ) song = loaded_slop.song tmp = str(loaded_slop.source_dir) owns_tmp = False elif is_loose: # Loose folders need no extraction — load_song reads the # arrangement XMLs directly from the flat directory. # song_path is already DLC-containment-validated by # _resolve_dlc_path, so audio conversion below can use # it directly. song = await loop.run_in_executor(None, lambda: load_song(str(song_path))) tmp = str(song_path) owns_tmp = False else: # Only open formats (.sloppak bundles and loose folders) are # servable. There is no fallback container extraction path. raise ValueError("Unsupported song format") finally: _keepalive_active = False keepalive_task.cancel() if not song.arrangements: await websocket.send_json({"error": "No arrangements found"}) await websocket.close() return # Smart names are needed for smart-mode arrangement selection. smart_names = compute_smart_names(song.arrangements) # Pick arrangement: explicit request > user preference > most notes best = -1 if 0 <= arrangement < len(song.arrangements): best = arrangement else: # Read the user's config once: their selected instrument (route the chart # to the matching part) and their default-arrangement preference. pref = "" sel_instrument = "" config_file = CONFIG_DIR / "config.json" if config_file.exists(): try: _cfg = json.loads(config_file.read_text(encoding="utf-8")) pref = _cfg.get("default_arrangement", "") sel_instrument = (_cfg.get("instrument", "") or "") except Exception: pass # Instrument routing: load the part that matches the selected instrument so # "your instrument" and "the chart you play" line up. The default ordering # is Lead/guitar-first, so without this a bass player gets handed a guitar # chart (and any tune-check then compares a 4-string bass against a 6-string # part). Currently routes bass -> a Bass arrangement; guitar — and any # unknown/future instrument (drums, keys) — falls through to the # preference/most-notes logic below, which already lands on a guitar part. # Drums/keys get their own match when those arrangement types + selector # entries land. Only applies when no explicit arrangement was requested, so # a manual arrangement switch is always respected. if sel_instrument.lower() == "bass": # Candidate bass parts, preferring the structured pathBass flag; the # normalized smart name (itself pathBass-derived) and raw name are # fallbacks for sources without the flag. bass_idxs = [ i for i, a in enumerate(song.arrangements) if getattr(a, "path_bass", False) or (smart_names[i] or "").lower().startswith("bass") or "bass" in (getattr(a, "name", "") or "").lower() ] if bass_idxs: # Among the bass parts: (1) honor the saved default-arrangement # preference if it names one of them (so a bass player who prefers # "Bass 2"/"Alt. Bass" keeps it), (2) else the canonical main "Bass", # (3) else the first bass part in order. pref_bass = -1 if pref: for i in bass_idxs: nm = (smart_names[i] if naming_mode == "smart" and i < len(smart_names) else getattr(song.arrangements[i], "name", "")) if nm == pref: pref_bass = i break if pref_bass >= 0: best = pref_bass else: best = next( (i for i in bass_idxs if (smart_names[i] if i < len(smart_names) else "") == "Bass"), bass_idxs[0], ) # User's default arrangement preference (only when instrument routing did not # already resolve a part — i.e. guitar, or a bass player with no bass part). if best < 0 and pref: if naming_mode == "smart": best = _pick_smart_arrangement(song.arrangements, smart_names, pref) else: for i, a in enumerate(song.arrangements): if a.name == pref: best = i break if best < 0: # Fallback: most notes best = 0 best_count = 0 for i, a in enumerate(song.arrangements): c = len(a.notes) + sum(len(ch.notes) for ch in a.chords) if c > best_count: best_count = c best = i arr = song.arrangements[best] # Resolve the manifest arrangement id for notation lookup (Option B loader). # Use the parallel arrangement_ids list (indexed by compacted position, # i.e. song.arrangements index) so skipped manifest entries can't shift # the index and serve the wrong arrangement's notation. _notation_arr_id: str | None = None if is_slop and loaded_slop is not None: _ids = loaded_slop.arrangement_ids if best < len(_ids): _notation_arr_id = _ids[best] # Convert audio with unique filename (check cache first) audio_url = None audio_error: str | None = None # Surfaced in song_info when audio_url is None stems_payload: list[dict] = [] # URL of the single full-mix audio (sloppak `original_audio:`), when the # pack ships one. The stems plugin uses this to play the untouched mix # while every stem slider is at unity; None otherwise (separate stems # only, loose folder, or archive). original_audio_url: str | None = None if is_loose: # Loose folder filenames are relative paths (artist/album/song). # Hash the *canonical* dlc-relative path (so two URL spellings # of the same physical folder share a cache key) PLUS the # source WEM's mtime+size so: # - different songs with the same leaf folder name can't # collide (a `/`→`__` escape would collapse `a/b__c` and # `a__b/c`); # - editing audio.wem in place invalidates the cached # converted file (without this, in-place custom song iteration # keeps serving the stale mp3/ogg from the cache). try: canonical = song_path.relative_to(dlc.resolve()).as_posix() except ValueError: canonical = filename wem_for_id = loosefolder_mod.find_audio(song_path) try: wem_stat = wem_for_id.stat() if wem_for_id else None except OSError: wem_stat = None stamp = f"{wem_stat.st_mtime_ns}-{wem_stat.st_size}" if wem_stat else "" digest = hashlib.sha256( (canonical + "|" + stamp).encode("utf-8") ).hexdigest()[:12] leaf = Path(canonical.rstrip("/\\")).stem.replace(" ", "_")[:40] or "song" audio_id = f"{leaf}_{digest}" else: audio_id = Path(filename).stem.replace(" ", "_") if is_slop: # Stems are served via the sloppak file endpoint; the first stem # (or explicit default) is the core