diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 03e441f..9b2bb1b 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -54,6 +54,7 @@ const VOCABULARY_STATS = [ pos3: null, frequency: 100, frequencyRank: 42, + animeCount: 2, firstSeen: Date.now(), lastSeen: Date.now(), }, @@ -75,6 +76,8 @@ const OCCURRENCES = [ animeTitle: 'Little Witch Academia', videoId: 2, videoTitle: 'Episode 4', + sourcePath: '/media/anime/lwa/ep04.mkv', + secondaryText: null, sessionId: 3, lineIndex: 7, segmentStartMs: 12_000, diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 3942f47..95b32d9 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -612,6 +612,7 @@ export class ImmersionTrackerService { startSec: number, endSec: number, tokens?: MergedToken[] | null, + secondaryText?: string | null, ): void { if (!this.sessionState || !text.trim()) return; const cleaned = normalizeText(text); @@ -692,6 +693,7 @@ export class ImmersionTrackerService { segmentStartMs: secToMs(startSec), segmentEndMs: secToMs(endSec), text: cleaned, + secondaryText: secondaryText ?? null, wordOccurrences: Array.from(wordOccurrences.values()), kanjiOccurrences: Array.from(kanjiCounts.entries()).map(([kanji, occurrenceCount]) => ({ kanji, diff --git a/src/core/services/immersion-tracker/query.ts b/src/core/services/immersion-tracker/query.ts index c707021..7af2b0d 100644 --- a/src/core/services/immersion-tracker/query.ts +++ b/src/core/services/immersion-tracker/query.ts @@ -221,11 +221,17 @@ export function getVocabularyStats( ? `WHERE (part_of_speech IS NULL OR part_of_speech NOT IN (${placeholders}))` : ''; const stmt = db.prepare(` - SELECT id AS wordId, headword, word, reading, - part_of_speech AS partOfSpeech, pos1, pos2, pos3, - frequency, frequency_rank AS frequencyRank, - first_seen AS firstSeen, last_seen AS lastSeen - FROM imm_words ${whereClause} ORDER BY frequency DESC LIMIT ? + SELECT w.id AS wordId, w.headword, w.word, w.reading, + w.part_of_speech AS partOfSpeech, w.pos1, w.pos2, w.pos3, + w.frequency, w.frequency_rank AS frequencyRank, + w.first_seen AS firstSeen, w.last_seen AS lastSeen, + COUNT(DISTINCT sl.anime_id) AS animeCount + FROM imm_words w + LEFT JOIN imm_word_line_occurrences o ON o.word_id = w.id + LEFT JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id AND sl.anime_id IS NOT NULL + ${whereClause ? whereClause.replace('part_of_speech', 'w.part_of_speech') : ''} + GROUP BY w.id + ORDER BY w.frequency DESC LIMIT ? `); const params = hasExclude ? [...excludePos, limit] : [limit]; return stmt.all(...params) as VocabularyStatsRow[]; @@ -528,6 +534,8 @@ export function getWordOccurrences( a.canonical_title AS animeTitle, l.video_id AS videoId, v.canonical_title AS videoTitle, + v.source_path AS sourcePath, + l.secondary_text AS secondaryText, l.session_id AS sessionId, l.line_index AS lineIndex, l.segment_start_ms AS segmentStartMs, @@ -562,6 +570,8 @@ export function getKanjiOccurrences( a.canonical_title AS animeTitle, l.video_id AS videoId, v.canonical_title AS videoTitle, + v.source_path AS sourcePath, + l.secondary_text AS secondaryText, l.session_id AS sessionId, l.line_index AS lineIndex, l.segment_start_ms AS segmentStartMs, diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index a440f8a..de7e75f 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -505,6 +505,7 @@ export function ensureSchema(db: DatabaseSync): void { segment_start_ms INTEGER, segment_end_ms INTEGER, text TEXT NOT NULL, + secondary_text TEXT, CREATED_DATE INTEGER, LAST_UPDATE_DATE INTEGER, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE, @@ -644,6 +645,7 @@ export function ensureSchema(db: DatabaseSync): void { segment_start_ms INTEGER, segment_end_ms INTEGER, text TEXT NOT NULL, + secondary_text TEXT, CREATED_DATE INTEGER, LAST_UPDATE_DATE INTEGER, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE, @@ -679,6 +681,10 @@ export function ensureSchema(db: DatabaseSync): void { addColumnIfMissing(db, 'imm_words', 'frequency_rank', 'INTEGER'); } + if (currentVersion?.schema_version && currentVersion.schema_version < 10) { + addColumnIfMissing(db, 'imm_subtitle_lines', 'secondary_text', 'TEXT'); + } + db.exec(` CREATE INDEX IF NOT EXISTS idx_anime_normalized_title ON imm_anime(normalized_title_key) @@ -820,9 +826,9 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar subtitleLineInsertStmt: db.prepare(` INSERT INTO imm_subtitle_lines ( session_id, event_id, video_id, anime_id, line_index, segment_start_ms, - segment_end_ms, text, CREATED_DATE, LAST_UPDATE_DATE + segment_end_ms, text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `), wordIdSelectStmt: db.prepare(` @@ -958,6 +964,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta write.segmentStartMs ?? null, write.segmentEndMs ?? null, write.text, + write.secondaryText ?? null, Date.now(), Date.now(), ); diff --git a/src/core/services/immersion-tracker/types.ts b/src/core/services/immersion-tracker/types.ts index 8abf93b..0bfa001 100644 --- a/src/core/services/immersion-tracker/types.ts +++ b/src/core/services/immersion-tracker/types.ts @@ -1,4 +1,4 @@ -export const SCHEMA_VERSION = 9; +export const SCHEMA_VERSION = 10; export const DEFAULT_QUEUE_CAP = 1_000; export const DEFAULT_BATCH_SIZE = 25; export const DEFAULT_FLUSH_INTERVAL_MS = 500; @@ -163,6 +163,7 @@ interface QueuedSubtitleLineWrite { segmentStartMs: number | null; segmentEndMs: number | null; text: string; + secondaryText?: string | null; wordOccurrences: CountedWordOccurrence[]; kanjiOccurrences: CountedKanjiOccurrence[]; firstSeen: number; @@ -243,6 +244,7 @@ export interface VocabularyStatsRow { pos3: string | null; frequency: number; frequencyRank: number | null; + animeCount: number; firstSeen: number; lastSeen: number; } @@ -282,6 +284,8 @@ export interface WordOccurrenceRow { animeTitle: string | null; videoId: number; videoTitle: string; + sourcePath: string | null; + secondaryText: string | null; sessionId: number; lineIndex: number; segmentStartMs: number | null; @@ -295,6 +299,8 @@ export interface KanjiOccurrenceRow { animeTitle: string | null; videoId: number; videoTitle: string; + sourcePath: string | null; + secondaryText: string | null; sessionId: number; lineIndex: number; segmentStartMs: number | null; diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 669f27d..87536c9 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -4,13 +4,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { appState: { initialArgs?: { jellyfinPlay?: unknown } | null; overlayRuntimeInitialized: boolean; - mpvClient: { connected?: boolean } | null; + mpvClient: { connected?: boolean; currentSecondarySubText?: string } | null; immersionTracker: { recordSubtitleLine?: ( text: string, start: number, end: number, tokens?: MergedToken[] | null, + secondaryText?: string | null, ) => void; handleMediaTitleUpdate?: (title: string) => void; recordPlaybackPosition?: (time: number) => void; @@ -70,25 +71,26 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { if (!tracker?.recordSubtitleLine) { return; } + const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null; const cachedTokens = deps.appState.currentSubtitleData?.text === text ? deps.appState.currentSubtitleData.tokens : null; if (cachedTokens) { - tracker.recordSubtitleLine(text, start, end, cachedTokens); + tracker.recordSubtitleLine(text, start, end, cachedTokens, secondaryText); return; } if (!deps.tokenizeSubtitleForImmersion) { - tracker.recordSubtitleLine(text, start, end, null); + tracker.recordSubtitleLine(text, start, end, null, secondaryText); return; } void deps .tokenizeSubtitleForImmersion(text) .then((payload) => { - tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null); + tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null, secondaryText); }) .catch(() => { - tracker.recordSubtitleLine?.(text, start, end, null); + tracker.recordSubtitleLine?.(text, start, end, null, secondaryText); }); }, hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker), diff --git a/stats/src/types/stats.ts b/stats/src/types/stats.ts index 29633f7..7617bd6 100644 --- a/stats/src/types/stats.ts +++ b/stats/src/types/stats.ts @@ -59,6 +59,7 @@ export interface VocabularyEntry { pos3: string | null; frequency: number; frequencyRank: number | null; + animeCount: number; firstSeen: number; lastSeen: number; } @@ -76,6 +77,8 @@ export interface VocabularyOccurrenceEntry { animeTitle: string | null; videoId: number; videoTitle: string; + sourcePath: string | null; + secondaryText: string | null; sessionId: number; lineIndex: number; segmentStartMs: number | null;