feat(career): hours-per-genre odometer — honest wall-clock play time (#942)

Career v2, WS2. Nothing measured play time before (the achievements
plugin's final-position shortcut double-counts loops and mis-reads
seeks). Now:

- stats-recorder.js accrues WALL-CLOCK seconds across song:play/resume ↔
  pause/stop/ended spans (single spans clamp at 2h against suspend
  inflation) and piggybacks them as `seconds` on the POSTs it already
  sends; failed POSTs restore the accumulator; a session reset flushes
  first so time can't re-attribute to the next song/arrangement.
- POST /api/stats accepts optional `seconds` (finite, 0 < s ≤ 6h) on the
  scored and position branches, plus a new seconds-only branch for
  unscored plays that ran to the natural end — banks time WITHOUT
  touching the resume position (song:ended must not overwrite Continue)
  and still counts as playing today for the streak.
- song_stats gains additive idempotent `seconds_total`; record_session/
  touch_position accrue, new add_play_seconds() for the seconds-only
  path; the legacy-encoding stats merge sums seconds across duplicates.
- Passports surface it: "14.2 h in Blues" under the badge stamp and on
  the shelf cover sub-line — a true fact that only grows, never a
  target or a meter (Stage 5 post-cap, per the career design).

Tests: seconds accrual/validation/seconds-only branch (stats API),
per-instrument-and-genre summing (career), fmtHours formatting (vm).
Full suites: pytest 2480, JS 1165.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-13 15:12:23 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d26347981c
commit 0fc6a4beed
11 changed files with 289 additions and 31 deletions
+46 -11
View File
@@ -614,6 +614,14 @@ class MetadataDB:
)
""")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)")
# Cumulative wall-clock play time (career "hours in genre" odometer).
# Fed by the same POST /api/stats the recorder already sends; additive
# + idempotent like every other song_stats change.
try:
self.conn.execute(
"ALTER TABLE song_stats ADD COLUMN seconds_total REAL NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Playlists + the reserved "Saved for Later" system playlist. Additive.
self.conn.execute("""
CREATE TABLE IF NOT EXISTS playlists (
@@ -901,6 +909,9 @@ class MetadataDB:
"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"],
# Play time is additive: both encodings' hours belong to
# the one canonical song.
"seconds_total": (cur.get("seconds_total") or 0.0) + (r.get("seconds_total") or 0.0),
"last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"],
}
# Atomic swap: clear and reinsert the canonicalized set in one txn.
@@ -1693,7 +1704,8 @@ class MetadataDB:
# ── Per-song practice stats ───────────────────────────────────────────---
_STATS_COLS = (
"filename", "arrangement", "plays", "best_score", "best_accuracy",
"last_score", "last_accuracy", "last_position", "last_played_at", "updated_at",
"last_score", "last_accuracy", "last_position", "seconds_total",
"last_played_at", "updated_at",
)
def _stats_row(self, filename: str, arrangement: int) -> dict | None:
@@ -2060,8 +2072,9 @@ class MetadataDB:
self.conn.commit()
def record_session(self, filename: str, arrangement: int, *, score: int,
accuracy: float, last_position=None) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new."""
accuracy: float, last_position=None, seconds: float = 0) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new.
`seconds` (wall-clock play time from the recorder) accrues."""
from song_score import merge_stats
with self._lock:
existing = self._stats_row(filename, int(arrangement))
@@ -2071,8 +2084,9 @@ class MetadataDB:
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 (?, ?, ?, ?, ?, ?, ?, ?,
last_score, last_accuracy, last_position, seconds_total,
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,
@@ -2081,32 +2095,53 @@ class MetadataDB:
last_score = excluded.last_score,
last_accuracy = excluded.last_accuracy,
last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
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"]),
merged["last_position"], float(seconds or 0)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
def touch_position(self, filename: str, arrangement: int, last_position: float) -> dict:
def touch_position(self, filename: str, arrangement: int, last_position: float,
seconds: float = 0) -> 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'."""
never surfaces as 'recent' / 'continue playing'. `seconds` accrues
wall-clock play time (career hours odometer)."""
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'),
seconds_total, 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,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), float(last_position)),
(filename, int(arrangement), float(last_position), float(seconds or 0)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
def add_play_seconds(self, filename: str, arrangement: int, seconds: float) -> dict:
"""Accrue wall-clock play time only (no plays/score/position change) —
the recorder's seconds-only flush for unscored plays that ran to the
song's natural end (no resume position to touch there: `song:ended`
must not overwrite Continue with the end-of-song offset)."""
with self._lock:
self.conn.execute(
"""INSERT INTO song_stats (filename, arrangement, seconds_total, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
seconds_total = song_stats.seconds_total + excluded.seconds_total,
updated_at = excluded.updated_at""",
(filename, int(arrangement), float(seconds)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
+34 -2
View File
@@ -76,6 +76,22 @@ def api_record_stats(data: dict):
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)
# Optional wall-clock play time (career hours odometer). Bounded per POST:
# the recorder flushes on pause/stop/end, so a single delta beyond 6h is a
# clock artifact (suspend/sleep), not practice.
seconds = data.get("seconds")
if seconds is not None:
if isinstance(seconds, bool):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
try:
seconds = float(seconds)
if not math.isfinite(seconds):
raise ValueError("non-finite")
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
if not (0 < seconds <= 6 * 3600):
return JSONResponse({"error": "seconds must be between 0 and 21600"}, status_code=400)
seconds = seconds or 0.0
# A scored session needs BOTH score and accuracy. Exactly one provided is
# ambiguous — don't silently fall through to the position-only branch.
@@ -115,7 +131,8 @@ def api_record_stats(data: dict):
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)
accuracy=accuracy, last_position=last_pos,
seconds=seconds)
# Unified XP + streak side-effects — never let these drop the stat write.
progress = None
try:
@@ -152,6 +169,21 @@ def api_record_stats(data: dict):
log.warning("stats side-effects (progression) failed", exc_info=True)
return {"stats": row, "progress": progress, "progression": progression_summary}
# Seconds-only accrual: an unscored play that ran to the song's natural
# end has play time to bank but no resume position to touch (song:ended
# must not overwrite Continue with the end-of-song offset). Still counts
# as playing today for the streak below.
if last_pos is None and seconds:
row = appstate.meta_db.add_play_seconds(filename, arrangement, seconds)
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}
# Position-only touch.
if last_pos is None:
return JSONResponse(
@@ -162,7 +194,7 @@ def api_record_stats(data: dict):
pos = float(last_pos)
if not math.isfinite(pos):
raise ValueError("non-finite")
row = appstate.meta_db.touch_position(filename, arrangement, pos)
row = appstate.meta_db.touch_position(filename, arrangement, pos, seconds=seconds)
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 —