fix(stats): preserve lifetime history across anime merges and moves

- Recompute anime aggregates from retained lifetime media summaries
- Prevent sequel title evidence and AniList conflicts from misattributing entries
This commit is contained in:
2026-08-14 00:17:14 -07:00
parent 93bdff1ca2
commit 6311838ef6
9 changed files with 263 additions and 45 deletions
@@ -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,
);
});
});
@@ -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 };
});
}
@@ -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)) {
@@ -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) {
@@ -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);
}
}