fix(stats): subtract lifetime totals incrementally on delete (#196)

This commit is contained in:
2026-08-14 22:13:59 -07:00
committed by GitHub
parent 6fb3d2eb13
commit d963def1a9
20 changed files with 1446 additions and 370 deletions
+41 -7
View File
@@ -30,9 +30,11 @@ import {
} from './immersion-tracker/storage';
import {
applySessionLifetimeSummary,
recomputeLifetimeAnimeAggregates,
reconcileStaleActiveSessions,
rebuildLifetimeSummaries as rebuildLifetimeSummaryTables,
recomputeLifetimeAnimeFromMedia,
recomputeLifetimeGlobalFromSummaries,
repairLifetimeSummariesFromMedia,
shouldBackfillLifetimeSummaries,
} from './immersion-tracker/lifetime';
import {
@@ -534,7 +536,7 @@ export class ImmersionTrackerService {
this.logger.info(
`Repaired season-scoped stats links on startup: scanned=${seasonRepair.scanned} movedVideos=${seasonRepair.movedVideos} deletedAnimeRows=${seasonRepair.deletedAnimeRows}`,
);
recomputeLifetimeAnimeAggregates(this.db);
repairLifetimeSummariesFromMedia(this.db);
}
if (shouldBackfillLifetimeSummaries(this.db)) {
const result = rebuildLifetimeSummaryTables(this.db);
@@ -660,7 +662,17 @@ export class ImmersionTrackerService {
async rebuildLifetimeSummaries(): Promise<LifetimeRebuildSummary> {
this.requireWriteQueueDrained('rebuilding lifetime summaries');
return rebuildLifetimeSummaryTables(this.db);
// Non-destructive: recomputes from the media ledger (or bootstraps empty
// lifetime tables), so history older than session retention is never reset.
const repaired = repairLifetimeSummariesFromMedia(this.db);
// Sessions currently tracked in the applied-sessions ledger, not sessions
// processed by this call — the repair recomputes summaries instead of
// re-applying sessions. Retention prunes these rows (FK cascade), so on an
// old database this reads lower than the history the totals still include.
const appliedRow = this.db
.prepare('SELECT COUNT(*) AS count FROM imm_lifetime_applied_sessions')
.get() as { count: number };
return { appliedSessions: Number(appliedRow.count), rebuiltAtMs: repaired.repairedAtMs };
}
async getKanjiStats(limit = 100): Promise<KanjiStatsRow[]> {
@@ -943,8 +955,16 @@ export class ImmersionTrackerService {
nowMs(),
animeId,
);
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
recomputeLifetimeAnimeAggregates(this.db);
// Empty lifetime tables still need the retained-session bootstrap. Once a
// media ledger exists, only the redistributed and explicitly edited anime
// can have changed.
if (shouldBackfillLifetimeSummaries(this.db)) {
repairLifetimeSummariesFromMedia(this.db);
} else {
const affectedAnimeIds = new Set(repair.affectedAnimeIds);
affectedAnimeIds.add(animeId);
recomputeLifetimeAnimeFromMedia(this.db, [...affectedAnimeIds]);
recomputeLifetimeGlobalFromSummaries(this.db);
}
// Update cover art for all videos in this anime
@@ -1393,7 +1413,7 @@ export class ImmersionTrackerService {
metadataJson: candidate.metadataJson,
});
}
recomputeLifetimeAnimeAggregates(this.db);
repairLifetimeSummariesFromMedia(this.db);
}
recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void {
@@ -1468,7 +1488,21 @@ export class ImmersionTrackerService {
this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId),
);
if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) {
recomputeLifetimeAnimeAggregates(this.db);
// Playback-time relink: only the old and new anime are affected, so
// recompute just those from the media ledger instead of a full repair.
const affectedAnimeIds = new Set<number>([animeId]);
if (previousLink?.animeId) affectedAnimeIds.add(previousLink.animeId);
let transactionStarted = false;
try {
this.db.exec('BEGIN IMMEDIATE');
transactionStarted = true;
recomputeLifetimeAnimeFromMedia(this.db, [...affectedAnimeIds]);
recomputeLifetimeGlobalFromSummaries(this.db);
this.db.exec('COMMIT');
} catch (error) {
if (transactionStarted) this.db.exec('ROLLBACK');
throw error;
}
}
}