diff --git a/docs/size-exemptions.md b/docs/size-exemptions.md index a2fa7d4..2f3f7db 100644 --- a/docs/size-exemptions.md +++ b/docs/size-exemptions.md @@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable. ## Planned, NOT exempt (owned by split plans — listed so nothing falls between states) core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py` -(7,478 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` -extractions and eleven `routers/` modules) · +(7,275 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` +extractions and twelve `routers/` modules) · `lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines and is a monolith in its own right, to be split per-table once the router train lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js` diff --git a/lib/routers/stats.py b/lib/routers/stats.py new file mode 100644 index 0000000..d6be701 --- /dev/null +++ b/lib/routers/stats.py @@ -0,0 +1,233 @@ +"""Gameplay scoring — XP award + per-song practice stats (record / recent / best / +top / per-song). The `/api/stats/{filename:path}` route is registered LAST so its +catch-all doesn't shadow the fixed /recent /best /top paths. + +Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``, +``meta_db`` -> ``appstate.meta_db``, ``_get_progression_content()`` / +``_builtin_diagnostic_filename()`` read through the seam. +""" + +import logging +import math + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +import appstate +from metadata_db import _as_int +from reqfields import _clean_str + +log = logging.getLogger("feedBack.server") + +router = APIRouter() + + +@router.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) + appstate.meta_db.award_xp(amount) + return appstate.meta_db.get_progress() + + +@router.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 = appstate.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 = appstate.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 = appstate.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 + appstate.meta_db.award_xp(xp_for_run(score)) + appstate.meta_db.record_active_day(date.today().isoformat()) + progress = appstate.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( + appstate.meta_db.arrangement_entry(filename, arrangement) + ) + progression_summary = appstate.meta_db.record_progression_event( + "song_completed", + { + "filename": filename, + "instrument": instrument, + "accuracy": accuracy, + "score": score, + "is_diagnostic": filename == appstate.builtin_diagnostic_filename(), + }, + appstate.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 = appstate.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 + appstate.meta_db.record_active_day(date.today().isoformat()) + progress = appstate.meta_db.get_progress() + except Exception: + log.warning("stats side-effects (streak) failed", exc_info=True) + return {"stats": row, "progress": progress} + + +@router.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 appstate.meta_db.recent_stats(limit): + meta = appstate.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 + + +@router.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 appstate.meta_db.best_accuracy_map() + + +@router.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 appstate.meta_db.top_stats(limit): + meta = appstate.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 + + +@router.get("/api/stats/{filename:path}") +def api_song_stats(filename: str): + return appstate.meta_db.get_song_stats(filename) diff --git a/server.py b/server.py index f6de51f..a65e72b 100644 --- a/server.py +++ b/server.py @@ -55,7 +55,7 @@ from dlc_paths import _get_dlc_dir, _resolve_dlc_path # Lives in lib/ because that is the one core dir every packaging path copies. import appstate # Extracted route modules. They import `appstate`, never `server` — one-way graph. -from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile +from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats import sloppak as sloppak_mod import loosefolder as loosefolder_mod # Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/ @@ -4670,21 +4670,10 @@ def api_artist_links_refresh(name: str): app.include_router(profile.router) -@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() +# ── Gameplay scoring: XP award + per-song practice stats ───────────────────── +# Mounted here (registration order; /api/stats/{path} is last inside the router). +# Implementation in lib/routers/stats.py. +app.include_router(stats.router) # ── Progression (spec 010) ─────────────────────────────────────────────────── @@ -4697,195 +4686,6 @@ app.include_router(progression.router) app.include_router(shop.router) -# ── Per-song practice stats (fee[dB]ack v0.3.0) ─────────────────────────────── - -@app.post("/api/stats") -def api_record_stats(data: dict): - """Record a play. With `score`+`accuracy` → a scored session (plays += 1, - best_* = max, last_* = new) plus unified-XP + streak side-effects. With - only `lastPlayPosition`/`last_position` → a lightweight resume-position - touch (no plays change) so Continue-Playing works for non-scored plays.""" - filename = _clean_str(data.get("filename")) - if not filename: - return JSONResponse({"error": "filename required"}, status_code=400) - # The recorder hands us URL-encoded filenames; canonicalize to the library - # key so stored rows line up with `songs` (and so the arrangement-count bound - # below resolves the real song). See MetadataDB._canonical_song_filename. - filename = meta_db._canonical_song_filename(filename) - arr_raw = data.get("arrangement", 0) - if arr_raw is None: - arrangement = 0 - else: - try: - arrangement = _as_int(arr_raw) # rejects bool / non-integral (1.9) / inf - except (TypeError, ValueError, OverflowError): - return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400) - # Reject (don't silently coerce to 0) so a malformed/out-of-range index - # can't corrupt arrangement 0's stats; also keeps it bindable to INTEGER. - if not (0 <= arrangement < 2**63): - return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400) - # Bound against the song's real arrangement count when it's a known library - # song, so a bad index can't create fake arrangement buckets that poison the - # per-song aggregate / Continue. Skipped when the song isn't in the library - # yet (count unknown — dead-song reads are filtered anyway). - _acount = meta_db.arrangement_count(filename) - if _acount and arrangement >= _acount: - return JSONResponse({"error": "arrangement out of range for this song"}, status_code=400) - score = data.get("score") - accuracy = data.get("accuracy") - last_pos = data.get("lastPlayPosition", data.get("last_position")) - if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position - return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) - - # A scored session needs BOTH score and accuracy. Exactly one provided is - # ambiguous — don't silently fall through to the position-only branch. - if (score is None) != (accuracy is None): - return JSONResponse({"error": "score and accuracy must be provided together"}, status_code=400) - - if score is not None and accuracy is not None: - # Reject booleans explicitly — float(True) would otherwise record a play. - if isinstance(score, bool) or isinstance(accuracy, bool): - return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400) - # Reject NaN/Inf too: round(inf) raises OverflowError (→ 500), and a - # stored Inf/NaN later breaks JSON serialization of /api/stats reads. - try: - score = float(score) - accuracy = float(accuracy) - if not (math.isfinite(score) and math.isfinite(accuracy)): - raise ValueError("non-finite") - score = int(round(score)) - except (TypeError, ValueError, OverflowError): - return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400) - # A huge-but-finite score passes isfinite() yet overflows SQLite's - # 64-bit INTEGER on bind (→ 500). Bound it to the int64 range. - if not (0 <= score < 2**63): - return JSONResponse({"error": "score out of range"}, status_code=400) - # accuracy is a 0..1 fraction (the recorder's contract); reject - # out-of-range values so they don't surface as >100% / negative in - # /api/stats/best and the badge UI. - if not (0 <= accuracy <= 1): - return JSONResponse({"error": "accuracy must be between 0 and 1"}, status_code=400) - # Validate the optional resume position in this branch too (the - # position-only branch below already rejects non-finite). - if last_pos is not None: - try: - last_pos = float(last_pos) - if not math.isfinite(last_pos): - raise ValueError("non-finite") - except (TypeError, ValueError, OverflowError): - return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) - row = meta_db.record_session(filename, arrangement, score=score, - accuracy=accuracy, last_position=last_pos) - # Unified XP + streak side-effects — never let these drop the stat write. - progress = None - try: - from xp import xp_for_run - from datetime import date - meta_db.award_xp(xp_for_run(score)) - meta_db.record_active_day(date.today().isoformat()) - progress = meta_db.get_progress() - except Exception: - log.warning("stats side-effects (xp/streak) failed", exc_info=True) - # Progression engine (spec 010) — same never-drop-the-stat-write - # contract. Scored sessions are the server-derived `song_completed` - # authority (scored == note detection by construction); instrument is - # resolved from library arrangement metadata, after the XP award so - # db_earned goals see this run's Decibels. - progression_summary = None - try: - import progression as progression_mod - instrument = progression_mod.instrument_for_arrangement( - meta_db.arrangement_entry(filename, arrangement) - ) - progression_summary = meta_db.record_progression_event( - "song_completed", - { - "filename": filename, - "instrument": instrument, - "accuracy": accuracy, - "score": score, - "is_diagnostic": filename == _builtin_diagnostic_filename(), - }, - _get_progression_content(), - ) - except Exception: - log.warning("stats side-effects (progression) failed", exc_info=True) - return {"stats": row, "progress": progress, "progression": progression_summary} - - # Position-only touch. - if last_pos is None: - return JSONResponse( - {"error": "provide score+accuracy (scored) or lastPlayPosition (resume)"}, - status_code=400, - ) - try: - pos = float(last_pos) - if not math.isfinite(pos): - raise ValueError("non-finite") - row = meta_db.touch_position(filename, arrangement, pos) - except (TypeError, ValueError, OverflowError): - return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) - # A resume session still counts as playing today: advance the streak (no XP — - # that's scoring-only) so a non-scored practice day keeps the streak alive, - # consistent with these sessions also surfacing in recent / continue. - progress = None - try: - from datetime import date - meta_db.record_active_day(date.today().isoformat()) - progress = meta_db.get_progress() - except Exception: - log.warning("stats side-effects (streak) failed", exc_info=True) - return {"stats": row, "progress": progress} - - -@app.get("/api/stats/recent") -def api_recent_stats(limit: int = 12): - """Recently-played rows joined to song metadata for 'Jump back in'.""" - from urllib.parse import quote - out = [] - for r in meta_db.recent_stats(limit): - meta = meta_db.conn.execute( - "SELECT title, artist, tuning_name FROM songs WHERE filename = ?", - (r["filename"],), - ).fetchone() - title, artist, tuning_name = meta if meta else (None, None, None) - out.append({ - **r, - "title": title or r["filename"], - "artist": artist or "", - "tuning_name": tuning_name or "", - "art_url": f"/api/song/{quote(r['filename'])}/art", - }) - return out - - -@app.get("/api/stats/best") -def api_stats_best(): - """{filename: best_accuracy} for all songs with a recorded best — one call - to badge the library grid (defined before the {filename} catch-all).""" - return meta_db.best_accuracy_map() - - -@app.get("/api/stats/top") -def api_top_stats(limit: int = 5): - """Top scored songs (best first), joined to song metadata, for the profile - 'Your best scores' panel (defined before the {filename} catch-all).""" - from urllib.parse import quote - out = [] - for r in meta_db.top_stats(limit): - meta = meta_db.conn.execute( - "SELECT title, artist, tuning_name FROM songs WHERE filename = ?", - (r["filename"],), - ).fetchone() - title, artist, tuning_name = meta if meta else (None, None, None) - out.append({ - **r, - "title": title or r["filename"], - "artist": artist or "", - "tuning_name": tuning_name or "", - "art_url": f"/api/song/{quote(r['filename'])}/art", - }) - return out @app.get("/api/library/practice-suggestions") @@ -4912,9 +4712,6 @@ def api_practice_suggestions(limit: int = 8): return out -@app.get("/api/stats/{filename:path}") -def api_song_stats(filename: str): - return meta_db.get_song_stats(filename) # ── Playlists / custom covers (fee[dB]ack v0.3.0) ─────────────────────────────