mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -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,
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { QueuedWrite, VideoMetadata } from './types';
|
||||
|
||||
export interface TrackerPreparedStatements {
|
||||
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
sessionCheckpointStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
eventInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordUpsertStmt: 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(`
|
||||
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') {
|
||||
|
||||
@@ -85,6 +85,7 @@ interface QueuedTelemetryWrite {
|
||||
kind: 'telemetry';
|
||||
sessionId: number;
|
||||
sampleMs?: number;
|
||||
lastMediaMs?: number | null;
|
||||
totalWatchedMs?: number;
|
||||
activeWatchedMs?: number;
|
||||
linesSeen?: number;
|
||||
|
||||
Reference in New Issue
Block a user