fix(stats): persist anime episode progress checkpoints

This commit is contained in:
2026-03-19 21:31:47 -07:00
parent ecb4b07f43
commit 34ba602405
8 changed files with 134 additions and 6 deletions

View File

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

View File

@@ -657,6 +657,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
video_id, video_id,
started_at_ms, started_at_ms,
status, status,
ended_media_ms,
CREATED_DATE, CREATED_DATE,
LAST_UPDATE_DATE LAST_UPDATE_DATE
) VALUES ( ) VALUES (
@@ -665,6 +666,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
1, 1,
${startedAtMs}, ${startedAtMs},
1, 1,
321000,
${startedAtMs}, ${startedAtMs},
${sampleMs} ${sampleMs}
); );
@@ -709,7 +711,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
const sessionRow = restartedApi.db const sessionRow = restartedApi.db
.prepare( .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 FROM imm_sessions
WHERE session_id = 1 WHERE session_id = 1
`, `,
@@ -717,6 +719,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
.get() as { .get() as {
ended_at_ms: number | null; ended_at_ms: number | null;
status: number; status: number;
ended_media_ms: number | null;
active_watched_ms: number; active_watched_ms: number;
tokens_seen: number; tokens_seen: number;
cards_mined: number; cards_mined: number;
@@ -751,6 +754,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
assert.ok(sessionRow); assert.ok(sessionRow);
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) >= sampleMs); assert.ok(Number(sessionRow?.ended_at_ms ?? 0) >= sampleMs);
assert.equal(sessionRow?.status, 2); assert.equal(sessionRow?.status, 2);
assert.equal(sessionRow?.ended_media_ms, 321_000);
assert.equal(sessionRow?.active_watched_ms, 4000); assert.equal(sessionRow?.active_watched_ms, 4000);
assert.equal(sessionRow?.tokens_seen, 120); assert.equal(sessionRow?.tokens_seen, 120);
assert.equal(sessionRow?.cards_mined, 2); 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 () => { test('deleteSession ignores the currently active session and keeps new writes flushable', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;

View File

@@ -1069,6 +1069,7 @@ export class ImmersionTrackerService {
kind: 'telemetry', kind: 'telemetry',
sessionId: this.sessionState.sessionId, sessionId: this.sessionState.sessionId,
sampleMs: Date.now(), sampleMs: Date.now(),
lastMediaMs: this.sessionState.lastMediaMs,
totalWatchedMs: this.sessionState.totalWatchedMs, totalWatchedMs: this.sessionState.totalWatchedMs,
activeWatchedMs: this.sessionState.activeWatchedMs, activeWatchedMs: this.sessionState.activeWatchedMs,
linesSeen: this.sessionState.linesSeen, linesSeen: this.sessionState.linesSeen,

View File

@@ -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', () => { test('getSessionTimeline returns the full session when no limit is provided', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);

View File

@@ -42,6 +42,7 @@ interface RetainedSessionRow {
videoId: number; videoId: number;
startedAtMs: number; startedAtMs: number;
endedAtMs: number; endedAtMs: number;
lastMediaMs: number | null;
totalWatchedMs: number; totalWatchedMs: number;
activeWatchedMs: number; activeWatchedMs: number;
linesSeen: number; linesSeen: number;
@@ -140,7 +141,7 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState {
startedAtMs: row.startedAtMs, startedAtMs: row.startedAtMs,
currentLineIndex: 0, currentLineIndex: 0,
lastWallClockMs: row.endedAtMs, lastWallClockMs: row.endedAtMs,
lastMediaMs: null, lastMediaMs: row.lastMediaMs,
lastPauseStartMs: null, lastPauseStartMs: null,
isPaused: false, isPaused: false,
pendingTelemetry: false, pendingTelemetry: false,
@@ -170,6 +171,7 @@ function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[]
s.video_id AS videoId, s.video_id AS videoId,
s.started_at_ms AS startedAtMs, s.started_at_ms AS startedAtMs,
COALESCE(t.sample_ms, s.LAST_UPDATE_DATE, s.started_at_ms) AS endedAtMs, 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.total_watched_ms, s.total_watched_ms, 0) AS totalWatchedMs,
COALESCE(t.active_watched_ms, s.active_watched_ms, 0) AS activeWatchedMs, COALESCE(t.active_watched_ms, s.active_watched_ms, 0) AS activeWatchedMs,
COALESCE(t.lines_seen, s.lines_seen, 0) AS linesSeen, COALESCE(t.lines_seen, s.lines_seen, 0) AS linesSeen,

View File

@@ -1748,8 +1748,10 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
SELECT s_recent.ended_media_ms SELECT s_recent.ended_media_ms
FROM imm_sessions s_recent FROM imm_sessions s_recent
WHERE s_recent.video_id = v.video_id WHERE s_recent.video_id = v.video_id
AND s_recent.ended_at_ms IS NOT NULL AND s_recent.ended_media_ms IS NOT NULL
ORDER BY s_recent.ended_at_ms DESC, s_recent.session_id DESC 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 LIMIT 1
) AS endedMediaMs, ) AS endedMediaMs,
v.watched AS watched, v.watched AS watched,

View File

@@ -6,6 +6,7 @@ import type { QueuedWrite, VideoMetadata } from './types';
export interface TrackerPreparedStatements { export interface TrackerPreparedStatements {
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>; telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
sessionCheckpointStmt: ReturnType<DatabaseSync['prepare']>;
eventInsertStmt: ReturnType<DatabaseSync['prepare']>; eventInsertStmt: ReturnType<DatabaseSync['prepare']>;
wordUpsertStmt: ReturnType<DatabaseSync['prepare']>; wordUpsertStmt: ReturnType<DatabaseSync['prepare']>;
kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>; kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>;
@@ -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(` eventInsertStmt: db.prepare(`
INSERT INTO imm_session_events ( INSERT INTO imm_session_events (
session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms, 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 { export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedStatements): void {
if (write.kind === 'telemetry') { if (write.kind === 'telemetry') {
const nowMs = Date.now();
stmts.telemetryInsertStmt.run( stmts.telemetryInsertStmt.run(
write.sessionId, write.sessionId,
write.sampleMs!, write.sampleMs!,
@@ -1311,9 +1321,10 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
write.seekForwardCount!, write.seekForwardCount!,
write.seekBackwardCount!, write.seekBackwardCount!,
write.mediaBufferEvents!, write.mediaBufferEvents!,
Date.now(), nowMs,
Date.now(), nowMs,
); );
stmts.sessionCheckpointStmt.run(write.lastMediaMs ?? null, nowMs, write.sessionId);
return; return;
} }
if (write.kind === 'word') { if (write.kind === 'word') {

View File

@@ -85,6 +85,7 @@ interface QueuedTelemetryWrite {
kind: 'telemetry'; kind: 'telemetry';
sessionId: number; sessionId: number;
sampleMs?: number; sampleMs?: number;
lastMediaMs?: number | null;
totalWatchedMs?: number; totalWatchedMs?: number;
activeWatchedMs?: number; activeWatchedMs?: number;
linesSeen?: number; linesSeen?: number;