feat: refactor immersion tracker queries and session word tracking

Add comprehensive query helpers for session deletion with word aggregate
refresh, known-words-per-session timeline, anime-level word summaries,
and trends dashboard aggregation. Track yomitanLookupCount in session
metrics and support bulk session operations.
This commit is contained in:
2026-03-17 19:52:59 -07:00
parent a5a6426fe1
commit 55ee12e87f
13 changed files with 1735 additions and 39 deletions

View File

@@ -6,6 +6,7 @@ import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-trac
import {
pruneRawRetention,
pruneRollupRetention,
runOptimizeMaintenance,
runRollupMaintenance,
} from './immersion-tracker/maintenance';
import { Database, type DatabaseSync } from './immersion-tracker/sqlite';
@@ -60,6 +61,11 @@ import {
getSessionEvents,
getSessionSummaries,
getSessionTimeline,
getSessionWordsByLine,
getTrendsDashboard,
getAllDistinctHeadwords,
getAnimeDistinctHeadwords,
getMediaDistinctHeadwords,
getVocabularyStats,
getWatchTimePerAnime,
getWordAnimeAppearances,
@@ -69,6 +75,7 @@ import {
upsertCoverArt,
markVideoWatched,
deleteSession as deleteSessionQuery,
deleteSessions as deleteSessionsQuery,
deleteVideo as deleteVideoQuery,
} from './immersion-tracker/query';
import {
@@ -83,6 +90,7 @@ import {
sanitizePayload,
secToMs,
} from './immersion-tracker/reducer';
import { DEFAULT_MIN_WATCH_RATIO } from '../../shared/watch-threshold';
import { enqueueWrite } from './immersion-tracker/queue';
import {
DEFAULT_BATCH_SIZE,
@@ -104,6 +112,7 @@ import {
EVENT_SEEK_BACKWARD,
EVENT_SEEK_FORWARD,
EVENT_SUBTITLE_LINE,
EVENT_YOMITAN_LOOKUP,
SOURCE_TYPE_LOCAL,
SOURCE_TYPE_REMOTE,
type ImmersionSessionRollupRow,
@@ -244,13 +253,21 @@ export class ImmersionTrackerService {
);
const retention = policy.retention ?? {};
const daysToRetentionMs = (value: number | undefined, fallbackMs: number, maxDays: number): number => {
const daysToRetentionMs = (
value: number | undefined,
fallbackMs: number,
maxDays: number,
): number => {
const fallbackDays = Math.floor(fallbackMs / 86_400_000);
const resolvedDays = resolveBoundedInt(value, fallbackDays, 0, maxDays);
return resolvedDays === 0 ? Number.POSITIVE_INFINITY : resolvedDays * 86_400_000;
};
this.eventsRetentionMs = daysToRetentionMs(retention.eventsDays, DEFAULT_EVENTS_RETENTION_MS, 3650);
this.eventsRetentionMs = daysToRetentionMs(
retention.eventsDays,
DEFAULT_EVENTS_RETENTION_MS,
3650,
);
this.telemetryRetentionMs = daysToRetentionMs(
retention.telemetryDays,
DEFAULT_TELEMETRY_RETENTION_MS,
@@ -321,6 +338,24 @@ export class ImmersionTrackerService {
return getSessionTimeline(this.db, sessionId, limit);
}
async getSessionWordsByLine(
sessionId: number,
): Promise<Array<{ lineIndex: number; headword: string; occurrenceCount: number }>> {
return getSessionWordsByLine(this.db, sessionId);
}
async getAllDistinctHeadwords(): Promise<string[]> {
return getAllDistinctHeadwords(this.db);
}
async getAnimeDistinctHeadwords(animeId: number): Promise<string[]> {
return getAnimeDistinctHeadwords(this.db, animeId);
}
async getMediaDistinctHeadwords(videoId: number): Promise<string[]> {
return getMediaDistinctHeadwords(this.db, videoId);
}
async getQueryHints(): Promise<{
totalSessions: number;
activeSessions: number;
@@ -343,6 +378,13 @@ export class ImmersionTrackerService {
return getMonthlyRollups(this.db, limit);
}
async getTrendsDashboard(
range: '7d' | '30d' | '90d' | 'all' = '30d',
groupBy: 'day' | 'month' = 'day',
): Promise<unknown> {
return getTrendsDashboard(this.db, range, groupBy);
}
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
return getVocabularyStats(this.db, limit, excludePos);
}
@@ -437,11 +479,40 @@ export class ImmersionTrackerService {
markVideoWatched(this.db, videoId, watched);
}
async markActiveVideoWatched(): Promise<boolean> {
if (!this.sessionState) return false;
markVideoWatched(this.db, this.sessionState.videoId, true);
this.sessionState.markedWatched = true;
return true;
}
async deleteSession(sessionId: number): Promise<void> {
if (this.sessionState?.sessionId === sessionId) {
this.logger.warn(`Ignoring delete request for active immersion session ${sessionId}`);
return;
}
deleteSessionQuery(this.db, sessionId);
}
async deleteSessions(sessionIds: number[]): Promise<void> {
const activeSessionId = this.sessionState?.sessionId;
const deletableSessionIds =
activeSessionId === undefined
? sessionIds
: sessionIds.filter((sessionId) => sessionId !== activeSessionId);
if (deletableSessionIds.length !== sessionIds.length) {
this.logger.warn(
`Ignoring bulk delete request for active immersion session ${activeSessionId}`,
);
}
deleteSessionsQuery(this.db, deletableSessionIds);
}
async deleteVideo(videoId: number): Promise<void> {
if (this.sessionState?.videoId === videoId) {
this.logger.warn(`Ignoring delete request for active immersion video ${videoId}`);
return;
}
deleteVideoQuery(this.db, videoId);
}
@@ -847,7 +918,7 @@ export class ImmersionTrackerService {
if (!this.sessionState.markedWatched) {
const durationMs = getVideoDurationMs(this.db, this.sessionState.videoId);
if (durationMs > 0 && mediaMs >= durationMs * 0.98) {
if (durationMs > 0 && mediaMs >= durationMs * DEFAULT_MIN_WATCH_RATIO) {
markVideoWatched(this.db, this.sessionState.videoId, true);
this.sessionState.markedWatched = true;
}
@@ -915,6 +986,21 @@ export class ImmersionTrackerService {
});
}
recordYomitanLookup(): void {
if (!this.sessionState) return;
this.sessionState.yomitanLookupCount += 1;
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
eventType: EVENT_YOMITAN_LOOKUP,
cardsDelta: 0,
wordsDelta: 0,
payloadJson: null,
});
}
recordCardsMined(count = 1, noteIds?: number[]): void {
if (!this.sessionState) return;
this.sessionState.cardsMined += count;
@@ -981,6 +1067,7 @@ export class ImmersionTrackerService {
cardsMined: this.sessionState.cardsMined,
lookupCount: this.sessionState.lookupCount,
lookupHits: this.sessionState.lookupHits,
yomitanLookupCount: this.sessionState.yomitanLookupCount,
pauseCount: this.sessionState.pauseCount,
pauseMs: this.sessionState.pauseMs,
seekForwardCount: this.sessionState.seekForwardCount,
@@ -1080,6 +1167,7 @@ export class ImmersionTrackerService {
this.db.exec('VACUUM');
this.lastVacuumMs = nowMs;
}
runOptimizeMaintenance(this.db);
} catch (error) {
this.logger.warn(
'Immersion tracker maintenance failed, will retry later',
@@ -1108,6 +1196,7 @@ export class ImmersionTrackerService {
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
pauseCount: 0,
pauseMs: 0,
seekForwardCount: 0,