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', () => {
|
test('config command writes newline-terminated path via process adapter', () => {
|
||||||
const writes: string[] = [];
|
const writes: string[] = [];
|
||||||
const context = createContext();
|
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 () => {
|
test('stats command launches attached app command with response path', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
|
||||||
context.args.stats = true;
|
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||||
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: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats',
|
'--stats',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
@@ -183,50 +228,34 @@ test('stats command launches attached app command with response path', async ()
|
|||||||
'debug',
|
'debug',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats background command launches attached daemon control command with response path', async () => {
|
test('stats background command launches attached daemon control command with response path', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true, statsBackground: true });
|
||||||
context.args.stats = true;
|
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||||
(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]);
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats-daemon-start',
|
'--stats-daemon-start',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
'/tmp/subminer-stats-test/response.json',
|
'/tmp/subminer-stats-test/response.json',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats command waits for attached app exit after startup response', async () => {
|
test('stats command waits for attached app exit after startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
context.args.stats = true;
|
|
||||||
const forwarded: string[][] = [];
|
|
||||||
const started = new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
|
const started = new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
|
||||||
|
|
||||||
const statsCommand = runStatsCommand(context, {
|
const statsCommand = runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
forwarded.push(appArgs);
|
|
||||||
return started;
|
return started;
|
||||||
},
|
},
|
||||||
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
const result = await Promise.race([
|
const result = await Promise.race([
|
||||||
statsCommand.then(() => 'resolved'),
|
statsCommand.then(() => 'resolved'),
|
||||||
@@ -237,53 +266,46 @@ test('stats command waits for attached app exit after startup response', async (
|
|||||||
|
|
||||||
const final = await statsCommand;
|
const final = await statsCommand;
|
||||||
assert.equal(final, true);
|
assert.equal(final, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats',
|
'--stats',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
'/tmp/subminer-stats-test/response.json',
|
'/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 () => {
|
test('stats command throws when attached app exits non-zero after startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
context.args.stats = true;
|
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => {
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
return 3;
|
return 3;
|
||||||
},
|
},
|
||||||
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
}, /Stats app exited with status 3\./);
|
}, /Stats app exited with status 3\./);
|
||||||
|
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({
|
||||||
context.args.stats = true;
|
stats: true,
|
||||||
context.args.statsCleanup = true;
|
statsCleanup: true,
|
||||||
context.args.statsCleanupVocab = true;
|
statsCleanupVocab: true,
|
||||||
const forwarded: string[][] = [];
|
});
|
||||||
|
const handled = await runStatsCommand(harness.context, {
|
||||||
const handled = await runStatsCommand(context, {
|
...harness.commandDeps,
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
|
||||||
joinPath: (...parts) => parts.join('/'),
|
|
||||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
|
||||||
forwarded.push(appArgs);
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
waitForStatsResponse: async () => ({ ok: true }),
|
waitForStatsResponse: async () => ({ ok: true }),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats',
|
'--stats',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
@@ -292,76 +314,62 @@ test('stats cleanup command forwards cleanup vocab flags to the app', async () =
|
|||||||
'--stats-cleanup-vocab',
|
'--stats-cleanup-vocab',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats stop command forwards stop flag to the app', async () => {
|
test('stats stop command forwards stop flag to the app', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
||||||
context.args.stats = true;
|
|
||||||
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
|
|
||||||
const forwarded: string[][] = [];
|
|
||||||
|
|
||||||
const handled = await runStatsCommand(context, {
|
const handled = await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
|
||||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
|
||||||
forwarded.push(appArgs);
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
waitForStatsResponse: async () => ({ ok: true }),
|
waitForStatsResponse: async () => ({ ok: true }),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats-daemon-stop',
|
'--stats-daemon-stop',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
'/tmp/subminer-stats-test/response.json',
|
'/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 () => {
|
test('stats stop command exits on process exit without waiting for startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
||||||
context.args.stats = true;
|
|
||||||
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
|
|
||||||
let waitedForResponse = false;
|
let waitedForResponse = false;
|
||||||
|
|
||||||
const handled = await runStatsCommand(context, {
|
const handled = await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => 0,
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
waitForStatsResponse: async () => {
|
waitForStatsResponse: async () => {
|
||||||
waitedForResponse = true;
|
waitedForResponse = true;
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.equal(waitedForResponse, false);
|
assert.equal(waitedForResponse, false);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
|
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({
|
||||||
context.args.stats = true;
|
stats: true,
|
||||||
context.args.statsCleanup = true;
|
statsCleanup: true,
|
||||||
context.args.statsCleanupLifetime = true;
|
statsCleanupLifetime: true,
|
||||||
const forwarded: string[][] = [];
|
});
|
||||||
|
const handled = await runStatsCommand(harness.context, {
|
||||||
const handled = await runStatsCommand(context, {
|
...harness.commandDeps,
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
|
||||||
joinPath: (...parts) => parts.join('/'),
|
|
||||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
|
||||||
forwarded.push(appArgs);
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
waitForStatsResponse: async () => ({ ok: true }),
|
waitForStatsResponse: async () => ({ ok: true }),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats',
|
'--stats',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
@@ -370,56 +378,64 @@ test('stats cleanup command forwards lifetime rebuild flag to the app', async ()
|
|||||||
'--stats-cleanup-lifetime',
|
'--stats-cleanup-lifetime',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats command throws when stats response reports an error', async () => {
|
test('stats command throws when stats response reports an error', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
context.args.stats = true;
|
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => 0,
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
waitForStatsResponse: async () => ({
|
waitForStatsResponse: async () => ({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'Immersion tracking is disabled in config.',
|
error: 'Immersion tracking is disabled in config.',
|
||||||
}),
|
}),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
}, /Immersion tracking is disabled in config\./);
|
}, /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 () => {
|
test('stats cleanup command fails if attached app exits before startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({
|
||||||
context.args.stats = true;
|
stats: true,
|
||||||
context.args.statsCleanup = true;
|
statsCleanup: true,
|
||||||
context.args.statsCleanupVocab = true;
|
statsCleanupVocab: true,
|
||||||
|
});
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => 2,
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
|
return 2;
|
||||||
|
},
|
||||||
waitForStatsResponse: async () => {
|
waitForStatsResponse: async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
return { ok: true, url: 'http://127.0.0.1:5175' };
|
return { ok: true, url: 'http://127.0.0.1:5175' };
|
||||||
},
|
},
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
}, /Stats app exited before startup response \(status 2\)\./);
|
}, /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 () => {
|
test('stats command aborts pending response wait when app exits before startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
context.args.stats = true;
|
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => 2,
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
|
return 2;
|
||||||
|
},
|
||||||
waitForStatsResponse: async (_responsePath, signal) =>
|
waitForStatsResponse: async (_responsePath, signal) =>
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
signal?.addEventListener(
|
signal?.addEventListener(
|
||||||
@@ -431,25 +447,24 @@ test('stats command aborts pending response wait when app exits before startup r
|
|||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
}, /Stats app exited before startup response \(status 2\)\./);
|
}, /Stats app exited before startup response \(status 2\)\./);
|
||||||
|
|
||||||
assert.equal(aborted, true);
|
assert.equal(aborted, true);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats command aborts pending response wait when attached app fails to spawn', async () => {
|
test('stats command aborts pending response wait when attached app fails to spawn', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
context.args.stats = true;
|
|
||||||
const spawnError = new Error('spawn failed');
|
const spawnError = new Error('spawn failed');
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
async () => {
|
async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => {
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
throw spawnError;
|
throw spawnError;
|
||||||
},
|
},
|
||||||
waitForStatsResponse: async (_responsePath, signal) =>
|
waitForStatsResponse: async (_responsePath, signal) =>
|
||||||
@@ -463,27 +478,30 @@ test('stats command aborts pending response wait when attached app fails to spaw
|
|||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
(error: unknown) => error === spawnError,
|
(error: unknown) => error === spawnError,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(aborted, true);
|
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 () => {
|
test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({
|
||||||
context.args.stats = true;
|
stats: true,
|
||||||
context.args.statsCleanup = true;
|
statsCleanup: true,
|
||||||
context.args.statsCleanupVocab = true;
|
statsCleanupVocab: true,
|
||||||
|
});
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => 2,
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
|
return 2;
|
||||||
|
},
|
||||||
waitForStatsResponse: async (_responsePath, signal) =>
|
waitForStatsResponse: async (_responsePath, signal) =>
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
signal?.addEventListener(
|
signal?.addEventListener(
|
||||||
@@ -495,9 +513,9 @@ test('stats cleanup command aborts pending response wait when app exits before s
|
|||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
}, /Stats app exited before startup response \(status 2\)\./);
|
}, /Stats app exited before startup response \(status 2\)\./);
|
||||||
|
|
||||||
assert.equal(aborted, true);
|
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', () => {
|
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);
|
||||||
|
|||||||
@@ -1745,10 +1745,38 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
|
|||||||
v.parsed_episode AS episode,
|
v.parsed_episode AS episode,
|
||||||
v.duration_ms AS durationMs,
|
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
|
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_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
|
ORDER BY
|
||||||
COALESCE(s_recent.ended_at_ms, s_recent.LAST_UPDATE_DATE, s_recent.started_at_ms) DESC,
|
COALESCE(s_recent.ended_at_ms, s_recent.LAST_UPDATE_DATE, s_recent.started_at_ms) DESC,
|
||||||
s_recent.session_id DESC
|
s_recent.session_id DESC
|
||||||
|
|||||||
Reference in New Issue
Block a user