From 34ba6024055510be4734bf3486e7a02b34dc5ffb Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 19 Mar 2026 21:31:47 -0700 Subject: [PATCH] fix(stats): persist anime episode progress checkpoints --- ...03-19-stats-session-progress-checkpoint.md | 4 ++ .../immersion-tracker-service.test.ts | 41 ++++++++++- .../services/immersion-tracker-service.ts | 1 + .../immersion-tracker/__tests__/query.test.ts | 68 +++++++++++++++++++ .../services/immersion-tracker/lifetime.ts | 4 +- src/core/services/immersion-tracker/query.ts | 6 +- .../services/immersion-tracker/storage.ts | 15 +++- src/core/services/immersion-tracker/types.ts | 1 + 8 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 changes/2026-03-19-stats-session-progress-checkpoint.md diff --git a/changes/2026-03-19-stats-session-progress-checkpoint.md b/changes/2026-03-19-stats-session-progress-checkpoint.md new file mode 100644 index 0000000..832f65d --- /dev/null +++ b/changes/2026-03-19-stats-session-progress-checkpoint.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Anime episode progress now keeps the latest known playback position through active-session checkpoints and stale-session recovery, so recently watched episodes no longer lose their progress percentage. diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index cc38569..741025f 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -657,6 +657,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a video_id, started_at_ms, status, + ended_media_ms, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( @@ -665,6 +666,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a 1, ${startedAtMs}, 1, + 321000, ${startedAtMs}, ${sampleMs} ); @@ -709,7 +711,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a const sessionRow = restartedApi.db .prepare( ` - SELECT ended_at_ms, status, active_watched_ms, tokens_seen, cards_mined + SELECT ended_at_ms, status, ended_media_ms, active_watched_ms, tokens_seen, cards_mined FROM imm_sessions WHERE session_id = 1 `, @@ -717,6 +719,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a .get() as { ended_at_ms: number | null; status: number; + ended_media_ms: number | null; active_watched_ms: number; tokens_seen: number; cards_mined: number; @@ -751,6 +754,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a assert.ok(sessionRow); assert.ok(Number(sessionRow?.ended_at_ms ?? 0) >= sampleMs); assert.equal(sessionRow?.status, 2); + assert.equal(sessionRow?.ended_media_ms, 321_000); assert.equal(sessionRow?.active_watched_ms, 4000); assert.equal(sessionRow?.tokens_seen, 120); assert.equal(sessionRow?.cards_mined, 2); @@ -1230,6 +1234,41 @@ test('recordPlaybackPosition marks watched at 85% completion', async () => { } }); +test('flushTelemetry checkpoints latest playback position on the active session row', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + + tracker.handleMediaChange('/tmp/episode-progress-checkpoint.mkv', 'Episode Progress Checkpoint'); + tracker.recordPlaybackPosition(91); + + const privateApi = tracker as unknown as { + db: DatabaseSync; + sessionState: { sessionId: number } | null; + flushTelemetry: (force?: boolean) => void; + flushNow: () => void; + }; + const sessionId = privateApi.sessionState?.sessionId; + assert.ok(sessionId); + + privateApi.flushTelemetry(true); + privateApi.flushNow(); + + const row = privateApi.db + .prepare('SELECT ended_media_ms FROM imm_sessions WHERE session_id = ?') + .get(sessionId) as { ended_media_ms: number | null } | null; + + assert.ok(row); + assert.equal(row?.ended_media_ms, 91_000); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('deleteSession ignores the currently active session and keeps new writes flushable', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index ed27a0a..97df132 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -1069,6 +1069,7 @@ export class ImmersionTrackerService { kind: 'telemetry', sessionId: this.sessionState.sessionId, sampleMs: Date.now(), + lastMediaMs: this.sessionState.lastMediaMs, totalWatchedMs: this.sessionState.totalWatchedMs, activeWatchedMs: this.sessionState.activeWatchedMs, linesSeen: this.sessionState.linesSeen, diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index 0d155c1..ed1a1dc 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -139,6 +139,74 @@ test('getSessionSummaries returns sessionId and canonicalTitle', () => { } }); +test('getAnimeEpisodes prefers the latest session media position when the latest session is still active', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/active-progress-episode.mkv', { + canonicalTitle: 'Active Progress Episode', + sourcePath: '/tmp/active-progress-episode.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Active Progress Anime', + canonicalTitle: 'Active Progress Anime', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: 'active-progress-episode.mkv', + parsedTitle: 'Active Progress Anime', + parsedSeason: 1, + parsedEpisode: 2, + parserSource: 'fallback', + parserConfidence: 1, + parseMetadataJson: '{"episode":2}', + }); + + const endedSessionId = startSessionRecord(db, videoId, 1_000_000).sessionId; + const activeSessionId = startSessionRecord(db, videoId, 1_010_000).sessionId; + db.prepare( + ` + UPDATE imm_sessions + SET + ended_at_ms = ?, + status = 2, + ended_media_ms = ?, + active_watched_ms = ?, + LAST_UPDATE_DATE = ? + WHERE session_id = ? + `, + ).run(1_005_000, 6_000, 3_000, 1_005_000, endedSessionId); + db.prepare( + ` + UPDATE imm_sessions + SET + ended_media_ms = ?, + active_watched_ms = ?, + LAST_UPDATE_DATE = ? + WHERE session_id = ? + `, + ).run(9_000, 4_000, 1_012_000, activeSessionId); + + const [episode] = getAnimeEpisodes(db, animeId); + assert.ok(episode); + assert.equal(episode?.endedMediaMs, 9_000); + assert.equal(episode?.totalSessions, 2); + assert.equal(episode?.totalActiveMs, 7_000); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('getSessionTimeline returns the full session when no limit is provided', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); diff --git a/src/core/services/immersion-tracker/lifetime.ts b/src/core/services/immersion-tracker/lifetime.ts index 7f11ca2..f277bef 100644 --- a/src/core/services/immersion-tracker/lifetime.ts +++ b/src/core/services/immersion-tracker/lifetime.ts @@ -42,6 +42,7 @@ interface RetainedSessionRow { videoId: number; startedAtMs: number; endedAtMs: number; + lastMediaMs: number | null; totalWatchedMs: number; activeWatchedMs: number; linesSeen: number; @@ -140,7 +141,7 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState { startedAtMs: row.startedAtMs, currentLineIndex: 0, lastWallClockMs: row.endedAtMs, - lastMediaMs: null, + lastMediaMs: row.lastMediaMs, lastPauseStartMs: null, isPaused: false, pendingTelemetry: false, @@ -170,6 +171,7 @@ function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[] s.video_id AS videoId, s.started_at_ms AS startedAtMs, COALESCE(t.sample_ms, s.LAST_UPDATE_DATE, s.started_at_ms) AS endedAtMs, + s.ended_media_ms AS lastMediaMs, COALESCE(t.total_watched_ms, s.total_watched_ms, 0) AS totalWatchedMs, COALESCE(t.active_watched_ms, s.active_watched_ms, 0) AS activeWatchedMs, COALESCE(t.lines_seen, s.lines_seen, 0) AS linesSeen, diff --git a/src/core/services/immersion-tracker/query.ts b/src/core/services/immersion-tracker/query.ts index f510f39..447da77 100644 --- a/src/core/services/immersion-tracker/query.ts +++ b/src/core/services/immersion-tracker/query.ts @@ -1748,8 +1748,10 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod SELECT s_recent.ended_media_ms FROM imm_sessions s_recent WHERE s_recent.video_id = v.video_id - AND s_recent.ended_at_ms IS NOT NULL - ORDER BY s_recent.ended_at_ms DESC, s_recent.session_id DESC + AND s_recent.ended_media_ms IS NOT NULL + ORDER BY + COALESCE(s_recent.ended_at_ms, s_recent.LAST_UPDATE_DATE, s_recent.started_at_ms) DESC, + s_recent.session_id DESC LIMIT 1 ) AS endedMediaMs, v.watched AS watched, diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index c4ee8e0..98f3ae8 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -6,6 +6,7 @@ import type { QueuedWrite, VideoMetadata } from './types'; export interface TrackerPreparedStatements { telemetryInsertStmt: ReturnType; + sessionCheckpointStmt: ReturnType; eventInsertStmt: ReturnType; wordUpsertStmt: ReturnType; kanjiUpsertStmt: ReturnType; @@ -1161,6 +1162,14 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `), + sessionCheckpointStmt: db.prepare(` + UPDATE imm_sessions + SET + ended_media_ms = ?, + LAST_UPDATE_DATE = ? + WHERE session_id = ? + AND ended_at_ms IS NULL + `), eventInsertStmt: db.prepare(` INSERT INTO imm_session_events ( session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms, @@ -1295,6 +1304,7 @@ function incrementKanjiAggregate( export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedStatements): void { if (write.kind === 'telemetry') { + const nowMs = Date.now(); stmts.telemetryInsertStmt.run( write.sessionId, write.sampleMs!, @@ -1311,9 +1321,10 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta write.seekForwardCount!, write.seekBackwardCount!, write.mediaBufferEvents!, - Date.now(), - Date.now(), + nowMs, + nowMs, ); + stmts.sessionCheckpointStmt.run(write.lastMediaMs ?? null, nowMs, write.sessionId); return; } if (write.kind === 'word') { diff --git a/src/core/services/immersion-tracker/types.ts b/src/core/services/immersion-tracker/types.ts index 3e01247..f484a98 100644 --- a/src/core/services/immersion-tracker/types.ts +++ b/src/core/services/immersion-tracker/types.ts @@ -85,6 +85,7 @@ interface QueuedTelemetryWrite { kind: 'telemetry'; sessionId: number; sampleMs?: number; + lastMediaMs?: number | null; totalWatchedMs?: number; activeWatchedMs?: number; linesSeen?: number;