mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
Restore anime episode progress from subtitle timing
- Fall back to latest retained subtitle/event segment end when `ended_media_ms` is missing - Tighten stats command tests and add regression coverage for the fallback
This commit is contained in:
@@ -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.
|
||||
4
changes/2026-03-19-texthooker-docs-bundle-update.md
Normal file
4
changes/2026-03-19-texthooker-docs-bundle-update.md
Normal file
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: docs
|
||||
|
||||
- Refreshed the vendored Texthooker docs/index.html bundle to match the latest local build artifacts.
|
||||
@@ -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.
|
||||
@@ -48,6 +48,64 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): 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<typeof runStatsCommand>[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<number>((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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user