diff --git a/lib/metadata_db.py b/lib/metadata_db.py index 29a3f15..5b3676c 100644 --- a/lib/metadata_db.py +++ b/lib/metadata_db.py @@ -2130,16 +2130,25 @@ class MetadataDB: 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) — + """Accrue wall-clock play time (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).""" + must not overwrite Continue with the end-of-song offset). Stamps + last_played_at like touch_position does: the song WAS played, so + /api/stats/recent and Continue ordering must see it. Accepted skew: + the recorder retries FAILED flushes later, which stamps recency at + retry time — rare (offline corner), self-healing on the next play, + and preferable to the alternative (keep-existing would leave repeat + plays looking stale, the common case).""" 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')) + """INSERT INTO song_stats (filename, arrangement, 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 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(seconds)), ) diff --git a/static/v3/stats-recorder.js b/static/v3/stats-recorder.js index 4dedd4b..cb25235 100644 --- a/static/v3/stats-recorder.js +++ b/static/v3/stats-recorder.js @@ -101,6 +101,10 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); + // A 4xx/5xx JSON error body must read as FAILURE — callers + // re-queue accrued seconds on null, and a parsed error object + // would silently drop them. + if (!r.ok) return null; try { return await r.json(); } catch (e) { return null; } } catch (e) { return null; /* offline / endpoint absent — non-fatal */ } } diff --git a/tests/test_song_stats_api.py b/tests/test_song_stats_api.py index cb2ebf9..28e0a33 100644 --- a/tests/test_song_stats_api.py +++ b/tests/test_song_stats_api.py @@ -480,6 +480,8 @@ def test_seconds_only_post_accrues_without_touching_position(client): # overwrite Continue with the end-of-song offset). assert row["plays"] == 0 assert row["last_position"] == pytest.approx(42.0) + # But the song WAS played — recency ordering must see it. + assert row["last_played_at"] # Still counts as playing today for the streak. assert r.json()["progress"]["current_streak"] == 1