mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix(stats): persist anime episode progress checkpoints
This commit is contained in:
4
changes/2026-03-19-stats-session-progress-checkpoint.md
Normal file
4
changes/2026-03-19-stats-session-progress-checkpoint.md
Normal 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.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user