import type { DatabaseSync } from './sqlite'; import { finalizeSessionRecord } from './session'; import { nowMs } from './time'; import { forEachIdChunk, makePlaceholders, toDbTimestamp } from './query-shared'; import type { LifetimeRebuildSummary, SessionState } from './types'; interface TelemetryRow { active_watched_ms: number | null; cards_mined: number | null; lines_seen: number | null; tokens_seen: number | null; } interface VideoRow { anime_id: number | null; watched: number; } interface AnimeRow { episodes_total: number | null; } function asPositiveNumber(value: number | null, fallback: number): number { const resolved = value !== null && Number.isFinite(value) ? value : fallback; return Number.isFinite(resolved) ? Math.floor(Math.max(resolved, 0)) : 0; } interface ExistenceRow { count: number; } interface LifetimeMediaStateRow { completed: number; } interface LifetimeAnimeStateRow { episodes_completed: number; } interface RetainedSessionRow { sessionId: number; videoId: number; startedAtMs: number | string; endedAtMs: number | string; lastMediaMs: number | null; totalWatchedMs: number; activeWatchedMs: number; linesSeen: number; tokensSeen: number; cardsMined: number; lookupCount: number; lookupHits: number; yomitanLookupCount: number; pauseCount: number; pauseMs: number; seekForwardCount: number; seekBackwardCount: number; mediaBufferEvents: number; } const RETAINED_SESSION_METRICS_CTE = ` retained_sessions AS ( SELECT s.session_id, s.video_id, v.anime_id, s.started_at_ms, s.ended_at_ms, CAST(MAX(COALESCE(t.active_watched_ms, s.active_watched_ms, 0), 0) AS INTEGER) AS active_ms, CAST(MAX(COALESCE(t.cards_mined, s.cards_mined, 0), 0) AS INTEGER) AS cards_mined, CAST(MAX(COALESCE(t.lines_seen, s.lines_seen, 0), 0) AS INTEGER) AS lines_seen, CAST(MAX(COALESCE(t.tokens_seen, s.tokens_seen, 0), 0) AS INTEGER) AS tokens_seen, CASE WHEN v.watched > 0 THEN 1 ELSE 0 END AS completed FROM imm_sessions s JOIN imm_videos v ON v.video_id = s.video_id LEFT JOIN imm_session_telemetry t ON t.telemetry_id = ( SELECT telemetry_id FROM imm_session_telemetry WHERE session_id = s.session_id ORDER BY sample_ms DESC, telemetry_id DESC LIMIT 1 ) WHERE s.ended_at_ms IS NOT NULL ) `; function hasRetainedPriorSession( db: DatabaseSync, videoId: number, startedAtMs: number, currentSessionId: number, ): boolean { const row = db .prepare( ` SELECT 1 AS found FROM imm_sessions WHERE video_id = ? AND ( CAST(started_at_ms AS REAL) < CAST(? AS REAL) OR ( CAST(started_at_ms AS REAL) = CAST(? AS REAL) AND session_id < ? ) ) LIMIT 1 `, ) .get(videoId, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs), currentSessionId) as { found: number; } | null; return Boolean(row); } function isFirstSessionForLocalDay( db: DatabaseSync, currentSessionId: number, startedAtMs: number, ): boolean { const row = db .prepare( ` SELECT 1 AS found FROM imm_sessions WHERE session_id != ? AND CAST( julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER ) = CAST( julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER ) AND ( CAST(started_at_ms AS REAL) < CAST(? AS REAL) OR ( CAST(started_at_ms AS REAL) = CAST(? AS REAL) AND session_id < ? ) ) LIMIT 1 `, ) .get( currentSessionId, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs), currentSessionId, ) as { found: number } | null; return !row; } function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void { db.exec(` DELETE FROM imm_lifetime_anime; DELETE FROM imm_lifetime_media; DELETE FROM imm_lifetime_applied_sessions; `); db.prepare( ` UPDATE imm_lifetime_global SET total_sessions = 0, total_active_ms = 0, total_cards = 0, active_days = 0, episodes_started = 0, episodes_completed = 0, anime_completed = 0, last_rebuilt_ms = ?, LAST_UPDATE_DATE = ? WHERE global_id = 1 `, ).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs)); } function rebuildLifetimeSummariesInternal( db: DatabaseSync, rebuiltAtMs: number, ): LifetimeRebuildSummary { const rebuiltAtDbMs = toDbTimestamp(rebuiltAtMs); const appliedSessions = Number( ( db .prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NOT NULL') .get() as { total: number } ).total, ); resetLifetimeSummaries(db, rebuiltAtMs); db.prepare( ` INSERT INTO imm_lifetime_applied_sessions ( session_id, applied_at_ms, CREATED_DATE, LAST_UPDATE_DATE ) SELECT session_id, ended_at_ms, ?, ? FROM imm_sessions WHERE ended_at_ms IS NOT NULL `, ).run(rebuiltAtDbMs, rebuiltAtDbMs); db.prepare( ` WITH ${RETAINED_SESSION_METRICS_CTE} INSERT INTO imm_lifetime_media ( video_id, total_sessions, total_active_ms, total_cards, total_lines_seen, total_tokens_seen, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE ) SELECT video_id, COUNT(*) AS total_sessions, COALESCE(SUM(active_ms), 0) AS total_active_ms, COALESCE(SUM(cards_mined), 0) AS total_cards, COALESCE(SUM(lines_seen), 0) AS total_lines_seen, COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen, MAX(completed) AS completed, MIN(started_at_ms) AS first_watched_ms, MAX(ended_at_ms) AS last_watched_ms, ? AS CREATED_DATE, ? AS LAST_UPDATE_DATE FROM retained_sessions GROUP BY video_id `, ).run(rebuiltAtDbMs, rebuiltAtDbMs); db.prepare( ` WITH ${RETAINED_SESSION_METRICS_CTE} 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 anime_id, COUNT(*) AS total_sessions, COALESCE(SUM(active_ms), 0) AS total_active_ms, COALESCE(SUM(cards_mined), 0) AS total_cards, COALESCE(SUM(lines_seen), 0) AS total_lines_seen, COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen, COUNT(DISTINCT video_id) AS episodes_started, COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END) AS episodes_completed, MIN(started_at_ms) AS first_watched_ms, MAX(ended_at_ms) AS last_watched_ms, ? AS CREATED_DATE, ? AS LAST_UPDATE_DATE FROM retained_sessions WHERE anime_id IS NOT NULL GROUP BY anime_id `, ).run(rebuiltAtDbMs, rebuiltAtDbMs); db.prepare( ` WITH ${RETAINED_SESSION_METRICS_CTE}, anime_completion AS ( SELECT rs.anime_id, MAX(a.episodes_total) AS episodes_total, COUNT(DISTINCT CASE WHEN rs.completed > 0 THEN rs.video_id END) AS completed_videos FROM retained_sessions rs JOIN imm_anime a ON a.anime_id = rs.anime_id WHERE rs.anime_id IS NOT NULL GROUP BY rs.anime_id ) UPDATE imm_lifetime_global SET total_sessions = (SELECT COUNT(*) FROM retained_sessions), total_active_ms = (SELECT COALESCE(SUM(active_ms), 0) FROM retained_sessions), total_cards = (SELECT COALESCE(SUM(cards_mined), 0) FROM retained_sessions), active_days = ( SELECT COUNT(DISTINCT CAST( julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER )) FROM retained_sessions ), episodes_started = (SELECT COUNT(DISTINCT video_id) FROM retained_sessions), episodes_completed = ( SELECT COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END) FROM retained_sessions ), anime_completed = ( SELECT COUNT(*) FROM anime_completion WHERE episodes_total IS NOT NULL AND episodes_total > 0 AND completed_videos >= episodes_total ), last_rebuilt_ms = ?, LAST_UPDATE_DATE = ? WHERE global_id = 1 `, ).run(rebuiltAtDbMs, rebuiltAtDbMs); return { appliedSessions, rebuiltAtMs, }; } function toRebuildSessionState(row: RetainedSessionRow): SessionState { return { sessionId: row.sessionId, videoId: row.videoId, startedAtMs: row.startedAtMs as number, currentLineIndex: 0, lastWallClockMs: row.endedAtMs as number, lastMediaMs: row.lastMediaMs, lastPauseStartMs: null, isPaused: false, pendingTelemetry: false, markedWatched: false, totalWatchedMs: Math.max(0, row.totalWatchedMs), activeWatchedMs: Math.max(0, row.activeWatchedMs), linesSeen: Math.max(0, row.linesSeen), tokensSeen: Math.max(0, row.tokensSeen), cardsMined: Math.max(0, row.cardsMined), lookupCount: Math.max(0, row.lookupCount), lookupHits: Math.max(0, row.lookupHits), yomitanLookupCount: Math.max(0, row.yomitanLookupCount), pauseCount: Math.max(0, row.pauseCount), pauseMs: Math.max(0, row.pauseMs), seekForwardCount: Math.max(0, row.seekForwardCount), seekBackwardCount: Math.max(0, row.seekBackwardCount), mediaBufferEvents: Math.max(0, row.mediaBufferEvents), }; } function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[] { const rows = db .prepare( ` SELECT s.session_id AS sessionId, s.video_id AS videoId, s.started_at_ms AS startedAtMs, COALESCE(t.sample_ms, s.LAST_UPDATE_DATE, s.started_at_ms) AS endedAtMs, s.ended_media_ms AS lastMediaMs, COALESCE(t.total_watched_ms, s.total_watched_ms, 0) AS totalWatchedMs, COALESCE(t.active_watched_ms, s.active_watched_ms, 0) AS activeWatchedMs, COALESCE(t.lines_seen, s.lines_seen, 0) AS linesSeen, COALESCE(t.tokens_seen, s.tokens_seen, 0) AS tokensSeen, COALESCE(t.cards_mined, s.cards_mined, 0) AS cardsMined, COALESCE(t.lookup_count, s.lookup_count, 0) AS lookupCount, COALESCE(t.lookup_hits, s.lookup_hits, 0) AS lookupHits, COALESCE(t.yomitan_lookup_count, s.yomitan_lookup_count, 0) AS yomitanLookupCount, COALESCE(t.pause_count, s.pause_count, 0) AS pauseCount, COALESCE(t.pause_ms, s.pause_ms, 0) AS pauseMs, COALESCE(t.seek_forward_count, s.seek_forward_count, 0) AS seekForwardCount, COALESCE(t.seek_backward_count, s.seek_backward_count, 0) AS seekBackwardCount, COALESCE(t.media_buffer_events, s.media_buffer_events, 0) AS mediaBufferEvents FROM imm_sessions s LEFT JOIN imm_session_telemetry t ON t.telemetry_id = ( SELECT telemetry_id FROM imm_session_telemetry WHERE session_id = s.session_id ORDER BY sample_ms DESC, telemetry_id DESC LIMIT 1 ) WHERE s.ended_at_ms IS NULL ORDER BY s.started_at_ms ASC, s.session_id ASC `, ) .all() as Array< Omit & { startedAtMs: number | string; endedAtMs: number | string; lastMediaMs: number | string | null; } >; return rows.map((row) => ({ ...row, startedAtMs: row.startedAtMs, endedAtMs: row.endedAtMs, lastMediaMs: row.lastMediaMs === null ? null : Number(row.lastMediaMs), })) as RetainedSessionRow[]; } function upsertLifetimeMedia( db: DatabaseSync, videoId: number, nowMs: number | string, activeMs: number, cardsMined: number, linesSeen: number, tokensSeen: number, completed: number, startedAtMs: number | string, endedAtMs: number | string, ): void { db.prepare( ` INSERT INTO imm_lifetime_media( video_id, total_sessions, total_active_ms, total_cards, total_lines_seen, total_tokens_seen, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE ) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(video_id) DO UPDATE SET total_sessions = total_sessions + 1, total_active_ms = total_active_ms + excluded.total_active_ms, total_cards = total_cards + excluded.total_cards, total_lines_seen = total_lines_seen + excluded.total_lines_seen, total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen, completed = MAX(completed, excluded.completed), first_watched_ms = CASE WHEN excluded.first_watched_ms IS NULL THEN first_watched_ms WHEN first_watched_ms IS NULL THEN excluded.first_watched_ms WHEN excluded.first_watched_ms < first_watched_ms THEN excluded.first_watched_ms ELSE first_watched_ms END, last_watched_ms = CASE WHEN excluded.last_watched_ms IS NULL THEN last_watched_ms WHEN last_watched_ms IS NULL THEN excluded.last_watched_ms WHEN excluded.last_watched_ms > last_watched_ms THEN excluded.last_watched_ms ELSE last_watched_ms END, LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE `, ).run( videoId, activeMs, cardsMined, linesSeen, tokensSeen, completed, startedAtMs, endedAtMs, nowMs, nowMs, ); } function upsertLifetimeAnime( db: DatabaseSync, animeId: number, nowMs: number | string, activeMs: number, cardsMined: number, linesSeen: number, tokensSeen: number, episodesStartedDelta: number, episodesCompletedDelta: number, startedAtMs: number | string, endedAtMs: number | string, ): void { 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 ) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(anime_id) DO UPDATE SET total_sessions = total_sessions + 1, total_active_ms = total_active_ms + excluded.total_active_ms, total_cards = total_cards + excluded.total_cards, total_lines_seen = total_lines_seen + excluded.total_lines_seen, total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen, episodes_started = episodes_started + excluded.episodes_started, episodes_completed = episodes_completed + excluded.episodes_completed, first_watched_ms = CASE WHEN excluded.first_watched_ms IS NULL THEN first_watched_ms WHEN first_watched_ms IS NULL THEN excluded.first_watched_ms WHEN excluded.first_watched_ms < first_watched_ms THEN excluded.first_watched_ms ELSE first_watched_ms END, last_watched_ms = CASE WHEN excluded.last_watched_ms IS NULL THEN last_watched_ms WHEN last_watched_ms IS NULL THEN excluded.last_watched_ms WHEN excluded.last_watched_ms > last_watched_ms THEN excluded.last_watched_ms ELSE last_watched_ms END, LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE `, ).run( animeId, activeMs, cardsMined, linesSeen, tokensSeen, episodesStartedDelta, episodesCompletedDelta, startedAtMs, endedAtMs, nowMs, nowMs, ); } export function applySessionLifetimeSummary( db: DatabaseSync, session: SessionState, endedAtMs: number | string, ): void { const updatedAtMs = toDbTimestamp(nowMs()); const applyResult = db .prepare( ` INSERT INTO imm_lifetime_applied_sessions ( session_id, applied_at_ms, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( ?, ?, ?, ? ) ON CONFLICT(session_id) DO NOTHING `, ) .run(session.sessionId, endedAtMs, updatedAtMs, updatedAtMs); if ((applyResult.changes ?? 0) <= 0) { return; } const telemetry = db .prepare( ` SELECT active_watched_ms, cards_mined, lines_seen, tokens_seen FROM imm_session_telemetry WHERE session_id = ? ORDER BY sample_ms DESC, telemetry_id DESC LIMIT 1 `, ) .get(session.sessionId) as TelemetryRow | null; const video = db .prepare('SELECT anime_id, watched FROM imm_videos WHERE video_id = ?') .get(session.videoId) as VideoRow | null; const mediaLifetime = (db .prepare('SELECT completed FROM imm_lifetime_media WHERE video_id = ?') .get(session.videoId) as LifetimeMediaStateRow | null | undefined) ?? null; const animeLifetime = video?.anime_id ? ((db .prepare('SELECT episodes_completed FROM imm_lifetime_anime WHERE anime_id = ?') .get(video.anime_id) as LifetimeAnimeStateRow | null | undefined) ?? null) : null; const anime = video?.anime_id ? ((db .prepare('SELECT episodes_total FROM imm_anime WHERE anime_id = ?') .get(video.anime_id) as AnimeRow | null | undefined) ?? null) : null; const activeMs = asPositiveNumber(telemetry?.active_watched_ms ?? null, session.activeWatchedMs); const cardsMined = asPositiveNumber(telemetry?.cards_mined ?? null, session.cardsMined); const linesSeen = asPositiveNumber(telemetry?.lines_seen ?? null, session.linesSeen); const tokensSeen = asPositiveNumber(telemetry?.tokens_seen ?? null, session.tokensSeen); const watched = video?.watched ?? 0; const isFirstSessionForVideoRun = mediaLifetime === null && !hasRetainedPriorSession(db, session.videoId, session.startedAtMs, session.sessionId); const isFirstCompletedSessionForVideoRun = watched > 0 && Number(mediaLifetime?.completed ?? 0) <= 0; const isFirstSessionForDay = isFirstSessionForLocalDay( db, session.sessionId, session.startedAtMs, ); const episodesCompletedBefore = Number(animeLifetime?.episodes_completed ?? 0); const animeEpisodesTotal = anime?.episodes_total ?? null; const animeCompletedDelta = watched > 0 && isFirstCompletedSessionForVideoRun && animeEpisodesTotal !== null && animeEpisodesTotal > 0 && episodesCompletedBefore < animeEpisodesTotal && episodesCompletedBefore + 1 >= animeEpisodesTotal ? 1 : 0; db.prepare( ` UPDATE imm_lifetime_global SET total_sessions = total_sessions + 1, total_active_ms = total_active_ms + ?, total_cards = total_cards + ?, active_days = active_days + ?, episodes_started = episodes_started + ?, episodes_completed = episodes_completed + ?, anime_completed = anime_completed + ?, LAST_UPDATE_DATE = ? WHERE global_id = 1 `, ).run( activeMs, cardsMined, isFirstSessionForDay ? 1 : 0, isFirstSessionForVideoRun ? 1 : 0, isFirstCompletedSessionForVideoRun ? 1 : 0, animeCompletedDelta, updatedAtMs, ); upsertLifetimeMedia( db, session.videoId, updatedAtMs, activeMs, cardsMined, linesSeen, tokensSeen, watched > 0 ? 1 : 0, session.startedAtMs, endedAtMs, ); if (video?.anime_id) { upsertLifetimeAnime( db, video.anime_id, updatedAtMs, activeMs, cardsMined, linesSeen, tokensSeen, isFirstSessionForVideoRun ? 1 : 0, isFirstCompletedSessionForVideoRun ? 1 : 0, session.startedAtMs, endedAtMs, ); } } export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSummary { const rebuiltAtMs = nowMs(); db.exec('BEGIN'); try { const summary = rebuildLifetimeSummariesInTransaction(db, rebuiltAtMs); db.exec('COMMIT'); return summary; } catch (error) { db.exec('ROLLBACK'); throw error; } } export function rebuildLifetimeSummariesInTransaction( db: DatabaseSync, rebuiltAtMs = nowMs(), ): LifetimeRebuildSummary { return rebuildLifetimeSummariesInternal(db, rebuiltAtMs); } const LOCAL_DAY_EXPR = `CAST( julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER )`; interface LifetimeMediaRemoval { videoId: number; sessions: number; activeMs: number; cards: number; linesSeen: number; tokensSeen: number; } /** * What a pending delete removes from the lifetime summary tables. * * Lifetime totals intentionally outlive raw-session retention, so they can * never be rebuilt from `imm_sessions` without collapsing history to the * retention window. Deletes instead subtract exactly what the deleted rows * contributed: this plan is measured before the rows are removed and applied * after. */ export interface LifetimeRemovalPlan { /** Per surviving video: summed metrics of its deleted, lifetime-applied sessions. */ mediaRemovals: LifetimeMediaRemoval[]; /** Surviving anime whose lifetime rows must be recomputed from their media rows. */ affectedAnimeIds: number[]; /** Local-day keys touched by deleted applied sessions, for active_days upkeep. */ affectedDayKeys: number[]; } export function planLifetimeRemovals( db: DatabaseSync, args: { /** Every session being deleted, including ones expanded from video/anime deletes. */ deletedSessionIds: number[]; /** Deleted sessions whose video survives the delete. */ sessionIdsOnSurvivingVideos: number[]; deletedVideoIds: number[]; deletedAnimeIds: number[]; }, ): LifetimeRemovalPlan { const mediaRemovalsByVideo = new Map(); forEachIdChunk(args.sessionIdsOnSurvivingVideos, (chunk) => { const rows = db .prepare( ` SELECT s.video_id AS videoId, COUNT(*) AS sessions, COALESCE(SUM(CAST(MAX(COALESCE(t.active_watched_ms, s.active_watched_ms, 0), 0) AS INTEGER)), 0) AS activeMs, COALESCE(SUM(CAST(MAX(COALESCE(t.cards_mined, s.cards_mined, 0), 0) AS INTEGER)), 0) AS cards, COALESCE(SUM(CAST(MAX(COALESCE(t.lines_seen, s.lines_seen, 0), 0) AS INTEGER)), 0) AS linesSeen, COALESCE(SUM(CAST(MAX(COALESCE(t.tokens_seen, s.tokens_seen, 0), 0) AS INTEGER)), 0) AS tokensSeen FROM imm_sessions s JOIN imm_lifetime_applied_sessions a ON a.session_id = s.session_id LEFT JOIN imm_session_telemetry t ON t.telemetry_id = ( SELECT telemetry_id FROM imm_session_telemetry WHERE session_id = s.session_id ORDER BY sample_ms DESC, telemetry_id DESC LIMIT 1 ) WHERE s.session_id IN (${makePlaceholders(chunk)}) GROUP BY s.video_id `, ) .all(...chunk) as LifetimeMediaRemoval[]; for (const row of rows) { const existing = mediaRemovalsByVideo.get(row.videoId); if (!existing) { mediaRemovalsByVideo.set(row.videoId, { ...row }); continue; } existing.sessions += row.sessions; existing.activeMs += row.activeMs; existing.cards += row.cards; existing.linesSeen += row.linesSeen; existing.tokensSeen += row.tokensSeen; } }); const deletedAnimeIds = new Set(args.deletedAnimeIds); const affectedAnimeIds = new Set(); forEachIdChunk(args.deletedVideoIds, (chunk) => { const rows = db .prepare( `SELECT DISTINCT anime_id AS animeId FROM imm_videos WHERE video_id IN (${makePlaceholders(chunk)}) AND anime_id IS NOT NULL`, ) .all(...chunk) as Array<{ animeId: number }>; for (const row of rows) affectedAnimeIds.add(row.animeId); }); forEachIdChunk(args.sessionIdsOnSurvivingVideos, (chunk) => { const rows = db .prepare( `SELECT DISTINCT v.anime_id AS animeId FROM imm_sessions s JOIN imm_videos v ON v.video_id = s.video_id WHERE s.session_id IN (${makePlaceholders(chunk)}) AND v.anime_id IS NOT NULL`, ) .all(...chunk) as Array<{ animeId: number }>; for (const row of rows) affectedAnimeIds.add(row.animeId); }); for (const animeId of deletedAnimeIds) affectedAnimeIds.delete(animeId); const affectedDayKeys = new Set(); forEachIdChunk(args.deletedSessionIds, (chunk) => { const rows = db .prepare( `SELECT DISTINCT ${LOCAL_DAY_EXPR} AS dayKey FROM imm_sessions s JOIN imm_lifetime_applied_sessions a ON a.session_id = s.session_id WHERE s.session_id IN (${makePlaceholders(chunk)})`, ) .all(...chunk) as Array<{ dayKey: number }>; for (const row of rows) affectedDayKeys.add(row.dayKey); }); return { mediaRemovals: [...mediaRemovalsByVideo.values()], affectedAnimeIds: [...affectedAnimeIds], affectedDayKeys: [...affectedDayKeys], }; } /** * Apply a removal plan after the underlying rows are gone. * * Media rows are adjusted by subtraction (pruned-session history stays intact), * affected anime rows are recomputed from their surviving media rows, and the * global row is re-derived from the media/anime tables. `active_days` is the * one metric that can't be derived, so a touched day is only decremented when * no ended session remains on that local day; days whose sessions were pruned * by retention keep their count because pruning never subtracts. */ export function applyLifetimeRemovals(db: DatabaseSync, plan: LifetimeRemovalPlan): void { const updatedAtMs = toDbTimestamp(nowMs()); const subtractMediaStmt = db.prepare( ` UPDATE imm_lifetime_media SET total_sessions = MAX(total_sessions - ?, 0), total_active_ms = MAX(total_active_ms - ?, 0), total_cards = MAX(total_cards - ?, 0), total_lines_seen = MAX(total_lines_seen - ?, 0), total_tokens_seen = MAX(total_tokens_seen - ?, 0), LAST_UPDATE_DATE = ? WHERE video_id = ? `, ); const dropEmptyMediaStmt = db.prepare( 'DELETE FROM imm_lifetime_media WHERE video_id = ? AND total_sessions <= 0', ); const remainingSessionRangeStmt = db.prepare( ` SELECT MIN(CAST(started_at_ms AS REAL)) AS minStartedMs, MAX(CAST(ended_at_ms AS REAL)) AS maxEndedMs FROM imm_sessions WHERE video_id = ? AND ended_at_ms IS NOT NULL `, ); const storedMediaRangeStmt = db.prepare( ` SELECT CAST(first_watched_ms AS REAL) AS firstWatchedMs FROM imm_lifetime_media WHERE video_id = ? `, ); const refreshMediaRangeStmt = db.prepare( ` UPDATE imm_lifetime_media SET first_watched_ms = ?, last_watched_ms = ? WHERE video_id = ? `, ); for (const removal of plan.mediaRemovals) { subtractMediaStmt.run( removal.sessions, removal.activeMs, removal.cards, removal.linesSeen, removal.tokensSeen, updatedAtMs, removal.videoId, ); dropEmptyMediaStmt.run(removal.videoId); const stored = storedMediaRangeStmt.get(removal.videoId) as { firstWatchedMs: number | null; } | null; if (!stored) continue; const range = remainingSessionRangeStmt.get(removal.videoId) as { minStartedMs: number | null; maxEndedMs: number | null; } | null; // Retained sessions are always newer than pruned ones, so the surviving // range is authoritative for last_watched while first_watched can only // keep or extend the stored (possibly pruned-history) minimum. When no // session survives, the stored values are all that's left. if (range && range.minStartedMs !== null && range.maxEndedMs !== null) { const firstWatchedMs = stored.firstWatchedMs === null ? range.minStartedMs : Math.min(stored.firstWatchedMs, range.minStartedMs); refreshMediaRangeStmt.run( toDbTimestamp(firstWatchedMs), toDbTimestamp(range.maxEndedMs), removal.videoId, ); } } recomputeLifetimeAnimeFromMedia(db, plan.affectedAnimeIds, updatedAtMs); // One pass over the sessions rather than a probe per affected day: the local // day is a computed expression with no index, so each probe would be a table // scan — and the miss case (the day we need to count) is the full-scan one. let removedDays = 0; if (plan.affectedDayKeys.length > 0) { const survivingDayKeys = new Set( ( db .prepare( `SELECT DISTINCT ${LOCAL_DAY_EXPR} AS dayKey FROM imm_sessions WHERE ended_at_ms IS NOT NULL`, ) .all() as Array<{ dayKey: number }> ).map((row) => row.dayKey), ); for (const dayKey of plan.affectedDayKeys) { if (!survivingDayKeys.has(dayKey)) removedDays += 1; } } recomputeLifetimeGlobalFromSummaries(db, { removedActiveDays: removedDays, updatedAtMs }); } /** * Recompute lifetime anime rows exactly from their surviving media rows. * * Media rows are the durable per-video ledger (they outlive session pruning and * follow a video when it moves between anime), so this is the correct refresh * after merges, moves, and deletes. Anime with no media rows left are dropped. */ export function recomputeLifetimeAnimeFromMedia( db: DatabaseSync, animeIds: number[], updatedAtMs = toDbTimestamp(nowMs()), ): void { if (animeIds.length === 0) return; const animeSummaryStmt = db.prepare( ` SELECT COUNT(*) AS episodeRows, COALESCE(SUM(m.total_sessions), 0) AS totalSessions, COALESCE(SUM(m.total_active_ms), 0) AS totalActiveMs, COALESCE(SUM(m.total_cards), 0) AS totalCards, COALESCE(SUM(m.total_lines_seen), 0) AS totalLinesSeen, COALESCE(SUM(m.total_tokens_seen), 0) AS totalTokensSeen, COALESCE(SUM(CASE WHEN m.completed > 0 THEN 1 ELSE 0 END), 0) AS episodesCompleted, MIN(CAST(m.first_watched_ms AS REAL)) AS firstWatchedMs, MAX(CAST(m.last_watched_ms AS REAL)) AS lastWatchedMs FROM imm_lifetime_media m JOIN imm_videos v ON v.video_id = m.video_id WHERE v.anime_id = ? `, ); const dropAnimeStmt = db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?'); const upsertAnimeStmt = 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 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(anime_id) DO UPDATE SET total_sessions = excluded.total_sessions, total_active_ms = excluded.total_active_ms, total_cards = excluded.total_cards, total_lines_seen = excluded.total_lines_seen, total_tokens_seen = excluded.total_tokens_seen, episodes_started = excluded.episodes_started, episodes_completed = excluded.episodes_completed, first_watched_ms = excluded.first_watched_ms, last_watched_ms = excluded.last_watched_ms, LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE `, ); for (const animeId of animeIds) { const summary = animeSummaryStmt.get(animeId) as { episodeRows: number; totalSessions: number; totalActiveMs: number; totalCards: number; totalLinesSeen: number; totalTokensSeen: number; episodesCompleted: number; firstWatchedMs: number | null; lastWatchedMs: number | null; }; if (Number(summary.episodeRows) === 0) { dropAnimeStmt.run(animeId); continue; } upsertAnimeStmt.run( animeId, summary.totalSessions, summary.totalActiveMs, summary.totalCards, summary.totalLinesSeen, summary.totalTokensSeen, summary.episodeRows, summary.episodesCompleted, summary.firstWatchedMs === null ? null : toDbTimestamp(summary.firstWatchedMs), summary.lastWatchedMs === null ? null : toDbTimestamp(summary.lastWatchedMs), updatedAtMs, updatedAtMs, ); } } /** * Re-derive the global lifetime row from the media/anime summary tables. * * Every global metric except active_days is a pure aggregate of those tables; * active_days can't be derived, so callers pass how many day slots their change * removed (0 for moves/merges, which never touch sessions). */ export function recomputeLifetimeGlobalFromSummaries( db: DatabaseSync, options: { removedActiveDays?: number; updatedAtMs?: string } = {}, ): void { const updatedAtMs = options.updatedAtMs ?? toDbTimestamp(nowMs()); const mediaTotals = db .prepare( ` SELECT COUNT(*) AS episodesStarted, COALESCE(SUM(total_sessions), 0) AS totalSessions, COALESCE(SUM(total_active_ms), 0) AS totalActiveMs, COALESCE(SUM(total_cards), 0) AS totalCards, COALESCE(SUM(CASE WHEN completed > 0 THEN 1 ELSE 0 END), 0) AS episodesCompleted FROM imm_lifetime_media `, ) .get() as { episodesStarted: number; totalSessions: number; totalActiveMs: number; totalCards: number; episodesCompleted: number; }; const animeCompletedRow = db .prepare( ` SELECT COUNT(*) AS animeCompleted 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 `, ) .get() as { animeCompleted: number }; db.prepare( ` UPDATE imm_lifetime_global SET total_sessions = ?, total_active_ms = ?, total_cards = ?, episodes_started = ?, episodes_completed = ?, anime_completed = ?, active_days = MAX(active_days - ?, 0), LAST_UPDATE_DATE = ? WHERE global_id = 1 `, ).run( mediaTotals.totalSessions, mediaTotals.totalActiveMs, mediaTotals.totalCards, mediaTotals.episodesStarted, mediaTotals.episodesCompleted, animeCompletedRow.animeCompleted, options.removedActiveDays ?? 0, updatedAtMs, ); } export interface LifetimeRepairSummary { recomputedAnime: number; repairedAtMs: number; } /** * Refresh every anime aggregate from the durable media ledger while the caller * holds a write transaction. This keeps merge and move operations atomic. */ export function recomputeLifetimeAnimeAggregatesInTransaction(db: DatabaseSync): void { const animeIds = new Set(); for (const row of db .prepare('SELECT DISTINCT anime_id AS animeId FROM imm_videos WHERE anime_id IS NOT NULL') .all() as Array<{ animeId: number }>) { animeIds.add(row.animeId); } for (const row of db .prepare('SELECT anime_id AS animeId FROM imm_lifetime_anime') .all() as Array<{ animeId: number }>) { animeIds.add(row.animeId); } const updatedAtMs = toDbTimestamp(nowMs()); recomputeLifetimeAnimeFromMedia(db, [...animeIds], updatedAtMs); // Moving attribution cannot change global totals. Recompute only the one // global metric that depends on per-anime ownership, preserving history that // may outlive the raw sessions and media rows available for aggregation. 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(updatedAtMs); } /** * Non-destructive lifetime repair: recompute every anime row and the global row * from the per-video media ledger. * * Unlike {@link rebuildLifetimeSummaries}, this never resets the tables from * retained sessions, so lifetime history older than the session retention * window survives. The one exception is a database whose lifetime tables were * never populated — there is no ledger to repair from, so it bootstraps with * the full rebuild instead. */ export function repairLifetimeSummariesFromMedia(db: DatabaseSync): LifetimeRepairSummary { const repairedAtMs = nowMs(); let transactionStarted = false; try { db.exec('BEGIN IMMEDIATE'); transactionStarted = true; if (shouldBackfillLifetimeSummaries(db)) { const rebuilt = rebuildLifetimeSummariesInTransaction(db, repairedAtMs); const animeRow = db .prepare('SELECT COUNT(*) AS count FROM imm_lifetime_anime') .get() as ExistenceRow; db.exec('COMMIT'); return { recomputedAnime: Number(animeRow.count), repairedAtMs: rebuilt.rebuiltAtMs }; } const animeIds = new Set(); for (const row of db .prepare('SELECT DISTINCT anime_id AS animeId FROM imm_videos WHERE anime_id IS NOT NULL') .all() as Array<{ animeId: number }>) { animeIds.add(row.animeId); } for (const row of db .prepare('SELECT anime_id AS animeId FROM imm_lifetime_anime') .all() as Array<{ animeId: number }>) { animeIds.add(row.animeId); } const updatedAtMs = toDbTimestamp(repairedAtMs); recomputeLifetimeAnimeFromMedia(db, [...animeIds], updatedAtMs); recomputeLifetimeGlobalFromSummaries(db, { updatedAtMs }); db.exec('COMMIT'); return { recomputedAnime: animeIds.size, repairedAtMs }; } catch (error) { if (transactionStarted) db.exec('ROLLBACK'); throw error; } } export function reconcileStaleActiveSessions(db: DatabaseSync): number { const sessions = getRetainedStaleActiveSessions(db); if (sessions.length === 0) { return 0; } db.exec('BEGIN'); try { for (const session of sessions) { const state = toRebuildSessionState(session); finalizeSessionRecord(db, state, session.endedAtMs); applySessionLifetimeSummary(db, state, session.endedAtMs); } db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); throw error; } return sessions.length; } export function shouldBackfillLifetimeSummaries(db: DatabaseSync): boolean { const globalRow = db .prepare('SELECT total_sessions AS totalSessions FROM imm_lifetime_global WHERE global_id = 1') .get() as { totalSessions: number } | null; const appliedRow = db .prepare('SELECT COUNT(*) AS count FROM imm_lifetime_applied_sessions') .get() as ExistenceRow | null; const endedRow = db .prepare('SELECT COUNT(*) AS count FROM imm_sessions WHERE ended_at_ms IS NOT NULL') .get() as ExistenceRow | null; const totalSessions = Number(globalRow?.totalSessions ?? 0); const appliedSessions = Number(appliedRow?.count ?? 0); const retainedEndedSessions = Number(endedRow?.count ?? 0); return retainedEndedSessions > 0 && (appliedSessions === 0 || totalSessions === 0); }