feat(stats): add library entry merge and episode move (#190)

This commit is contained in:
2026-08-14 21:54:17 -07:00
committed by GitHub
parent c5a77ac067
commit 6fb3d2eb13
42 changed files with 4850 additions and 89 deletions
@@ -0,0 +1,322 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { DatabaseSync } from '../immersion-tracker/sqlite';
type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService;
type ImmersionTrackerServiceCtor =
typeof import('../immersion-tracker-service').ImmersionTrackerService;
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
if (trackerCtor) return trackerCtor;
const mod = await import('../immersion-tracker-service');
trackerCtor = mod.ImmersionTrackerService;
return trackerCtor;
}
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) return;
fs.rmSync(dir, { recursive: true, force: true });
}
interface TrackerInternals {
db: DatabaseSync;
queue: unknown[];
recordWrite: (write: Record<string, unknown>) => void;
deleteSession: (sessionId: number) => Promise<void>;
mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise<unknown>;
moveVideoToAnime: (videoId: number, targetAnimeId: number) => Promise<unknown>;
rebuildLifetimeSummaries: () => Promise<unknown>;
reassignAnimeAnilist: (animeId: number, info: { anilistId: number }) => Promise<void>;
flushNow: () => void;
writeLock: { locked: boolean };
}
test('delete maintenance fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let deleteRunnerCalls = 0;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor(
{ dbPath, policy: { batchSize: 2 } },
{
runDeleteMaintenanceTask: async () => {
deleteRunnerCalls += 1;
},
},
);
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
let flushCalls = 0;
internals.flushNow = () => {
flushCalls += 1;
if (flushCalls > 1) throw new Error('bounded no-progress sentinel');
};
await assert.rejects(internals.deleteSession(1), /queue did not drain/i);
assert.equal(flushCalls, 1);
assert.equal(deleteRunnerCalls, 0);
assert.equal(internals.writeLock.locked, false);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run();
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(
internals.reassignAnimeAnilist(1, { anilistId: 123 }),
/queue did not drain/i,
);
assert.deepEqual(
internals.db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all(),
[
{ animeId: 1, anilistId: null },
{ animeId: 2, anilistId: 123 },
],
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('mergeAnime fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
assert.deepEqual(
internals.db
.prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id')
.all()
.map((row) => (row as { animeId: number }).animeId),
[1, 2],
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
assert.equal(
(
internals.db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2')
.get() as {
animeId: number;
}
).animeId,
2,
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
function seedTwoEntries(db: DatabaseSync): void {
db.exec(`
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000);
INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000),
(2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000);
INSERT INTO imm_sessions (session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'drain-session', 2, '1000', '2000', 2, 1000, 1000, 2000);
`);
}
function queueSubtitleLines(tracker: TrackerInternals, count: number): void {
for (let index = 0; index < count; index += 1) {
tracker.recordWrite({
kind: 'subtitleLine',
sessionId: 1,
videoId: 2,
lineIndex: index,
segmentStartMs: index * 1000,
segmentEndMs: index * 1000 + 900,
text: `line ${index}`,
wordOccurrences: [],
kanjiOccurrences: [],
firstSeen: 1000,
lastSeen: 2000,
});
}
}
/**
* Queued last so it sits past the first batch. Lifetime `total_lines_seen`
* reads this counter, not a COUNT over imm_subtitle_lines, so the rebuilt
* summary only reflects the session once the queue is drained all the way.
*/
function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void {
tracker.recordWrite({
kind: 'telemetry',
sessionId: 1,
sampleMs: 3000,
lastMediaMs: 3000,
totalWatchedMs: 4000,
activeWatchedMs: 3500,
linesSeen,
tokensSeen: linesSeen * 5,
cardsMined: 2,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
pauseCount: 0,
pauseMs: 0,
seekForwardCount: 0,
seekBackwardCount: 0,
mediaBufferEvents: 0,
});
}
/** The queued telemetry sample only exists in the database once the queue drained fully. */
function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null {
const row = db
.prepare(
`SELECT lines_seen AS linesSeen
FROM imm_session_telemetry
WHERE session_id = ?
ORDER BY sample_ms DESC, telemetry_id DESC
LIMIT 1`,
)
.get(sessionId) as { linesSeen: number } | undefined;
return row ? Number(row.linesSeen) : null;
}
function countLinesForAnime(db: DatabaseSync, animeId: number): number {
const row = db
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
.get(animeId) as { total: number };
return Number(row.total);
}
/**
* Both entry points must see a settled database before changing episode
* ownership. A single flushNow() only writes one batch off the front of the
* queue, so anything past `batchSize` would still be unwritten when the merge
* repoints rows.
*/
test('mergeAnime drains a queue larger than one batch before repointing rows', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 8);
queueTelemetry(internals, 8);
assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch');
await internals.mergeAnime(1, [2]);
assert.equal(internals.queue.length, 0);
// Every queued line landed, attributed to the surviving entry.
assert.equal(countLinesForAnime(internals.db, 1), 8);
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 8);
queueTelemetry(internals, 8);
await internals.moveVideoToAnime(2, 1);
assert.equal(internals.queue.length, 0);
assert.equal(countLinesForAnime(internals.db, 1), 8);
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
@@ -1227,6 +1227,55 @@ describe('stats server API routes', () => {
assert.equal(body[0].canonicalTitle, 'Little Witch Academia');
});
it('GET /api/stats/anime/merge-recommendations returns pending duplicate pairs', async () => {
const app = createStatsApp(
createMockTracker({
getAnimeMergeRecommendations: async () => [{ recommendationId: 4, animeIds: [1, 2] }],
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/merge-recommendations');
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
recommendations: [{ recommendationId: 4, animeIds: [1, 2] }],
});
});
it('DELETE /api/stats/anime/merge-recommendations/:id dismisses a pending pair', async () => {
let dismissedId: number | null = null;
const app = createStatsApp(
createMockTracker({
dismissAnimeMergeRecommendation: async (recommendationId: number) => {
dismissedId = recommendationId;
return true;
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/merge-recommendations/4', {
method: 'DELETE',
});
assert.equal(res.status, 200);
assert.equal(dismissedId, 4);
assert.deepEqual(await res.json(), { ok: true });
});
it('DELETE /api/stats/anime/merge-recommendations/:id reports missing recommendations', async () => {
const app = createStatsApp(
createMockTracker({
dismissAnimeMergeRecommendation: async () => false,
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/merge-recommendations/99', {
method: 'DELETE',
});
assert.equal(res.status, 404);
});
it('GET /api/stats/anime/:animeId returns anime detail with episodes', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anime/1');
@@ -3198,6 +3247,148 @@ Aligned English subtitle
assert.equal(deleteCalls, 0);
});
it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => {
let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
const app = createStatsApp(
createMockTracker({
mergeAnime: async (targetAnimeId: number, sourceAnimeIds: number[]) => {
merged = { targetAnimeId, sourceAnimeIds };
return {
survivingAnimeId: targetAnimeId,
mergedAnimeIds: sourceAnimeIds,
movedVideos: 3,
};
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The target repeated in the sources must not delete the entry we keep.
body: '{"sourceAnimeIds":[8,9,8,7]}',
});
assert.equal(res.status, 200);
assert.deepEqual(merged, { targetAnimeId: 7, sourceAnimeIds: [8, 9] });
assert.deepEqual(await res.json(), {
ok: true,
animeId: 7,
mergedAnimeIds: [8, 9],
movedVideos: 3,
});
});
it('POST /api/stats/anime/:animeId/merge rejects an empty or malformed source list', async () => {
let mergeCalls = 0;
const app = createStatsApp(
createMockTracker({
mergeAnime: async () => {
mergeCalls += 1;
return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 };
},
} as Partial<ImmersionTrackerService>),
);
for (const body of [
'{"sourceAnimeIds":[]}',
'{"sourceAnimeIds":[7]}',
'{"sourceAnimeIds":0}',
]) {
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
assert.equal(res.status, 400);
}
assert.equal(mergeCalls, 0);
});
it('PATCH /api/stats/media/:videoId/anime moves the episode to another entry', async () => {
let moved: { videoId: number; animeId: number } | null = null;
const app = createStatsApp(
createMockTracker({
moveVideoToAnime: async (videoId: number, animeId: number) => {
moved = { videoId, animeId };
return { targetAnimeId: animeId, previousAnimeId: 4, removedPreviousAnime: true };
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/media/12/anime', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: '{"animeId":7}',
});
assert.equal(res.status, 200);
assert.deepEqual(moved, { videoId: 12, animeId: 7 });
assert.deepEqual(await res.json(), {
ok: true,
animeId: 7,
previousAnimeId: 4,
removedPreviousAnime: true,
});
});
it('POST /api/stats/anime/:animeId/merge reports a merge that folded nothing as 404', async () => {
const app = createStatsApp(
createMockTracker({
mergeAnime: async (targetAnimeId: number) => ({
survivingAnimeId: targetAnimeId,
mergedAnimeIds: [],
movedVideos: 0,
}),
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"sourceAnimeIds":[8]}',
});
assert.equal(res.status, 404);
});
it('PATCH /api/stats/media/:videoId/anime reports an unknown target as 404', async () => {
const app = createStatsApp(
createMockTracker({
moveVideoToAnime: async () => {
throw new Error('Unknown episode or target library entry');
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/media/12/anime', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: '{"animeId":99}',
});
assert.equal(res.status, 404);
});
it('PATCH /api/stats/media/:videoId/anime does not disguise storage failures as 404', async () => {
const app = createStatsApp(
createMockTracker({
moveVideoToAnime: async () => {
throw new Error('database is locked');
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/media/12/anime', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: '{"animeId":7}',
});
assert.notEqual(res.status, 404);
assert.equal(res.status >= 500, true);
});
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });