diff --git a/src/core/services/__tests__/immersion-tracker-write-queue.test.ts b/src/core/services/__tests__/immersion-tracker-write-queue.test.ts index 948e18ec..f9449b74 100644 --- a/src/core/services/__tests__/immersion-tracker-write-queue.test.ts +++ b/src/core/services/__tests__/immersion-tracker-write-queue.test.ts @@ -244,19 +244,18 @@ function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void { }); } -function lifetimeForAnime( - db: DatabaseSync, - animeId: number, -): { linesSeen: number; activeMs: number; cards: number } | null { +/** 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 total_lines_seen AS linesSeen, total_active_ms AS activeMs, total_cards AS cards - FROM imm_lifetime_anime WHERE anime_id = ?`, + `SELECT lines_seen AS linesSeen + FROM imm_session_telemetry + WHERE session_id = ? + ORDER BY sample_ms DESC, telemetry_id DESC + LIMIT 1`, ) - .get(animeId) as { linesSeen: number; activeMs: number; cards: number } | undefined; - return row - ? { linesSeen: Number(row.linesSeen), activeMs: Number(row.activeMs), cards: Number(row.cards) } - : null; + .get(sessionId) as { linesSeen: number } | undefined; + return row ? Number(row.linesSeen) : null; } function countLinesForAnime(db: DatabaseSync, animeId: number): number { @@ -267,12 +266,12 @@ function countLinesForAnime(db: DatabaseSync, animeId: number): number { } /** - * Both entry points rebuild the lifetime summaries, which recompute from the - * database. A single flushNow() only writes one batch off the front of the - * queue, so anything past `batchSize` would still be unwritten when the rebuild - * reads. + * 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 rebuilding summaries', async () => { +test('mergeAnime drains a queue larger than one batch before repointing rows', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; @@ -289,19 +288,16 @@ test('mergeAnime drains a queue larger than one batch before rebuilding summarie 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); - // The surviving entry's summary was rebuilt from the fully drained queue. - const lifetime = lifetimeForAnime(internals.db, 1); - assert.equal(lifetime?.linesSeen, 8); - assert.equal(lifetime?.activeMs, 3500); - assert.equal(lifetime?.cards, 2); + assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8); } finally { tracker?.destroy(); cleanupDbPath(dbPath); } }); -test('moveVideoToAnime drains a queue larger than one batch before rebuilding summaries', async () => { +test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; @@ -318,10 +314,7 @@ test('moveVideoToAnime drains a queue larger than one batch before rebuilding su assert.equal(internals.queue.length, 0); assert.equal(countLinesForAnime(internals.db, 1), 8); - const lifetime = lifetimeForAnime(internals.db, 1); - assert.equal(lifetime?.linesSeen, 8); - assert.equal(lifetime?.activeMs, 3500); - assert.equal(lifetime?.cards, 2); + assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8); } finally { tracker?.destroy(); cleanupDbPath(dbPath); diff --git a/src/core/services/anilist/season-resolver.test.ts b/src/core/services/anilist/season-resolver.test.ts index a5d12964..a5e0b8cd 100644 --- a/src/core/services/anilist/season-resolver.test.ts +++ b/src/core/services/anilist/season-resolver.test.ts @@ -156,7 +156,10 @@ test('season 1 resolves to the anchor without relation lookups', async () => { assert.deepEqual(relationLookups, []); }); -test('season 2 preserves the anchor exact-title evidence through a sequel resolution', async () => { +test('a sequel resolution is not certified by the anchor exact-title evidence', async () => { + // The anchor matched the search title exactly, but the hopped-to entry is a + // different inference (a split-cour chain can land one season short), so the + // sequel result must report its own title evidence, not the anchor's. const { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS); const result = await resolveAnilistSeasonMedia( { title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 1 }, @@ -165,6 +168,33 @@ test('season 2 preserves the anchor exact-title evidence through a sequel resolu assert.equal(result?.id, 20698); assert.equal(result?.via, 'sequel-chain'); + assert.equal(result?.exactTitleMatch, false); +}); + +test('a sequel resolution whose own title matches the parsed title stays exact', async () => { + const anchor: AnilistSeasonMedia = { + id: 1, + episodes: 12, + format: 'TV', + title: { english: 'Show' }, + }; + const sequel: AnilistSeasonMedia = { + id: 2, + episodes: 12, + format: 'TV', + title: { english: 'Show 2nd Season' }, + }; + const { execute } = createExecutor([anchor], { + 1: [{ relationType: 'SEQUEL', node: sequel }], + }); + + const result = await resolveAnilistSeasonMedia( + { title: 'Show 2nd Season', season: 2, episode: 1 }, + { execute }, + ); + + assert.equal(result?.id, 2); + assert.equal(result?.via, 'sequel-chain'); assert.equal(result?.exactTitleMatch, true); }); diff --git a/src/core/services/anilist/season-resolver.ts b/src/core/services/anilist/season-resolver.ts index c57d0342..e2883a4b 100644 --- a/src/core/services/anilist/season-resolver.ts +++ b/src/core/services/anilist/season-resolver.ts @@ -370,10 +370,20 @@ export async function resolveAnilistSeasonMedia( episode: season === null || season <= 1 ? input.episode : null, }); if (!anchor) return null; - const exactTitleMatch = mediaTitles(anchor).includes(normalizeTitleIdentity(searchTitle)); + // Certifies the media actually returned, never the anchor on its behalf: a + // sequel-chain hop can land one season short (split-cour entries) while the + // anchor title still matches perfectly, and that certainty must not carry + // over to the hopped-to entry. + const exactMatchFor = (candidate: AnilistSeasonMedia): boolean => { + const titles = mediaTitles(candidate); + return ( + titles.includes(normalizeTitleIdentity(searchTitle)) || + titles.includes(normalizeTitleIdentity(input.title)) + ); + }; if (season === null || season <= 1) { - return toResolution(anchor, searchTitle, season, 'anchor', true, exactTitleMatch); + return toResolution(anchor, searchTitle, season, 'anchor', true, exactMatchFor(anchor)); } let chainError: unknown = null; @@ -387,7 +397,7 @@ export async function resolveAnilistSeasonMedia( deps.logInfo?.( `[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`, ); - return toResolution(viaChain, searchTitle, season, 'sequel-chain', true, exactTitleMatch); + return toResolution(viaChain, searchTitle, season, 'sequel-chain', true, exactMatchFor(viaChain)); } const viaAirOrder = pickByAirOrder(anchor, season, media); @@ -395,7 +405,14 @@ export async function resolveAnilistSeasonMedia( deps.logInfo?.( `[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`, ); - return toResolution(viaAirOrder, searchTitle, season, 'air-order', true, exactTitleMatch); + return toResolution( + viaAirOrder, + searchTitle, + season, + 'air-order', + true, + exactMatchFor(viaAirOrder), + ); } // The chain failed for transport reasons rather than because the season is absent; @@ -407,5 +424,5 @@ export async function resolveAnilistSeasonMedia( deps.logInfo?.( `[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`, ); - return toResolution(anchor, searchTitle, season, 'anchor', false, exactTitleMatch); + return toResolution(anchor, searchTitle, season, 'anchor', false, exactMatchFor(anchor)); } diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index dfa0ce1d..e62bba01 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -28,6 +28,7 @@ import { } from './immersion-tracker/storage'; import { applySessionLifetimeSummary, + recomputeLifetimeAnimeAggregates, reconcileStaleActiveSessions, rebuildLifetimeSummaries as rebuildLifetimeSummaryTables, shouldBackfillLifetimeSummaries, @@ -526,7 +527,7 @@ export class ImmersionTrackerService { this.logger.info( `Repaired season-scoped stats links on startup: scanned=${seasonRepair.scanned} movedVideos=${seasonRepair.movedVideos} deletedAnimeRows=${seasonRepair.deletedAnimeRows}`, ); - rebuildLifetimeSummaryTables(this.db); + recomputeLifetimeAnimeAggregates(this.db); } if (shouldBackfillLifetimeSummaries(this.db)) { const result = rebuildLifetimeSummaryTables(this.db); @@ -924,7 +925,7 @@ export class ImmersionTrackerService { animeId, ); if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) { - rebuildLifetimeSummaryTables(this.db); + recomputeLifetimeAnimeAggregates(this.db); } // Update cover art for all videos in this anime @@ -1372,7 +1373,7 @@ export class ImmersionTrackerService { metadataJson: candidate.metadataJson, }); } - rebuildLifetimeSummaryTables(this.db); + recomputeLifetimeAnimeAggregates(this.db); } recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void { @@ -1445,7 +1446,7 @@ export class ImmersionTrackerService { this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId), ); if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) { - rebuildLifetimeSummaryTables(this.db); + recomputeLifetimeAnimeAggregates(this.db); } } diff --git a/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts b/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts index a65b367a..b7d97a07 100644 --- a/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts +++ b/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts @@ -72,7 +72,10 @@ interface EpisodeSeed { cards?: number; } -/** One episode with one ended session, so lifetime rebuilds have something to sum. */ +/** + * One episode with one ended session, plus the imm_lifetime_media row the + * session would have left behind, so lifetime aggregates have something to sum. + */ function insertEpisode(db: DatabaseSync, seed: EpisodeSeed): void { const activeMs = seed.activeMs ?? 1000; const cards = seed.cards ?? 1; @@ -107,6 +110,18 @@ function insertEpisode(db: DatabaseSync, seed: EpisodeSeed): void { `INSERT INTO imm_subtitle_lines(session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE) VALUES (?, ?, ?, 1, ?, ?, ?)`, ).run(seed.videoId, seed.videoId, seed.animeId, `line ${seed.videoId}`, BASE_MS, BASE_MS); + db.prepare( + `INSERT INTO imm_lifetime_media(video_id, total_sessions, total_active_ms, total_cards, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (?, 1, ?, ?, 1, ?, ?, ?, ?)`, + ).run( + seed.videoId, + activeMs, + cards, + String(BASE_MS), + String(BASE_MS + activeMs), + BASE_MS, + BASE_MS, + ); } function animeIds(db: DatabaseSync): number[] { @@ -162,6 +177,49 @@ test('mergeAnimeRecords folds episodes, lines and lifetime totals into the targe }); }); +test('merge and move preserve lifetime history whose raw sessions were pruned', () => { + withDb((db) => { + insertAnime(db, { animeId: 1, key: 'show', title: 'Show' }); + insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' }); + insertAnime(db, { animeId: 3, key: 'other show', title: 'Other Show' }); + insertEpisode(db, { videoId: 1, animeId: 1, activeMs: 1000, cards: 1 }); + insertEpisode(db, { videoId: 2, animeId: 2, season: 1, activeMs: 2000, cards: 3 }); + insertEpisode(db, { videoId: 3, animeId: 3, activeMs: 4000, cards: 5 }); + // Retention pruned every raw session; only the lifetime summaries remain. + db.exec('DELETE FROM imm_sessions'); + db.prepare( + `UPDATE imm_lifetime_global + SET total_sessions = 200, total_active_ms = 360000000, total_cards = 500, active_days = 90 + WHERE global_id = 1`, + ).run(); + + mergeAnimeRecords(db, 1, [2]); + moveVideoToAnime(db, 3, 1); + + const globalRow = db + .prepare( + `SELECT total_sessions AS sessions, total_active_ms AS activeMs, total_cards AS cards, active_days AS days + FROM imm_lifetime_global WHERE global_id = 1`, + ) + .get() as { sessions: number; activeMs: number; cards: number; days: number }; + assert.equal(globalRow.sessions, 200); + assert.equal(globalRow.activeMs, 360000000); + assert.equal(globalRow.cards, 500); + assert.equal(globalRow.days, 90); + + const survivor = db + .prepare( + `SELECT total_active_ms AS activeMs, total_cards AS cards, episodes_started AS episodes + FROM imm_lifetime_anime WHERE anime_id = 1`, + ) + .get() as { activeMs: number; cards: number; episodes: number }; + assert.equal(survivor.activeMs, 7000); + assert.equal(survivor.cards, 9); + assert.equal(survivor.episodes, 3); + assert.equal(db.prepare('SELECT 1 FROM imm_lifetime_anime WHERE anime_id = 3').get(), undefined); + }); +}); + test('mergeAnimeRecords repoints subtitle lines recorded before the anime link landed', () => { withDb((db) => { insertAnime(db, { animeId: 1, key: 'show', title: 'Show' }); @@ -698,3 +756,33 @@ test('resolveAnimeAnilistConflict leaves an entry that already links elsewhere a assert.deepEqual(getAnimeMergeRecommendations(db), []); }); }); + +test('automatic AniList update onto an entry that already links elsewhere does not throw', () => { + withDb((db) => { + insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 }); + insertAnime(db, { animeId: 2, key: 'show s2', title: 'Show Season 2', anilistId: 999 }); + insertEpisode(db, { videoId: 1, animeId: 1, season: 1 }); + insertEpisode(db, { videoId: 2, animeId: 2, season: 2 }); + + // Entry 2 explicitly links to 999; a later video re-resolving to entry 1's + // id must be refused, not written over the UNIQUE anilist_id column. + updateAnimeAnilistInfo(db, 2, { + anilistId: 163132, + titleRomaji: 'Show', + titleEnglish: null, + titleNative: null, + episodesTotal: 12, + exactTitleMatch: true, + }); + + assert.deepEqual(animeIds(db), [1, 2]); + assert.equal( + ( + db.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2').get() as { + anilistId: number; + } + ).anilistId, + 999, + ); + }); +}); diff --git a/src/core/services/immersion-tracker/anime-merge.ts b/src/core/services/immersion-tracker/anime-merge.ts index 86ec5c88..006b94f5 100644 --- a/src/core/services/immersion-tracker/anime-merge.ts +++ b/src/core/services/immersion-tracker/anime-merge.ts @@ -1,5 +1,5 @@ import type { DatabaseSync } from './sqlite'; -import { rebuildLifetimeSummariesInTransaction } from './lifetime'; +import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime'; import { toDbTimestamp } from './query-shared'; import { nowMs } from './time'; @@ -151,8 +151,9 @@ function absorbAnimeMetadata( * is repointed, metadata the target is missing is inherited from the sources, * and the emptied source rows are deleted. * - * Assumes the caller already holds a write transaction and rebuilds the - * lifetime summaries afterwards; use {@link mergeAnimeRecords} otherwise. + * Assumes the caller already holds a write transaction and refreshes the + * per-anime lifetime aggregates afterwards; use {@link mergeAnimeRecords} + * otherwise. */ export function mergeAnimeRecordsInTransaction( db: DatabaseSync, @@ -237,7 +238,7 @@ export function mergeAnimeRecords( return runInTransaction(db, () => { const summary = mergeAnimeRecordsInTransaction(db, targetAnimeId, sourceAnimeIds); if (summary.mergedAnimeIds.length > 0) { - rebuildLifetimeSummariesInTransaction(db); + recomputeLifetimeAnimeAggregatesInTransaction(db); } return summary; }); @@ -286,7 +287,7 @@ export function moveVideoToAnime( removedPreviousAnime = true; } - rebuildLifetimeSummariesInTransaction(db); + recomputeLifetimeAnimeAggregatesInTransaction(db); return { targetAnimeId, previousAnimeId, removedPreviousAnime }; }); } diff --git a/src/core/services/immersion-tracker/anime-season-repair.ts b/src/core/services/immersion-tracker/anime-season-repair.ts index dae92003..87c80388 100644 --- a/src/core/services/immersion-tracker/anime-season-repair.ts +++ b/src/core/services/immersion-tracker/anime-season-repair.ts @@ -380,8 +380,12 @@ export function resolveAnimeAnilistConflict( targetRow.anilist_id !== anilistId ) { // An automatic lookup disagreeing with an existing explicit link is a - // mis-resolution, not evidence that either row should move or merge. - return emptySummary(1); + // mis-resolution, not evidence that either row should move or merge. The + // colliding id must not be assigned either: another row owns it and + // imm_anime.anilist_id is UNIQUE. + const summary = emptySummary(1); + summary.anilistAssignmentBlocked = true; + return summary; } const isManual = options.survivor === 'target' || options.matchConfidence === 'manual'; if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) { diff --git a/src/core/services/immersion-tracker/lifetime.ts b/src/core/services/immersion-tracker/lifetime.ts index d3ba121a..bf65a179 100644 --- a/src/core/services/immersion-tracker/lifetime.ts +++ b/src/core/services/immersion-tracker/lifetime.ts @@ -708,6 +708,87 @@ export function rebuildLifetimeSummariesInTransaction( return rebuildLifetimeSummariesInternal(db, rebuiltAtMs); } +/** + * Re-derive every per-anime lifetime row from the per-video summaries after + * episodes changed owners (merge, move, season repair). + * + * Deliberately NOT a full rebuild: {@link rebuildLifetimeSummariesInTransaction} + * recomputes from raw sessions, which are pruned after the retention window, so + * it silently truncates lifetime history. `imm_lifetime_media` is keyed by + * video and survives repointing, so aggregating it preserves all-time totals; + * `imm_lifetime_global` only needs `anime_completed` refreshed because moving + * attribution between entries cannot change the global counters. + * + * Assumes the caller holds a write transaction; use + * {@link recomputeLifetimeAnimeAggregates} otherwise. + */ +export function recomputeLifetimeAnimeAggregatesInTransaction(db: DatabaseSync): void { + const updatedAt = toDbTimestamp(nowMs()); + db.exec('DELETE FROM imm_lifetime_anime'); + db.prepare( + ` + INSERT INTO imm_lifetime_anime ( + anime_id, + total_sessions, + total_active_ms, + total_cards, + total_lines_seen, + total_tokens_seen, + episodes_started, + episodes_completed, + first_watched_ms, + last_watched_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) + SELECT + v.anime_id, + COALESCE(SUM(m.total_sessions), 0), + COALESCE(SUM(m.total_active_ms), 0), + COALESCE(SUM(m.total_cards), 0), + COALESCE(SUM(m.total_lines_seen), 0), + COALESCE(SUM(m.total_tokens_seen), 0), + COUNT(*), + COUNT(CASE WHEN m.completed > 0 THEN 1 END), + MIN(m.first_watched_ms), + MAX(m.last_watched_ms), + ?, + ? + FROM imm_lifetime_media m + JOIN imm_videos v ON v.video_id = m.video_id + WHERE v.anime_id IS NOT NULL + GROUP BY v.anime_id + `, + ).run(updatedAt, updatedAt); + db.prepare( + ` + UPDATE imm_lifetime_global + SET + anime_completed = ( + SELECT COUNT(*) + FROM imm_lifetime_anime la + JOIN imm_anime a ON a.anime_id = la.anime_id + WHERE a.episodes_total IS NOT NULL + AND a.episodes_total > 0 + AND la.episodes_completed >= a.episodes_total + ), + LAST_UPDATE_DATE = ? + WHERE global_id = 1 + `, + ).run(updatedAt); +} + +export function recomputeLifetimeAnimeAggregates(db: DatabaseSync): void { + db.exec('BEGIN IMMEDIATE'); + try { + recomputeLifetimeAnimeAggregatesInTransaction(db); + db.exec('COMMIT'); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + export function reconcileStaleActiveSessions(db: DatabaseSync): number { const sessions = getRetainedStaleActiveSessions(db); if (sessions.length === 0) { diff --git a/src/core/services/immersion-tracker/query-maintenance.ts b/src/core/services/immersion-tracker/query-maintenance.ts index 84c9e91b..2ea36c07 100644 --- a/src/core/services/immersion-tracker/query-maintenance.ts +++ b/src/core/services/immersion-tracker/query-maintenance.ts @@ -1,7 +1,10 @@ import { createHash } from 'node:crypto'; import type { DatabaseSync } from './sqlite'; import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage'; -import { rebuildLifetimeSummaries, rebuildLifetimeSummariesInTransaction } from './lifetime'; +import { + recomputeLifetimeAnimeAggregates, + rebuildLifetimeSummariesInTransaction, +} from './lifetime'; import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance'; import { nowMs } from './time'; import { resolveAnimeAnilistConflict } from './anime-season-repair'; @@ -460,7 +463,7 @@ export function updateAnimeAnilistInfo( targetRow.anime_id, ); if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) { - rebuildLifetimeSummaries(db); + recomputeLifetimeAnimeAggregates(db); } }