diff --git a/backlog/tasks/task-211 - Recover-anime-episode-progress-from-subtitle-timing-when-checkpoints-are-missing.md b/backlog/tasks/task-211 - Recover-anime-episode-progress-from-subtitle-timing-when-checkpoints-are-missing.md new file mode 100644 index 0000000..2e41459 --- /dev/null +++ b/backlog/tasks/task-211 - Recover-anime-episode-progress-from-subtitle-timing-when-checkpoints-are-missing.md @@ -0,0 +1,33 @@ +--- +id: TASK-211 +title: Recover anime episode progress from subtitle timing when checkpoints are missing +status: Done +assignee: + - '@Codex' +created_date: '2026-03-20 10:15' +updated_date: '2026-03-20 10:22' +labels: + - stats + - bug +milestone: m-1 +dependencies: [] +references: + - src/core/services/immersion-tracker/query.ts + - src/core/services/immersion-tracker/__tests__/query.test.ts +--- + +## Description + +Anime episode progress can still show `0%` for older sessions that have watch-time and subtitle timing but no persisted `ended_media_ms` checkpoint. Recover progress from the latest retained subtitle/event segment end so already-recorded sessions render a useful progress percentage. + +## Acceptance Criteria + +- [x] `getAnimeEpisodes` returns the latest known session position even when `ended_media_ms` is null but subtitle/event timing exists. +- [x] Existing ended-session metrics and aggregation totals do not regress. +- [x] Regression coverage locks the fallback behavior. + +## Implementation Notes + +Added a query-side fallback for anime episode progress: when the newest session for a video has no persisted `ended_media_ms`, `getAnimeEpisodes` now uses the latest retained subtitle-line or session-event `segment_end_ms` from that same session. This recovers useful progress for already-recorded sessions that have timing data but predate or missed checkpoint persistence. + +Verification: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` passed. `bun run typecheck` passed. diff --git a/changes/2026-03-19-texthooker-docs-bundle-update.md b/changes/2026-03-19-texthooker-docs-bundle-update.md new file mode 100644 index 0000000..55fedcb --- /dev/null +++ b/changes/2026-03-19-texthooker-docs-bundle-update.md @@ -0,0 +1,4 @@ +type: changed +area: docs + +- Refreshed the vendored Texthooker docs/index.html bundle to match the latest local build artifacts. diff --git a/changes/2026-03-20-stats-episode-progress-subtitle-fallback.md b/changes/2026-03-20-stats-episode-progress-subtitle-fallback.md new file mode 100644 index 0000000..1bdb47f --- /dev/null +++ b/changes/2026-03-20-stats-episode-progress-subtitle-fallback.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Anime episode progress now falls back to the latest retained subtitle/event timing when a session is missing a persisted playback-position checkpoint, so older watch sessions no longer get stuck at `0%` progress. diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 639c2f9..5844d52 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -48,6 +48,64 @@ function createContext(overrides: Partial = {}): Launche }; } +type StatsTestArgOverrides = { + stats?: boolean; + statsBackground?: boolean; + statsCleanup?: boolean; + statsCleanupVocab?: boolean; + statsCleanupLifetime?: boolean; + statsStop?: boolean; + logLevel?: LauncherCommandContext['args']['logLevel']; +}; + +function createStatsTestHarness(overrides: StatsTestArgOverrides = {}) { + const context = createContext(); + const forwarded: string[][] = []; + const removedPaths: string[] = []; + const createTempDir = (_prefix: string) => { + const created = `/tmp/subminer-stats-test`; + return created; + }; + const joinPath = (...parts: string[]) => parts.join('/'); + const removeDir = (targetPath: string) => { + removedPaths.push(targetPath); + }; + const runAppCommandAttachedStub = async ( + _appPath: string, + appArgs: string[], + _logLevel: LauncherCommandContext['args']['logLevel'], + _label: string, + ) => { + forwarded.push(appArgs); + return 0; + }; + const waitForStatsResponseStub = async () => ({ ok: true, url: 'http://127.0.0.1:5175' }); + + context.args = { + ...context.args, + stats: true, + ...overrides, + }; + + return { + context, + forwarded, + removedPaths, + createTempDir, + joinPath, + removeDir, + runAppCommandAttachedStub, + waitForStatsResponseStub, + commandDeps: { + createTempDir, + joinPath, + runAppCommandAttached: runAppCommandAttachedStub, + waitForStatsResponse: waitForStatsResponseStub, + removeDir, + }, + }; +} + test('config command writes newline-terminated path via process adapter', () => { const writes: string[] = []; const context = createContext(); @@ -157,24 +215,11 @@ test('dictionary command throws if app handoff unexpectedly returns', () => { }); test('stats command launches attached app command with response path', async () => { - const context = createContext(); - context.args.stats = true; - context.args.logLevel = 'debug'; - const forwarded: string[][] = []; - - const handled = await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async (_appPath, appArgs) => { - forwarded.push(appArgs); - return 0; - }, - waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }), - removeDir: () => {}, - }); + const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' }); + const handled = await runStatsCommand(harness.context, harness.commandDeps); assert.equal(handled, true); - assert.deepEqual(forwarded, [ + assert.deepEqual(harness.forwarded, [ [ '--stats', '--stats-response-path', @@ -183,50 +228,34 @@ test('stats command launches attached app command with response path', async () 'debug', ], ]); + assert.equal(harness.removedPaths.length, 1); }); test('stats background command launches attached daemon control command with response path', async () => { - const context = createContext(); - context.args.stats = true; - (context.args as typeof context.args & { statsBackground?: boolean }).statsBackground = true; - const forwarded: string[][] = []; - - const handled = await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async (_appPath, appArgs) => { - forwarded.push(appArgs); - return 0; - }, - waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }), - removeDir: () => {}, - } as Parameters[1]); + const harness = createStatsTestHarness({ stats: true, statsBackground: true }); + const handled = await runStatsCommand(harness.context, harness.commandDeps); assert.equal(handled, true); - assert.deepEqual(forwarded, [ + assert.deepEqual(harness.forwarded, [ [ '--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json', ], ]); + assert.equal(harness.removedPaths.length, 1); }); test('stats command waits for attached app exit after startup response', async () => { - const context = createContext(); - context.args.stats = true; - const forwarded: string[][] = []; + const harness = createStatsTestHarness({ stats: true }); const started = new Promise((resolve) => setTimeout(() => resolve(0), 20)); - const statsCommand = runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async (_appPath, appArgs) => { - forwarded.push(appArgs); + const statsCommand = runStatsCommand(harness.context, { + ...harness.commandDeps, + runAppCommandAttached: async (...args) => { + await harness.runAppCommandAttachedStub(...args); return started; }, - waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }), - removeDir: () => {}, }); const result = await Promise.race([ statsCommand.then(() => 'resolved'), @@ -237,53 +266,46 @@ test('stats command waits for attached app exit after startup response', async ( const final = await statsCommand; assert.equal(final, true); - assert.deepEqual(forwarded, [ + assert.deepEqual(harness.forwarded, [ [ '--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', ], ]); + assert.equal(harness.removedPaths.length, 1); }); test('stats command throws when attached app exits non-zero after startup response', async () => { - const context = createContext(); - context.args.stats = true; + const harness = createStatsTestHarness({ stats: true }); await assert.rejects(async () => { - await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async () => { + await runStatsCommand(harness.context, { + ...harness.commandDeps, + runAppCommandAttached: async (...args) => { + await harness.runAppCommandAttachedStub(...args); await new Promise((resolve) => setTimeout(resolve, 10)); return 3; }, - waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }), - removeDir: () => {}, }); }, /Stats app exited with status 3\./); + + assert.equal(harness.removedPaths.length, 1); }); test('stats cleanup command forwards cleanup vocab flags to the app', async () => { - const context = createContext(); - context.args.stats = true; - context.args.statsCleanup = true; - context.args.statsCleanupVocab = true; - const forwarded: string[][] = []; - - const handled = await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async (_appPath, appArgs) => { - forwarded.push(appArgs); - return 0; - }, + const harness = createStatsTestHarness({ + stats: true, + statsCleanup: true, + statsCleanupVocab: true, + }); + const handled = await runStatsCommand(harness.context, { + ...harness.commandDeps, waitForStatsResponse: async () => ({ ok: true }), - removeDir: () => {}, }); assert.equal(handled, true); - assert.deepEqual(forwarded, [ + assert.deepEqual(harness.forwarded, [ [ '--stats', '--stats-response-path', @@ -292,76 +314,62 @@ test('stats cleanup command forwards cleanup vocab flags to the app', async () = '--stats-cleanup-vocab', ], ]); + assert.equal(harness.removedPaths.length, 1); }); test('stats stop command forwards stop flag to the app', async () => { - const context = createContext(); - context.args.stats = true; - (context.args as typeof context.args & { statsStop?: boolean }).statsStop = true; - const forwarded: string[][] = []; + const harness = createStatsTestHarness({ stats: true, statsStop: true }); - const handled = await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async (_appPath, appArgs) => { - forwarded.push(appArgs); - return 0; - }, + const handled = await runStatsCommand(harness.context, { + ...harness.commandDeps, waitForStatsResponse: async () => ({ ok: true }), - removeDir: () => {}, }); assert.equal(handled, true); - assert.deepEqual(forwarded, [ + assert.deepEqual(harness.forwarded, [ [ '--stats-daemon-stop', '--stats-response-path', '/tmp/subminer-stats-test/response.json', ], ]); + assert.equal(harness.removedPaths.length, 1); }); test('stats stop command exits on process exit without waiting for startup response', async () => { - const context = createContext(); - context.args.stats = true; - (context.args as typeof context.args & { statsStop?: boolean }).statsStop = true; + const harness = createStatsTestHarness({ stats: true, statsStop: true }); let waitedForResponse = false; - const handled = await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async () => 0, + const handled = await runStatsCommand(harness.context, { + ...harness.commandDeps, + runAppCommandAttached: async (...args) => { + await harness.runAppCommandAttachedStub(...args); + return 0; + }, waitForStatsResponse: async () => { waitedForResponse = true; return { ok: true }; }, - removeDir: () => {}, }); assert.equal(handled, true); assert.equal(waitedForResponse, false); + assert.equal(harness.removedPaths.length, 1); }); test('stats cleanup command forwards lifetime rebuild flag to the app', async () => { - const context = createContext(); - context.args.stats = true; - context.args.statsCleanup = true; - context.args.statsCleanupLifetime = true; - const forwarded: string[][] = []; - - const handled = await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async (_appPath, appArgs) => { - forwarded.push(appArgs); - return 0; - }, + const harness = createStatsTestHarness({ + stats: true, + statsCleanup: true, + statsCleanupLifetime: true, + }); + const handled = await runStatsCommand(harness.context, { + ...harness.commandDeps, waitForStatsResponse: async () => ({ ok: true }), - removeDir: () => {}, }); assert.equal(handled, true); - assert.deepEqual(forwarded, [ + assert.deepEqual(harness.forwarded, [ [ '--stats', '--stats-response-path', @@ -370,56 +378,64 @@ test('stats cleanup command forwards lifetime rebuild flag to the app', async () '--stats-cleanup-lifetime', ], ]); + assert.equal(harness.removedPaths.length, 1); }); test('stats command throws when stats response reports an error', async () => { - const context = createContext(); - context.args.stats = true; + const harness = createStatsTestHarness({ stats: true }); await assert.rejects(async () => { - await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async () => 0, + await runStatsCommand(harness.context, { + ...harness.commandDeps, + runAppCommandAttached: async (...args) => { + await harness.runAppCommandAttachedStub(...args); + return 0; + }, waitForStatsResponse: async () => ({ ok: false, error: 'Immersion tracking is disabled in config.', }), - removeDir: () => {}, }); }, /Immersion tracking is disabled in config\./); + + assert.equal(harness.removedPaths.length, 1); }); test('stats cleanup command fails if attached app exits before startup response', async () => { - const context = createContext(); - context.args.stats = true; - context.args.statsCleanup = true; - context.args.statsCleanupVocab = true; + const harness = createStatsTestHarness({ + stats: true, + statsCleanup: true, + statsCleanupVocab: true, + }); await assert.rejects(async () => { - await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async () => 2, + await runStatsCommand(harness.context, { + ...harness.commandDeps, + runAppCommandAttached: async (...args) => { + await harness.runAppCommandAttachedStub(...args); + return 2; + }, waitForStatsResponse: async () => { await new Promise((resolve) => setTimeout(resolve, 25)); return { ok: true, url: 'http://127.0.0.1:5175' }; }, - removeDir: () => {}, }); }, /Stats app exited before startup response \(status 2\)\./); + + assert.equal(harness.removedPaths.length, 1); }); test('stats command aborts pending response wait when app exits before startup response', async () => { - const context = createContext(); - context.args.stats = true; + const harness = createStatsTestHarness({ stats: true }); let aborted = false; await assert.rejects(async () => { - await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async () => 2, + await runStatsCommand(harness.context, { + ...harness.commandDeps, + runAppCommandAttached: async (...args) => { + await harness.runAppCommandAttachedStub(...args); + return 2; + }, waitForStatsResponse: async (_responsePath, signal) => await new Promise((resolve) => { signal?.addEventListener( @@ -431,25 +447,24 @@ test('stats command aborts pending response wait when app exits before startup r { once: true }, ); }), - removeDir: () => {}, }); }, /Stats app exited before startup response \(status 2\)\./); assert.equal(aborted, true); + assert.equal(harness.removedPaths.length, 1); }); test('stats command aborts pending response wait when attached app fails to spawn', async () => { - const context = createContext(); - context.args.stats = true; + const harness = createStatsTestHarness({ stats: true }); const spawnError = new Error('spawn failed'); let aborted = false; await assert.rejects( async () => { - await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async () => { + await runStatsCommand(harness.context, { + ...harness.commandDeps, + runAppCommandAttached: async (...args) => { + await harness.runAppCommandAttachedStub(...args); throw spawnError; }, waitForStatsResponse: async (_responsePath, signal) => @@ -463,27 +478,30 @@ test('stats command aborts pending response wait when attached app fails to spaw { once: true }, ); }), - removeDir: () => {}, }); }, (error: unknown) => error === spawnError, ); assert.equal(aborted, true); + assert.equal(harness.removedPaths.length, 1); }); test('stats cleanup command aborts pending response wait when app exits before startup response', async () => { - const context = createContext(); - context.args.stats = true; - context.args.statsCleanup = true; - context.args.statsCleanupVocab = true; + const harness = createStatsTestHarness({ + stats: true, + statsCleanup: true, + statsCleanupVocab: true, + }); let aborted = false; await assert.rejects(async () => { - await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async () => 2, + await runStatsCommand(harness.context, { + ...harness.commandDeps, + runAppCommandAttached: async (...args) => { + await harness.runAppCommandAttachedStub(...args); + return 2; + }, waitForStatsResponse: async (_responsePath, signal) => await new Promise((resolve) => { signal?.addEventListener( @@ -495,9 +513,9 @@ test('stats cleanup command aborts pending response wait when app exits before s { once: true }, ); }), - removeDir: () => {}, }); }, /Stats app exited before startup response \(status 2\)\./); assert.equal(aborted, true); + assert.equal(harness.removedPaths.length, 1); }); diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index ed1a1dc..d1f0cce 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -207,6 +207,78 @@ test('getAnimeEpisodes prefers the latest session media position when the latest } }); +test('getAnimeEpisodes falls back to the latest subtitle segment end when session progress checkpoints are missing', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const stmts = createTrackerPreparedStatements(db); + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/subtitle-progress-fallback.mkv', { + canonicalTitle: 'Subtitle Progress Fallback', + sourcePath: '/tmp/subtitle-progress-fallback.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Subtitle Progress Fallback Anime', + canonicalTitle: 'Subtitle Progress Fallback Anime', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: 'subtitle-progress-fallback.mkv', + parsedTitle: 'Subtitle Progress Fallback Anime', + parsedSeason: 1, + parsedEpisode: 1, + parserSource: 'fallback', + parserConfidence: 1, + parseMetadataJson: '{"episode":1}', + }); + db.prepare('UPDATE imm_videos SET duration_ms = ? WHERE video_id = ?').run(24_000, videoId); + + const startedAtMs = 1_100_000; + const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId; + db.prepare( + ` + UPDATE imm_sessions + SET + ended_at_ms = ?, + status = 2, + active_watched_ms = ?, + LAST_UPDATE_DATE = ? + WHERE session_id = ? + `, + ).run(startedAtMs + 10_000, 10_000, startedAtMs + 10_000, sessionId); + stmts.eventInsertStmt.run( + sessionId, + startedAtMs + 9_000, + EVENT_SUBTITLE_LINE, + 1, + 18_000, + 21_000, + 5, + 0, + '{"line":"progress fallback"}', + startedAtMs + 9_000, + startedAtMs + 9_000, + ); + + const [episode] = getAnimeEpisodes(db, animeId); + assert.ok(episode); + assert.equal(episode?.endedMediaMs, 21_000); + assert.equal(episode?.totalSessions, 1); + assert.equal(episode?.totalActiveMs, 10_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/query.ts b/src/core/services/immersion-tracker/query.ts index 7eb30de..d796724 100644 --- a/src/core/services/immersion-tracker/query.ts +++ b/src/core/services/immersion-tracker/query.ts @@ -1745,10 +1745,38 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod v.parsed_episode AS episode, v.duration_ms AS durationMs, ( - SELECT s_recent.ended_media_ms + SELECT COALESCE( + s_recent.ended_media_ms, + ( + SELECT MAX(line.segment_end_ms) + FROM imm_subtitle_lines line + WHERE line.session_id = s_recent.session_id + AND line.segment_end_ms IS NOT NULL + ), + ( + SELECT MAX(event.segment_end_ms) + FROM imm_session_events event + WHERE event.session_id = s_recent.session_id + AND event.segment_end_ms IS NOT NULL + ) + ) FROM imm_sessions s_recent WHERE s_recent.video_id = v.video_id - AND s_recent.ended_media_ms IS NOT NULL + AND ( + s_recent.ended_media_ms IS NOT NULL + OR EXISTS ( + SELECT 1 + FROM imm_subtitle_lines line + WHERE line.session_id = s_recent.session_id + AND line.segment_end_ms IS NOT NULL + ) + OR EXISTS ( + SELECT 1 + FROM imm_session_events event + WHERE event.session_id = s_recent.session_id + AND event.segment_end_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