feat(tracking): store secondary subtitle text and source path in occurrence data

- Add secondary_text column to imm_subtitle_lines with migration
- Pass currentSecondarySubText through recordSubtitleLine flow
- Include secondaryText and sourcePath in word/kanji occurrence queries
- Update all type interfaces (backend + frontend)
This commit is contained in:
2026-03-16 01:43:05 -07:00
parent 5a30446809
commit a1f30fd482
7 changed files with 46 additions and 13 deletions

View File

@@ -54,6 +54,7 @@ const VOCABULARY_STATS = [
pos3: null, pos3: null,
frequency: 100, frequency: 100,
frequencyRank: 42, frequencyRank: 42,
animeCount: 2,
firstSeen: Date.now(), firstSeen: Date.now(),
lastSeen: Date.now(), lastSeen: Date.now(),
}, },
@@ -75,6 +76,8 @@ const OCCURRENCES = [
animeTitle: 'Little Witch Academia', animeTitle: 'Little Witch Academia',
videoId: 2, videoId: 2,
videoTitle: 'Episode 4', videoTitle: 'Episode 4',
sourcePath: '/media/anime/lwa/ep04.mkv',
secondaryText: null,
sessionId: 3, sessionId: 3,
lineIndex: 7, lineIndex: 7,
segmentStartMs: 12_000, segmentStartMs: 12_000,

View File

@@ -612,6 +612,7 @@ export class ImmersionTrackerService {
startSec: number, startSec: number,
endSec: number, endSec: number,
tokens?: MergedToken[] | null, tokens?: MergedToken[] | null,
secondaryText?: string | null,
): void { ): void {
if (!this.sessionState || !text.trim()) return; if (!this.sessionState || !text.trim()) return;
const cleaned = normalizeText(text); const cleaned = normalizeText(text);
@@ -692,6 +693,7 @@ export class ImmersionTrackerService {
segmentStartMs: secToMs(startSec), segmentStartMs: secToMs(startSec),
segmentEndMs: secToMs(endSec), segmentEndMs: secToMs(endSec),
text: cleaned, text: cleaned,
secondaryText: secondaryText ?? null,
wordOccurrences: Array.from(wordOccurrences.values()), wordOccurrences: Array.from(wordOccurrences.values()),
kanjiOccurrences: Array.from(kanjiCounts.entries()).map(([kanji, occurrenceCount]) => ({ kanjiOccurrences: Array.from(kanjiCounts.entries()).map(([kanji, occurrenceCount]) => ({
kanji, kanji,

View File

@@ -221,11 +221,17 @@ export function getVocabularyStats(
? `WHERE (part_of_speech IS NULL OR part_of_speech NOT IN (${placeholders}))` ? `WHERE (part_of_speech IS NULL OR part_of_speech NOT IN (${placeholders}))`
: ''; : '';
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT id AS wordId, headword, word, reading, SELECT w.id AS wordId, w.headword, w.word, w.reading,
part_of_speech AS partOfSpeech, pos1, pos2, pos3, w.part_of_speech AS partOfSpeech, w.pos1, w.pos2, w.pos3,
frequency, frequency_rank AS frequencyRank, w.frequency, w.frequency_rank AS frequencyRank,
first_seen AS firstSeen, last_seen AS lastSeen w.first_seen AS firstSeen, w.last_seen AS lastSeen,
FROM imm_words ${whereClause} ORDER BY frequency DESC LIMIT ? 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]; const params = hasExclude ? [...excludePos, limit] : [limit];
return stmt.all(...params) as VocabularyStatsRow[]; return stmt.all(...params) as VocabularyStatsRow[];
@@ -528,6 +534,8 @@ export function getWordOccurrences(
a.canonical_title AS animeTitle, a.canonical_title AS animeTitle,
l.video_id AS videoId, l.video_id AS videoId,
v.canonical_title AS videoTitle, v.canonical_title AS videoTitle,
v.source_path AS sourcePath,
l.secondary_text AS secondaryText,
l.session_id AS sessionId, l.session_id AS sessionId,
l.line_index AS lineIndex, l.line_index AS lineIndex,
l.segment_start_ms AS segmentStartMs, l.segment_start_ms AS segmentStartMs,
@@ -562,6 +570,8 @@ export function getKanjiOccurrences(
a.canonical_title AS animeTitle, a.canonical_title AS animeTitle,
l.video_id AS videoId, l.video_id AS videoId,
v.canonical_title AS videoTitle, v.canonical_title AS videoTitle,
v.source_path AS sourcePath,
l.secondary_text AS secondaryText,
l.session_id AS sessionId, l.session_id AS sessionId,
l.line_index AS lineIndex, l.line_index AS lineIndex,
l.segment_start_ms AS segmentStartMs, l.segment_start_ms AS segmentStartMs,

View File

@@ -505,6 +505,7 @@ export function ensureSchema(db: DatabaseSync): void {
segment_start_ms INTEGER, segment_start_ms INTEGER,
segment_end_ms INTEGER, segment_end_ms INTEGER,
text TEXT NOT NULL, text TEXT NOT NULL,
secondary_text TEXT,
CREATED_DATE INTEGER, CREATED_DATE INTEGER,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE INTEGER,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE, 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_start_ms INTEGER,
segment_end_ms INTEGER, segment_end_ms INTEGER,
text TEXT NOT NULL, text TEXT NOT NULL,
secondary_text TEXT,
CREATED_DATE INTEGER, CREATED_DATE INTEGER,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE INTEGER,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE, 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'); 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(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_anime_normalized_title CREATE INDEX IF NOT EXISTS idx_anime_normalized_title
ON imm_anime(normalized_title_key) ON imm_anime(normalized_title_key)
@@ -820,9 +826,9 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
subtitleLineInsertStmt: db.prepare(` subtitleLineInsertStmt: db.prepare(`
INSERT INTO imm_subtitle_lines ( INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, 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 ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ? ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) )
`), `),
wordIdSelectStmt: db.prepare(` wordIdSelectStmt: db.prepare(`
@@ -958,6 +964,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
write.segmentStartMs ?? null, write.segmentStartMs ?? null,
write.segmentEndMs ?? null, write.segmentEndMs ?? null,
write.text, write.text,
write.secondaryText ?? null,
Date.now(), Date.now(),
Date.now(), Date.now(),
); );

View File

@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 9; export const SCHEMA_VERSION = 10;
export const DEFAULT_QUEUE_CAP = 1_000; export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25; export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500; export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -163,6 +163,7 @@ interface QueuedSubtitleLineWrite {
segmentStartMs: number | null; segmentStartMs: number | null;
segmentEndMs: number | null; segmentEndMs: number | null;
text: string; text: string;
secondaryText?: string | null;
wordOccurrences: CountedWordOccurrence[]; wordOccurrences: CountedWordOccurrence[];
kanjiOccurrences: CountedKanjiOccurrence[]; kanjiOccurrences: CountedKanjiOccurrence[];
firstSeen: number; firstSeen: number;
@@ -243,6 +244,7 @@ export interface VocabularyStatsRow {
pos3: string | null; pos3: string | null;
frequency: number; frequency: number;
frequencyRank: number | null; frequencyRank: number | null;
animeCount: number;
firstSeen: number; firstSeen: number;
lastSeen: number; lastSeen: number;
} }
@@ -282,6 +284,8 @@ export interface WordOccurrenceRow {
animeTitle: string | null; animeTitle: string | null;
videoId: number; videoId: number;
videoTitle: string; videoTitle: string;
sourcePath: string | null;
secondaryText: string | null;
sessionId: number; sessionId: number;
lineIndex: number; lineIndex: number;
segmentStartMs: number | null; segmentStartMs: number | null;
@@ -295,6 +299,8 @@ export interface KanjiOccurrenceRow {
animeTitle: string | null; animeTitle: string | null;
videoId: number; videoId: number;
videoTitle: string; videoTitle: string;
sourcePath: string | null;
secondaryText: string | null;
sessionId: number; sessionId: number;
lineIndex: number; lineIndex: number;
segmentStartMs: number | null; segmentStartMs: number | null;

View File

@@ -4,13 +4,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: { appState: {
initialArgs?: { jellyfinPlay?: unknown } | null; initialArgs?: { jellyfinPlay?: unknown } | null;
overlayRuntimeInitialized: boolean; overlayRuntimeInitialized: boolean;
mpvClient: { connected?: boolean } | null; mpvClient: { connected?: boolean; currentSecondarySubText?: string } | null;
immersionTracker: { immersionTracker: {
recordSubtitleLine?: ( recordSubtitleLine?: (
text: string, text: string,
start: number, start: number,
end: number, end: number,
tokens?: MergedToken[] | null, tokens?: MergedToken[] | null,
secondaryText?: string | null,
) => void; ) => void;
handleMediaTitleUpdate?: (title: string) => void; handleMediaTitleUpdate?: (title: string) => void;
recordPlaybackPosition?: (time: number) => void; recordPlaybackPosition?: (time: number) => void;
@@ -70,25 +71,26 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
if (!tracker?.recordSubtitleLine) { if (!tracker?.recordSubtitleLine) {
return; return;
} }
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
const cachedTokens = const cachedTokens =
deps.appState.currentSubtitleData?.text === text deps.appState.currentSubtitleData?.text === text
? deps.appState.currentSubtitleData.tokens ? deps.appState.currentSubtitleData.tokens
: null; : null;
if (cachedTokens) { if (cachedTokens) {
tracker.recordSubtitleLine(text, start, end, cachedTokens); tracker.recordSubtitleLine(text, start, end, cachedTokens, secondaryText);
return; return;
} }
if (!deps.tokenizeSubtitleForImmersion) { if (!deps.tokenizeSubtitleForImmersion) {
tracker.recordSubtitleLine(text, start, end, null); tracker.recordSubtitleLine(text, start, end, null, secondaryText);
return; return;
} }
void deps void deps
.tokenizeSubtitleForImmersion(text) .tokenizeSubtitleForImmersion(text)
.then((payload) => { .then((payload) => {
tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null); tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null, secondaryText);
}) })
.catch(() => { .catch(() => {
tracker.recordSubtitleLine?.(text, start, end, null); tracker.recordSubtitleLine?.(text, start, end, null, secondaryText);
}); });
}, },
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker), hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),

View File

@@ -59,6 +59,7 @@ export interface VocabularyEntry {
pos3: string | null; pos3: string | null;
frequency: number; frequency: number;
frequencyRank: number | null; frequencyRank: number | null;
animeCount: number;
firstSeen: number; firstSeen: number;
lastSeen: number; lastSeen: number;
} }
@@ -76,6 +77,8 @@ export interface VocabularyOccurrenceEntry {
animeTitle: string | null; animeTitle: string | null;
videoId: number; videoId: number;
videoTitle: string; videoTitle: string;
sourcePath: string | null;
secondaryText: string | null;
sessionId: number; sessionId: number;
lineIndex: number; lineIndex: number;
segmentStartMs: number | null; segmentStartMs: number | null;