Files
SubMiner/src/core/services/immersion-tracker/lifetime.ts
T

1237 lines
38 KiB
TypeScript

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<RetainedSessionRow, 'startedAtMs' | 'endedAtMs' | 'lastMediaMs'> & {
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<number, LifetimeMediaRemoval>();
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<number>();
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<number>();
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<number>();
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<number>();
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);
}