feedBack/server.py
ChrisBeWithYou 744c848636
v3 library: mastery filter + sort (needs-practice / most-mastered) (#687)
* feat(v3): sort the library by mastery (needs-practice / most-mastered)

Adds two sort options to the Songs library: "Needs practice first" (weakest
measured accuracy first) and "Most mastered first". Mastery = MAX(best_accuracy)
across a song's arrangements, from song_stats; because that's a separate table
it's a correlated subquery in the ORDER BY, so these sorts use OFFSET paging like
tuning/year. Unscored ("not started") songs always sort to the bottom in both
directions, so a large unpracticed library doesn't bury the songs you're
actually working on. Never the default.

Verified against a running server: scored songs at 0.90 / 0.30 plus unscored ->
ascending orders 0.30, 0.90, unscored; descending 0.90, 0.30, unscored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* feat(v3): add the 3-state mastery filter (Mastered / In progress / Not started)

Complements the mastery sort with the gentler filter the charrette preferred: a
"Progress" facet in the Filters drawer with Mastered (>= 0.9), In progress
(attempted but < 0.9), and Not started (no score) -- multi-select, OR within the
set. Server-side via a correlated subquery on song_stats threaded through
_build_where / query_page (passed as a separate kwarg so query_artists /
query_stats are unaffected). Smart-collection providers ignore it (they define
their own filters).

Verified live: mastered -> the 0.90 song, in_progress -> the 0.30 song,
not_started -> the unscored songs, combined -> both scored (with correct totals).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:08:57 -05:00

8777 lines
402 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""FeedBack — FastAPI backend serving highway viewer + library."""
import asyncio
import bisect
import hashlib
import json
import logging
import math
import os
import secrets
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
# 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 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$")),
]
@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.
_TUNING_GROUP_KEY_SQL = (
"CASE WHEN tuning_name = 'Custom Tuning' AND COALESCE(tuning_offsets, '') != '' "
"THEN tuning_offsets ELSE tuning_name END"
)
# ── 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", "COLLATE NOCASE", "ASC"),
"artist-desc": ("artist", "COLLATE NOCASE", "DESC"),
"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
`<col> <primary_dir>, 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"]])
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 ''
)
""")
# 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 ''",
):
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_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)")
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
# 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)"
)
# 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()
# 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
# ── 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
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
]
# ── Playlists ─────────────────────────────────────────────────────────--
SAVED_KEY = "saved_for_later"
def _playlist_count(self, pid: int) -> int:
# 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.
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 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],
"count": self._playlist_count(pid),
"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) -> dict:
with self._lock:
cur = self.conn.execute(
"INSERT INTO playlists (name, system_key, created_at, updated_at) "
"VALUES (?, ?, datetime('now'), datetime('now'))",
(name, system_key),
)
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 FROM playlists "
"WHERE id = ? AND rules IS NULL", (pid,)
).fetchone()
if not head:
return None
rows = self.conn.execute(
"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name
FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename
WHERE ps.playlist_id = ?
-- hide dead songs (race-free; not deleted on scan)
AND s.filename IS NOT NULL
ORDER BY ps.position, ps.filename""",
(pid,),
).fetchall()
from urllib.parse import quote
songs = [{
"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",
} for r in rows]
return {
"id": head[0], "name": head[1], "system_key": head[2],
"created_at": head[3], "updated_at": head[4], "songs": songs,
}
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.
if not self.conn.execute("SELECT 1 FROM playlists WHERE id = ?", (pid,)).fetchone():
return 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) VALUES (?, ?, ?)",
(pid, filename, nxt),
)
self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,))
self.conn.commit()
return cur.rowcount > 0
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) "
"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 ""),
)
self.conn.commit()
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()
return len(stale)
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,
naming_mode: str = "legacy") -> 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.
"""
where = "WHERE title != ''"
params: list = []
if favorites_only:
where += " AND filename IN (SELECT filename FROM favorites)"
if format_filter:
where += " AND format = ?"
params.append(format_filter)
if artist_filter:
where += " AND artist = ? COLLATE NOCASE"
params.append(artist_filter)
if album_filter:
where += " AND album = ? COLLATE NOCASE"
params.append(album_filter)
# 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) + ")"
if q:
where += " AND (title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE OR album LIKE ? COLLATE NOCASE)"
params += [f"%{q}%"] * 3
# 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 += (
" AND EXISTS (SELECT 1 FROM json_each(songs.arrangements) WHERE "
+ " OR ".join(f"({c})" for c in clauses)
+ ")"
)
else:
placeholders = ",".join(["?"] * len(arr_has))
where += (" AND EXISTS (SELECT 1 FROM json_each(songs.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 += (
" AND NOT EXISTS (SELECT 1 FROM json_each(songs.arrangements) WHERE "
+ " OR ".join(f"({c})" for c in clauses)
+ ")"
)
else:
placeholders = ",".join(["?"] * len(arr_lacks))
where += (" AND NOT EXISTS (SELECT 1 FROM json_each(songs.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 += (" AND EXISTS (SELECT 1 FROM json_each(songs.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 += (" AND NOT EXISTS (SELECT 1 FROM json_each(songs.stem_ids) "
f"WHERE value IN ({placeholders}))")
params += stems_l
if has_lyrics in (0, 1):
where += " AND 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} "
f"COLLATE NOCASE IN ({placeholders})")
params += tn
return where, params
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,
after: str | None = None,
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."""
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, naming_mode=naming_mode,
)
sort_map = {
"artist": "artist COLLATE NOCASE", "artist-desc": "artist COLLATE NOCASE DESC",
"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",
# 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"
),
}
order = sort_map.get(sort, "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 (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.
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"
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
# `<col> <dir>, 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,
})
return songs, total
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,
)
if letter == "#":
where += " AND artist NOT GLOB '[A-Za-z]*'"
elif letter:
where += " AND UPPER(SUBSTR(artist, 1, 1)) = ?"
params.append(letter.upper())
# Get paginated distinct artists
total_artists = self.conn.execute(
f"SELECT COUNT(DISTINCT artist COLLATE NOCASE) FROM songs {where}", params
).fetchone()[0]
artist_rows = self.conn.execute(
f"SELECT DISTINCT artist 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 artists only
placeholders = ",".join(["?"] * len(artist_names))
song_where = f"{where} AND artist COLLATE NOCASE IN ({placeholders})"
song_params = params + artist_names
rows = self.conn.execute(
f"SELECT filename, title, artist, album, year, duration, tuning, arrangements, has_lyrics, "
f"format, stem_count, stem_ids, tuning_name "
f"FROM songs {song_where} ORDER BY artist 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_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,
sort: str = "artist",
want_sort_letters: 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.
`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, naming_mode=naming_mode,
)
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-AZ 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"
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_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, AZ 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_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")
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 <offset>;
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}
_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 _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/``). Logs and continues on missing source or copy 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
root = _feedBack_server_root()
dest_dir = dlc / _BUILTIN_DIAGNOSTIC_SUBDIR
# 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 is_symlink() guard below cannot catch this.
if dest_dir.is_symlink():
log.warning(
"Builtin diagnostic seed: %s is a symlink, skipping all seeding",
_BUILTIN_DIAGNOSTIC_SUBDIR,
)
return
dest_dir.mkdir(parents=True, exist_ok=True)
for dest_name, rel_source in _BUILTIN_DIAGNOSTIC_SOURCES:
source = root / rel_source
dest = dest_dir / dest_name
if not source.is_file():
log.warning(
"Builtin diagnostic seed: source missing, skipping %s (%s)",
dest_name,
source,
)
continue
# Refuse to seed through a symlink. is_file()/stat()/copy2 all
# follow links, so a symlink planted at the destination would let
# the copy redirect outside diagnostics-builtin/ and overwrite an
# arbitrary file the server user can write. Skip and warn; never
# touch the link or its target.
if dest.is_symlink():
log.warning(
"Builtin diagnostic seed: destination is a symlink, skipping %s/%s",
_BUILTIN_DIAGNOSTIC_SUBDIR,
dest_name,
)
continue
if dest.is_file():
try:
if source.stat().st_mtime <= dest.stat().st_mtime:
log.info(
"Builtin diagnostic seed: already present %s/%s",
_BUILTIN_DIAGNOSTIC_SUBDIR,
dest_name,
)
continue
action = "updated"
except OSError as exc:
log.warning(
"Builtin diagnostic seed: cannot compare %s and %s: %s",
source,
dest,
exc,
)
continue
else:
action = "seeded"
try:
shutil.copy2(source, dest)
log.info(
"Builtin diagnostic seed: %s %s -> %s/%s",
action,
source.name,
_BUILTIN_DIAGNOSTIC_SUBDIR,
dest_name,
)
except OSError as exc:
log.warning(
"Builtin diagnostic seed: failed to copy %s -> %s: %s",
source,
dest,
exc,
)
except Exception:
log.warning("Builtin diagnostic 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)
# 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
stale = meta_db.delete_missing(current_files)
if stale:
log.info("Removed %d stale DB entries", stale)
# 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"}
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"}
_scan_kick_lock = threading.Lock()
_scan_rescan_pending = False
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
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
threading.Thread(target=_scan_runner, daemon=True).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
return
_scan_rescan_pending = False
_scan_status["running"] = True
# ── 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 `<a href>` 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 <a href> 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/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,))
meta_db.conn.commit()
_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 _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 = "", after: str = "", 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),
naming_mode=naming_mode,
mastery=_split_csv(mastery),
**_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}
@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",
sort: str = "artist", sort_letters: 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."""
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),
**_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/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}
# ── 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/<name> 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 132 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/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 1100 characters."}, status_code=400)
return meta_db.create_playlist(name)
@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 1100 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.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,
}
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 "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 48"}
if sc < 4 or sc > 8:
return {"error": "string_count must be an integer 48"}
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 `<target>.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=<mtime>` 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):
"""Serve album art for a song.
Dispatches by format and returns the appropriate media type:
- Sloppak: serves `cover.jpg` (or manifest-declared cover) read directly
from the package (the single cover member for zip-form sloppaks — no
full unpack) as JPEG/PNG/WebP.
- Loose folder: serves the discovered art file directly as
JPEG/PNG/WebP.
"""
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)
# Sloppak path: 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 None:
return JSONResponse({"error": "no art"}, 404)
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)
# Loose folder path: serve art file directly.
# song_path is already validated against DLC_DIR by _resolve_dlc_path.
if 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)
return JSONResponse({"error": "no art"}, 404)
# Custom art uploaded via /art/upload is cached as PNG under ART_CACHE_DIR;
# serve it if present (works for any song the user pinned art to).
art_cache = ART_CACHE_DIR
safe_name = filename.replace("/", "_").replace(" ", "_")
cached = art_cache / f"{safe_name}.png"
if cached.exists():
return _file_art_response(cached, "image/png", request)
return JSONResponse({"error": "no art"}, 404)
@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}
@app.post("/api/song/{filename:path}/art/upload")
async def upload_song_art_b64(filename: str, data: dict):
"""Upload custom album art as base64 PNG/JPG."""
import base64
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"}
art_cache = ART_CACHE_DIR
art_cache.mkdir(parents=True, exist_ok=True)
safe_name = filename.replace("/", "_").replace(" ", "_")
cached = art_cache / f"{safe_name}.png"
# Convert to PNG if needed
try:
from PIL import Image
import io
img = Image.open(io.BytesIO(img_data)).convert("RGB")
img.save(str(cached), "PNG")
except Exception as e:
return {"error": f"Invalid image: {e}"}
return {"ok": True}
@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 <audio> source. The stems
# plugin replaces it with a mixed graph when active.
from urllib.parse import quote
q_fn = quote(filename, safe="")
for s in loaded_slop.stems:
url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}"
stems_payload.append({"id": s["id"], "url": url, "default": s["default"]})
# Full-mix URL (served by the same /api/sloppak/.../file/ endpoint).
if loaded_slop is not None and loaded_slop.original_audio:
original_audio_url = (
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.original_audio)}"
)
if stems_payload:
# Stems present: keep the core <audio> pointed at stem[0]. This
# URL is only ever heard in the degraded path (stems plugin
# refuses takeover / decode fails); the full-mix↔stems switch is
# driven client-side by `original_audio_url`, not `audio_url`.
audio_url = stems_payload[0]["url"]
elif original_audio_url:
# Stem-less full-mix pack: nothing to separate, so play the full
# mix natively through the core <audio>. The stems plugin's
# onSongReady returns early on an empty stems list (no graph).
audio_url = original_audio_url
else:
audio_error = "This sloppak has no playable stems."
else:
AUDIO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
# Check if audio already cached (writable cache dir or legacy static dir)
for ext in [".mp3", ".ogg", ".wav"]:
for cache_dir in [AUDIO_CACHE_DIR, STATIC_DIR]:
cached_audio = cache_dir / f"audio_{audio_id}{ext}"
if cached_audio.exists() and cached_audio.stat().st_size > 1000:
audio_url = f"/audio/audio_{audio_id}{ext}"
break
if audio_url:
break
def _evict_audio_cache():
# Keep AUDIO_CACHE_DIR bounded so a library full of loose
# folders / many archives doesn't fill disk. LRU on st_atime
# so songs the user keeps replaying stay warm. Best-effort:
# log at debug so permission / disk errors are diagnosable
# without aborting the request.
try:
audio_files = [f for f in AUDIO_CACHE_DIR.iterdir()
if f.name.startswith("audio_") and f.suffix in (".mp3", ".ogg", ".wav")]
if len(audio_files) > 100:
audio_files.sort(key=lambda f: f.stat().st_atime)
for f in audio_files[:len(audio_files) - 100]:
f.unlink(missing_ok=True)
except Exception:
log.debug("audio cache eviction failed for %s", AUDIO_CACHE_DIR, exc_info=True)
if not audio_url and is_loose:
await websocket.send_json({"type": "loading", "stage": "Converting audio..."})
wem_path = loosefolder_mod.find_audio(song_path)
if wem_path:
# Re-resolve to defeat a symlinked audio.wem that points
# outside the song folder — without this, a crafted
# custom song could turn convert_wem into an arbitrary-file
# decode/read primitive.
wem_resolved = wem_path.resolve()
try:
wem_resolved.relative_to(song_path)
except ValueError:
audio_error = "Audio file escapes the loose folder."
wem_resolved = None
if wem_resolved is not None:
# Convert into a unique temp basename and then
# atomically rename onto the final cache name.
# Two clients requesting the same song concurrently
# would otherwise race writing the same file and
# one could serve a partial mp3/wav.
tmp_suffix = uuid.uuid4().hex[:8]
tmp_base = AUDIO_CACHE_DIR / f"audio_{audio_id}.{tmp_suffix}"
try:
produced = convert_wem(str(wem_resolved), str(tmp_base))
ext = Path(produced).suffix
final_path = AUDIO_CACHE_DIR / f"audio_{audio_id}{ext}"
os.replace(produced, final_path)
audio_url = f"/audio/audio_{audio_id}{ext}"
except Exception as e:
log.exception("loose-folder audio conversion failed for %s", audio_id)
audio_error = f"Audio conversion failed: {e}"
# Best-effort cleanup of partial temp artifacts.
for stale in AUDIO_CACHE_DIR.glob(f"audio_{audio_id}.{tmp_suffix}.*"):
stale.unlink(missing_ok=True)
else:
audio_error = "No audio file found in loose folder."
_evict_audio_cache()
if not audio_url and not is_slop and not is_loose:
await websocket.send_json({"type": "loading", "stage": "Converting audio..."})
wem_files = find_wem_files(tmp)
if not wem_files:
audio_error = "No WEM audio files were found inside this archive."
else:
try:
audio_path = convert_wem(wem_files[0], os.path.join(tmp, "audio"))
ext = Path(audio_path).suffix
audio_dest = AUDIO_CACHE_DIR / f"audio_{audio_id}{ext}"
shutil.copy2(audio_path, audio_dest)
audio_url = f"/audio/audio_{audio_id}{ext}"
except Exception as e:
log.exception("audio conversion failed for %s", audio_id)
audio_error = f"Audio conversion failed: {e}"
_evict_audio_cache()
# Send song metadata
arr_list = [
{
"index": i,
"name": a.name,
"smart_name": smart_names[i],
"notes": len(a.notes) + sum(len(c.notes) for c in a.chords),
}
for i, a in enumerate(song.arrangements)
]
arr_list.sort(key=_arr_smart_sort_key)
await websocket.send_json({
"type": "song_info",
"title": song.title,
"artist": song.artist,
"duration": song.song_length,
"arrangement": arr.name,
"arrangement_smart_name": smart_names[best],
"arrangement_index": best,
# Echo the resolved naming mode so highway.js doesn't have to
# re-read localStorage (which can be unavailable / disagree with
# app.js's in-memory cache when storage writes fail).
"naming_mode": "smart" if naming_mode == "smart" else "legacy",
"arrangements": arr_list,
"audio_url": audio_url,
"audio_error": audio_error,
"tuning": arr.tuning,
# Number of strings on the active arrangement
# (feedBack-plugin-3dhighway#7). arrangement XML / archive sources
# always emit `tuning` as length 6 with zero-padding for
# unused string slots, so `len(arr.tuning)` is unreliable
# there; sloppak / GP-imported sources may instead carry
# a trimmed list. arrangement_string_count() combines a
# notes-derived lower bound, a name-based fallback (4 for
# "bass" arrangements), and the tuning length (when it
# disagrees with the RS-XML padded 6) into a single
# reliable signal. Plugins should size string-indexed UI
# / geometry against THIS rather than assuming 6 or
# using `tuning.length` directly.
"stringCount": arrangement_string_count(arr),
"capo": arr.capo,
"centOffset": arr.cent_offset,
# Sanitize song.offset before send_json: a malformed loose
# chart can produce NaN via `float("nan")`, which Starlette
# would serialise as the literal `NaN` token (invalid JSON)
# and break the frontend's song_info parsing.
"offset": _sanitized_song_offset(song) if is_loose else 0.0,
"format": "sloppak" if is_slop else ("loose" if is_loose else "archive"),
# Feedpak contributor credits (manifest `authors:`, spec §5.4) —
# name + role only, shown on the highway when a song is loaded.
# Only sloppak/feedpak packs carry a manifest; loose/archive
# sources get []. The frontend uses a non-empty list as the gate
# for the credits overlay, so minigames / synthetic highway uses
# (no manifest) never trigger it.
"authors": _sanitize_authors(loaded_slop.manifest) if (is_slop and loaded_slop is not None) else [],
"stems": stems_payload,
# Full-mix audio (sloppak `original_audio:`) served alongside the
# separate `stems`. The stems plugin plays this single file while
# every stem slider is at unity and switches to the separate stems
# the moment one drops below 100%. None when the pack ships stems
# only. `has_*` flags mirror the has_drum_tab/has_keys convention so
# a client can branch without re-deriving from the URLs.
"original_audio_url": original_audio_url,
"has_original_audio": bool(original_audio_url),
"has_stems": bool(stems_payload),
# Surface a drum_tab presence flag so the visualization picker
# can auto-activate the drums plugin even when the chosen
# arrangement isn't named "Drums" (drum_tab.json lives next
# to the manifest, not inside the arrangements list).
"has_drum_tab": bool(
is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None
),
"has_notation": bool(
is_slop
and loaded_slop is not None
and loaded_slop.notation_by_id is not None
and _notation_arr_id is not None
and _notation_arr_id in loaded_slop.notation_by_id
),
# Song-level key/scale track presence (keys.json, spec §7.7) so a
# consumer can light up a key/scale display without parsing the pack.
"has_keys": bool(
is_slop and loaded_slop is not None and loaded_slop.keys is not None
),
})
# Send drum_tab when the sloppak ships one (manifest `drum_tab:` key,
# see lib/sloppak.py). The drums plugin subscribes to `drum_tab` for
# the kit legend and `drum_hits` for the timed hit stream. Chunked
# 500-per-frame like notes so a long song stays well under WS frame
# limits. Legacy drum sloppaks (drums encoded as guitar notes) skip
# this branch and fall through to the regular `notes` stream — the
# client-side drums plugin keeps a fallback decoder for them.
if is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None:
dt = loaded_slop.drum_tab
kit = drums_mod.normalise_kit(dt.get("kit"))
hits_wire = drums_mod.hits_to_wire(dt.get("hits") or [])
_dt_name = dt.get("name")
_dt_name = _dt_name if isinstance(_dt_name, str) and _dt_name else "Drums"
try:
await websocket.send_json({
"type": "drum_tab",
"version": int(dt.get("version", drums_mod.SCHEMA_VERSION)),
"name": _dt_name,
"kit": kit,
"total": len(hits_wire),
})
for i in range(0, len(hits_wire), 500):
await websocket.send_json({
"type": "drum_hits",
"data": hits_wire[i:i + 500],
"total": len(hits_wire),
})
except WebSocketDisconnect:
return
# Send beats
beats = [{"time": b.time, "measure": b.measure} for b in song.beats]
await websocket.send_json({"type": "beats", "data": beats})
# Send sections
sections = [{"name": s.name, "time": s.start_time} for s in song.sections]
await websocket.send_json({"type": "sections", "data": sections})
# Send the song-level key/scale track (keys.json, spec §7.7) when the
# sloppak ships one. Consumers read it from the WS rather than the file,
# like drum_tab/beats/sections. The loader already sanitized the events
# (finite t, non-empty string key, sorted), so this is a direct send.
if is_slop and loaded_slop is not None and loaded_slop.keys is not None:
await websocket.send_json({
"type": "keys",
"version": int(loaded_slop.keys.get("version", 1)),
"data": loaded_slop.keys.get("events") or [],
})
# Song-level tempo + time-signature maps (song_timeline, feedpak 1.2.0),
# plus the per-chart tempo override (§6.10): the active arrangement's own
# `tempos` wins over the song-level map for this chart. Both are
# pre-sanitized by the loader / arrangement_from_wire, so they stream
# directly. Consumers read these rather than the file.
_song_tempos = loaded_slop.tempos if (is_slop and loaded_slop is not None) else None
_tempos_out = getattr(arr, "tempos", None) or _song_tempos
if _tempos_out:
await websocket.send_json({"type": "tempos", "data": _tempos_out})
_time_sigs = (loaded_slop.time_signatures
if (is_slop and loaded_slop is not None) else None)
if _time_sigs:
await websocket.send_json({"type": "time_signatures", "data": _time_sigs})
# Send notation data when the sloppak ships it for the active arrangement.
# Slots after sections (cursor sync depends on beats, which precede sections)
# and before anchors — per docs/sloppak-spec.md §5.3.
if (
is_slop
and loaded_slop is not None
and loaded_slop.notation_by_id is not None
and _notation_arr_id is not None
and _notation_arr_id in loaded_slop.notation_by_id
):
nt = loaded_slop.notation_by_id[_notation_arr_id]
measures_wire = notation_mod.measures_to_wire(nt.get("measures") or [])
try:
await websocket.send_json({
"type": "notation_info",
"version": int(nt.get("version", notation_mod.SCHEMA_VERSION)),
"instrument": str(nt.get("instrument", "")),
"staves": nt.get("staves") or [],
"total": len(measures_wire),
})
_NOTATION_CHUNK = 32
for i in range(0, len(measures_wire), _NOTATION_CHUNK):
await websocket.send_json({
"type": "notation_measures",
"data": measures_wire[i:i + _NOTATION_CHUNK],
"total": len(measures_wire),
})
except WebSocketDisconnect:
return
# Send anchors
anchors = [anchor_to_wire(a) for a in arr.anchors]
await websocket.send_json({"type": "anchors", "data": anchors})
# Send chord templates. Include `fingers` alongside `name` /
# `frets` so plugin overlays consuming highway.getChordTemplates()
# can render full chord boxes (chord-style fingering
# diagrams), not just chord names. Each fingering entry is
# per-string: -1 = unused, 0 = open string, n > 0 = finger
# number. RS XML sources populate real values; GP imports
# currently emit all -1 (no finger data available pre-import).
templates = [chord_template_to_wire(ct) for ct in arr.chord_templates]
await websocket.send_json({"type": "chord_templates", "data": templates})
# Send lyrics if available
import xml.etree.ElementTree as ET
lyrics = []
lyrics_source = ""
# Loose folders are flat — only inspect direct children so a
# nested backup/export directory inside the song folder can't
# override the active arrangement's lyrics / tone. archives are
# unpacked into nested tmp dirs, so they keep recursive rglob.
# Sloppak skips XML lookups entirely below but the json loop
# is unconditional, so define both walkers up front.
_xml_walk = Path(tmp).glob if is_loose else Path(tmp).rglob
_json_walk = Path(tmp).glob if is_loose else Path(tmp).rglob
if is_slop:
lyrics = list(song.lyrics or [])
lyrics_source = getattr(song, "lyrics_source", "") or ""
else:
for xml_path in sorted(_xml_walk("*.xml")):
try:
root = ET.parse(xml_path).getroot()
if root.tag == "vocals":
# An empty <vocals/> shell would otherwise
# short-circuit later XML files, so only stop
# scanning when the XML actually produced lyric
# tokens — a meaningful XML further down the
# walk must still be reachable.
candidate = [
{
"t": round(float(v.get("time", "0")), 3),
"d": round(float(v.get("length", "0")), 3),
"w": v.get("lyric", ""),
}
for v in root.findall("vocal")
]
if candidate:
lyrics = candidate
lyrics_source = "xml"
break
except Exception:
pass
if lyrics:
payload = {"type": "lyrics", "data": lyrics}
if lyrics_source:
payload["source"] = lyrics_source
await websocket.send_json(payload)
# Send tone changes. archive and loose folders carry tone data in
# arrangement XMLs; a sloppak ships it inline in its arrangement JSON
# (Arrangement.tones, populated by the converter), so read it straight
# off `arr` rather than walking for XML that doesn't exist.
if is_slop:
# `sloppak_tone_changes` builds the (base, sorted changes) pair
# from `Arrangement.tones`, skipping non-string names and
# non-finite/non-numeric times — unit-tested in test_tones.py.
from tones import sloppak_tone_changes
base_name, tone_changes = sloppak_tone_changes(getattr(arr, "tones", None))
# Send when there's a base tone OR timed changes — a single-tone
# arrangement has a base but no switches, and the highway should
# still be able to show the initial tone.
if tone_changes or base_name:
await websocket.send_json({
"type": "tone_changes",
"base": base_name,
"data": tone_changes,
})
else:
xml_paths = sorted(_xml_walk("*.xml"))
# Build tone ID→name map from the manifest JSON for the selected
# arrangement. Match on the entry's `ArrangementName` field, not a
# filename-stem substring — "Lead" is a substring of "Bonus Lead",
# so the old substring test could build the map from the wrong
# arrangement. Record the matched JSON stem so the XML below can
# be paired exactly (RS names the JSON and XML with the same stem).
arr_tone_names = {} # the SELECTED arrangement's own Tone_A..D only
matched_stem = None
# Strip + lowercase both sides when matching ArrangementName,
# mirroring lib/tones.py — a manifest with padded whitespace
# must not fall through to an unrelated arrangement.
arr_name_lower = arr.name.strip().lower() if arr else ""
def _manifest_entries(path):
"""Parsed `Entries` dict for a manifest JSON, or {} if the
file isn't a well-formed manifest (non-dict top level /
Entries, unparseable JSON)."""
try:
# JSON is UTF-8; decode strictly so malformed bytes fail
# cleanly (caught below) rather than silently corrupting
# arrangement / tone names.
jdata = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
entries = jdata.get("Entries") if isinstance(jdata, dict) else None
return entries if isinstance(entries, dict) else {}
def _tone_names(attrs):
"""{idx: name} from an entry's Tone_A..Tone_D — string values
only, so a malformed manifest can't emit a non-string name."""
m = {}
for idx, key in enumerate(("Tone_A", "Tone_B", "Tone_C", "Tone_D")):
val = attrs.get(key)
if isinstance(val, str) and val:
m[idx] = val
return m
for jf in sorted(_json_walk("*.json")):
for entry in _manifest_entries(jf).values():
if not isinstance(entry, dict):
continue
attrs = entry.get("Attributes")
if not isinstance(attrs, dict):
continue
ename = attrs.get("ArrangementName")
if not isinstance(ename, str) or ename.strip().lower() != arr_name_lower:
continue
# Only the SELECTED arrangement's own Tone_A..D — never
# borrowed from another manifest. An unrelated map would
# mislabel `N/A` tone-change markers; `Tone {id}` is the
# correct fallback (matching lib/tones.py).
arr_tone_names = _tone_names(attrs)
matched_stem = jf.stem.lower()
break
if matched_stem is not None:
break
# Parse XMLs. Prefer the XML paired with the matched manifest
# (identical stem). When no manifest matched (loose/custom song), fall
# back to a name-token match — but rank by how few *extra* stem
# tokens a candidate carries, mirroring lib/tones.py: {"lead"} is
# a subset of both `song_lead` and `song_bonus_lead`, so a plain
# subset test still ties. A unique fewest-extra match wins; an
# exact tie among token candidates is treated as ambiguous —
# `_token_ambiguous` then suppresses the rank-2 best-effort
# fallback, so no arrangement's tone timeline is guessed at
# (matching lib/tones.py, which attaches nothing on a tie).
# Shared tokenizer with lib/tones.py so archive playback and
# archive→sloppak conversion select arrangement XMLs identically.
from tones import tokens as _name_tokens
_arr_tokens = _name_tokens(arr.name) if arr else set()
_token_pick = None
_token_ambiguous = False
if _arr_tokens and matched_stem is None:
_cands = []
for xp in xml_paths:
stem_tokens = _name_tokens(xp.stem)
if _arr_tokens <= stem_tokens:
_cands.append((len(stem_tokens - _arr_tokens), xp))
if _cands:
_best = min(extra for extra, _ in _cands)
_tied = [xp for extra, xp in _cands if extra == _best]
if len(_tied) == 1:
_token_pick = _tied[0]
else:
_token_ambiguous = True
def _xml_rank(xp):
if matched_stem and xp.stem.lower() == matched_stem:
return 0
if _token_pick is not None and xp == _token_pick:
return 1
return 2
sorted_xml = sorted(xml_paths, key=lambda xp: (_xml_rank(xp), xp.name))
# When the arrangement was positively identified (manifest stem
# pair or a unique token match), tone data must come only from
# that XML — a rank-2 fallback XML belongs to another
# arrangement. A token tie is likewise suppressed (guessing among
# equally-named XMLs would be wrong). Only a genuine no-match
# case (loose/custom song with no usable manifest and no name overlap)
# keeps the long-standing rank-2 best-effort source.
_suppress_fallback = (
matched_stem is not None or _token_pick is not None or _token_ambiguous
)
sent_tones = False
tone_base = "" # <tonebase> of the preferred arrangement XML
for xml_path in sorted_xml:
try:
root = ET.parse(xml_path).getroot()
if root.tag != "song":
continue
if _suppress_fallback and _xml_rank(xml_path) == 2:
# Don't read tones from an unrelated arrangement's XML.
continue
# Capture the base tone from the first XML the loop
# accepts. The skip above already excluded untrusted
# rank-2 XMLs whenever a match was confirmed; in the
# genuine no-match case rank-2 IS the best-effort source,
# so its <tonebase> is equally valid for a base-only song.
if not tone_base:
_tb = root.find("tonebase")
if _tb is not None and _tb.text:
# Strip whitespace from pretty-printed XML so the
# base name matches the sloppak path, which also
# strips it.
tone_base = _tb.text.strip()
tones_el = root.find("tones")
if tones_el is not None:
# Accumulate into a per-XML list — if this file
# raises partway through, its partial changes are
# discarded rather than bleeding into the next
# candidate XML.
xml_tone_changes = []
for t in tones_el.findall("tone"):
tc_time = t.get("time")
tc_name = t.get("name", "")
tc_id = t.get("id", "")
# Resolve "N/A" or empty names via the selected
# arrangement's own tone map; `Tone {id}` when it
# has none (never another arrangement's names).
if (not tc_name or tc_name == "N/A") and tc_id:
try:
tc_name = arr_tone_names.get(int(tc_id), f"Tone {tc_id}")
except (TypeError, ValueError):
pass
if tc_time and tc_name:
# Skip a single malformed/non-finite marker
# rather than letting it raise — the outer
# `except` would otherwise swallow the whole
# XML and drop every tone change. NaN/inf
# would also produce client-unparseable JSON.
try:
tc_t = float(tc_time)
except (TypeError, ValueError):
continue
if not math.isfinite(tc_t):
continue
xml_tone_changes.append({
"t": round(tc_t, 3),
"name": tc_name,
})
if xml_tone_changes:
tonebase = root.find("tonebase")
base_name = tonebase.text.strip() if tonebase is not None and tonebase.text else ""
# If base name not in XML, use the selected
# arrangement's own Tone_A.
if not base_name:
base_name = arr_tone_names.get(0, "")
await websocket.send_json({
"type": "tone_changes",
"base": base_name,
"data": sorted(xml_tone_changes, key=lambda x: x["t"]),
})
sent_tones = True
break
except (ET.ParseError, OSError) as e:
# Only swallow unreadable/malformed XML — skip to the next
# candidate. A blanket `except` here would also eat a
# `WebSocketDisconnect` from `send_json`; let that bubble
# to the handler's outer disconnect handler.
log.debug(
"highway: skipping unreadable arrangement XML %s: %s",
xml_path.name, e,
)
continue
# Base-only fallback: a single-tone arrangement has a <tonebase>
# but no <tones> markers — still surface the initial tone so the
# highway can show it (parity with the sloppak path above).
# `tone_base` is the <tonebase> of whichever XML the loop
# accepted: the confirmed-match XML, or — in the genuine no-match
# case — the best-effort rank-2 XML. `arr_tone_names` holds the
# selected arrangement's own Tone_A..D. An ambiguous arrangement
# (token tie) accepts no XML and has no manifest map, so it
# correctly sends nothing rather than a guessed tone.
if not sent_tones:
base_name = tone_base
if not base_name:
base_name = arr_tone_names.get(0, "")
if base_name:
await websocket.send_json({
"type": "tone_changes",
"base": base_name,
"data": [],
})
# Teaching mark sd (§6.2.2): derive each note's scale degree from the
# active key (keys.json §7.7) + its sounding pitch (tuning[string] +
# fret), only when the author didn't author one. Display/teaching only —
# NEVER feeds grading. Notes whose string/fret has no tuning entry, or
# that have no active key, or whose key name is unparseable, stay unset.
_key_events = (
(loaded_slop.keys.get("events") or [])
if (is_slop and loaded_slop is not None and loaded_slop.keys is not None)
else []
)
_key_times = [e["t"] for e in _key_events]
_key_tonics = [key_to_tonic_pc(e.get("key")) for e in _key_events]
_tuning = arr.tuning or []
# Hoist the open-string base out of the per-note loop: arr.tuning holds
# per-string OFFSETS from standard, so the sounding pitch is
# base[string] + offset + capo + fret (matches the tuner / open-string
# labels). arrangement_string_count is O(notes), so compute once here.
_base = base_open_string_midis(
arrangement_string_count(arr), "bass" in (arr.name or "").lower())
_capo = int(getattr(arr, "capo", 0) or 0)
def _fill_scale_degree(wire: dict, n, t: float) -> None:
# Author-provided sd wins — note_to_wire already emitted it.
if "sd" in wire or not _key_times:
return
idx = bisect.bisect_right(_key_times, t) - 1
if idx < 0:
return
tonic = _key_tonics[idx]
if tonic is None:
return
midi = pitch_from_base(_base, _capo, _tuning, n.string, n.fret)
if midi is None:
return
wire["sd"] = scale_degree_for_pitch(midi, tonic)
# Send notes in chunks
notes = []
for n in arr.notes:
w = note_to_wire(n)
_fill_scale_degree(w, n, n.time)
notes.append(w)
# Send in chunks of 500
for i in range(0, len(notes), 500):
await websocket.send_json({
"type": "notes",
"data": notes[i:i+500],
"total": len(notes),
})
# Send chords
chords = []
for c in arr.chords:
cw = chord_to_wire(c)
for cn, cnw in zip(c.notes, cw.get("notes", [])):
_fill_scale_degree(cnw, cn, c.time)
chords.append(cw)
for i in range(0, len(chords), 500):
await websocket.send_json({
"type": "chords",
"data": chords[i:i+500],
"total": len(chords),
})
hand_shapes_out = [hand_shape_to_wire(h) for h in arr.hand_shapes]
for i in range(0, len(hand_shapes_out), 500):
await websocket.send_json({
"type": "handshapes",
"data": hand_shapes_out[i:i+500],
"total": len(hand_shapes_out),
})
# Per-phrase difficulty data for the master-difficulty slider
# (feedBack#48). Only sent when the source chart had multiple
# `<level>` tiers — single-level charts (GP converter, older
# sloppaks without phrase data) produce arr.phrases=None, and the
# frontend treats the missing message as "slider disabled".
# Consumers that don't know about this message type ignore it.
#
# Chunked at phrase granularity (20 phrases per frame) because
# each phrase nests per-level note/chord lists — a single frame
# could otherwise exceed proxy/WS size limits on large songs.
# Chunk boundary is per-phrase (not per-level) so the frontend
# reassembles whole phrase ladders.
if arr.phrases:
total = len(arr.phrases)
for i in range(0, total, 20):
await websocket.send_json({
"type": "phrases",
"data": [phrase_to_wire(p) for p in arr.phrases[i:i + 20]],
"total": total,
})
await websocket.send_json({"type": "ready"})
# Keep connection alive for control messages
try:
while True:
msg = await websocket.receive_text()
data = json.loads(msg)
if data.get("action") == "change_arrangement":
pass
except WebSocketDisconnect:
pass
except Exception as e:
log.exception("highway_ws unhandled error for %s", filename)
try:
await websocket.send_json({"error": str(e)})
await websocket.close()
except Exception:
pass
finally:
pass # Don't clean up — cached for arrangement switching
# ── Audio serving ─────────────────────────────────────────────────────────────
@app.get("/api/audio-local-path")
def audio_local_path(url: str, request: Request):
"""Return absolute local filesystem path for an /audio/… URL (Electron desktop only).
Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments —
no scheme, no host, no query string, no fragment. The resolved path must stay
inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and
absolute ``filename`` values are rejected.
This endpoint returns a raw filesystem path and is intended exclusively for
the Electron desktop process (which runs on loopback). Requests from non-
loopback clients are rejected with 403.
"""
# Loopback-only — only the local Electron process should call this
client_host = request.client.host if request.client else None
try:
is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback)
except ValueError:
is_loopback = client_host == "localhost"
if not is_loopback:
return JSONResponse({"error": "forbidden"}, status_code=403)
# Accept only simple /audio/<filename> — no scheme, no host, no query/fragment
if not re.fullmatch(r"/audio/[^?#]+", url):
return JSONResponse({"error": "invalid url"}, status_code=400)
filename = url[len("/audio/"):]
# Reject traversal, absolute paths, and backslash separators
if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename:
return JSONResponse({"error": "invalid url"}, status_code=400)
for d in [AUDIO_CACHE_DIR, STATIC_DIR]:
candidate = (d / filename).resolve()
# Ensure resolved path is inside the allowed directory
try:
candidate.relative_to(d.resolve())
except ValueError:
continue
if candidate.is_file():
return JSONResponse({"path": str(candidate)})
return JSONResponse({"error": "not found"}, status_code=404)
@app.get("/audio/{filename:path}")
def serve_audio(filename: str):
"""Serve audio files from the writable audio cache directory."""
# Reject traversal attempts and absolute-path components
if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename:
return JSONResponse({"error": "not found"}, status_code=404)
for d in [AUDIO_CACHE_DIR, STATIC_DIR]:
candidate = (d / filename).resolve()
try:
candidate.relative_to(d.resolve())
except ValueError:
continue
if candidate.is_file():
return FileResponse(str(candidate))
return JSONResponse({"error": "not found"}, status_code=404)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/")
def index():
# fee[dB]ack v0.3.0: the v3 shell is now the DEFAULT at `/`. The classic v2
# UI remains fully available as a fallback — opt back in with
# FEEDBACK_UI=v2 (or =legacy), or hit the dedicated /v2 route below (which
# serves it regardless of the env var).
if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy"):
return FileResponse(str(STATIC_DIR / "index.html"))
return FileResponse(str(STATIC_DIR / "v3" / "index.html"))
@app.get("/v3")
def index_v3():
# Always serve the v0.3.0 shell, independent of the env var (kept for
# explicit/back-compat links even though `/` now defaults to v3).
return FileResponse(str(STATIC_DIR / "v3" / "index.html"))
@app.get("/v2")
def index_v2():
# Always serve the classic v2 UI, independent of the env var, so the
# fallback is reachable without flipping FEEDBACK_UI.
return FileResponse(str(STATIC_DIR / "index.html"))