fix(career): hours polish — recency stamp + non-2xx POST is a failure (#947)

CodeRabbit follow-up on #942:

- add_play_seconds() now stamps last_played_at (like touch_position):
  an unscored play that ran to the natural end WAS played — recent /
  Continue ordering must see it. Resume position stays untouched.
- stats-recorder post() treats non-2xx as failure: a 4xx/5xx JSON error
  body parsed as an object read as success, silently dropping the
  accrued seconds instead of re-queuing them.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos 2026-07-13 15:31:12 +02:00 committed by GitHub
parent b85496fe58
commit 3e57ba0345
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 19 additions and 4 deletions

View File

@ -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)),
)

View File

@ -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 */ }
}

View File

@ -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