feat(stats): speed up session maintenance and improve stats UI (#111)

This commit is contained in:
2026-06-08 02:20:52 -07:00
committed by GitHub
parent e6a16a069b
commit 311f1e8ee5
108 changed files with 7441 additions and 729 deletions
+16
View File
@@ -156,6 +156,22 @@ export class AnkiConnectClient {
return (result as number[]) || [];
}
async findCards(query: string, options?: { maxRetries?: number }): Promise<number[]> {
const result = await this.invoke('findCards', { query }, options);
return (result as number[]) || [];
}
async changeDeck(cardIds: number[], deckName: string): Promise<void> {
if (cardIds.length === 0 || !deckName.trim()) {
return;
}
await this.invoke('changeDeck', {
cards: cardIds,
deck: deckName,
});
}
async deckNames(): Promise<string[]> {
const result = await this.invoke('deckNames');
return Array.isArray(result)
@@ -63,7 +63,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'string',
defaultValue: defaultConfig.ankiConnect.deck,
description:
'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.',
'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.',
},
{
path: 'ankiConnect.fields.word',
File diff suppressed because it is too large Load Diff
@@ -34,13 +34,13 @@ test('guessAnilistMediaInfo fills missing guessit episode from filename parser',
});
});
test('guessAnilistMediaInfo ignores low-confidence parser details when guessit omits them', async () => {
test('guessAnilistMediaInfo keeps season directory scope when guessit omits details', async () => {
const result = await guessAnilistMediaInfo('/tmp/Season 2/Guessit Title.mkv', null, {
runGuessit: async () => JSON.stringify({ title: 'Guessit Title' }),
});
assert.deepEqual(result, {
title: 'Guessit Title',
season: null,
season: 2,
episode: null,
source: 'guessit',
});
+1 -1
View File
@@ -292,7 +292,7 @@ export async function guessAnilistMediaInfo(
title: buildGuessitTitle(title, alternativeTitle),
...(alternativeTitle ? { alternativeTitle } : {}),
...(year ? { year } : {}),
season: season ?? (canUseFallbackDetails ? fallback.season : null),
season: season ?? fallback.season,
episode: episode ?? (canUseFallbackDetails ? fallback.episode : null),
source: 'guessit',
};
@@ -6,6 +6,7 @@ import path from 'node:path';
import { toMonthKey } from './immersion-tracker/maintenance';
import { enqueueWrite } from './immersion-tracker/queue';
import { toDbTimestamp } from './immersion-tracker/query-shared';
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
import { Database, type DatabaseSync } from './immersion-tracker/sqlite';
import { nowMs as trackerNowMs } from './immersion-tracker/time';
import {
@@ -1164,6 +1165,54 @@ test('recordSubtitleLine leaves session token counts at zero when tokenization i
}
});
test('recordSubtitleLine skips invalid cue timing and still stores the later valid cue', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('/tmp/timing.mkv', 'Timing');
tracker.recordSubtitleLine('same subtitle', 953.991, 953.891);
tracker.recordSubtitleLine('same subtitle', 953.991, 956.56);
const privateApi = tracker as unknown as {
flushTelemetry: (force?: boolean) => void;
flushNow: () => void;
};
privateApi.flushTelemetry(true);
privateApi.flushNow();
const db = new Database(dbPath);
const rows = db
.prepare(
`SELECT line_index, segment_start_ms, segment_end_ms, text
FROM imm_subtitle_lines
ORDER BY line_id ASC`,
)
.all() as Array<{
line_index: number;
segment_start_ms: number | null;
segment_end_ms: number | null;
text: string;
}>;
db.close();
assert.deepEqual(rows, [
{
line_index: 1,
segment_start_ms: 953991,
segment_end_ms: 956560,
text: 'same subtitle',
},
]);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('subtitle-line event payload omits duplicated subtitle text', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -1470,7 +1519,7 @@ test('handleMediaChange links parsed anime metadata on the active video row', as
assert.equal(row?.parsed_season, 2);
assert.equal(row?.parsed_episode, 5);
assert.ok(row?.parser_source === 'guessit' || row?.parser_source === 'fallback');
assert.equal(row?.anime_title, 'Little Witch Academia');
assert.equal(row?.anime_title, 'Little Witch Academia Season 2');
assert.equal(row?.anilist_id, null);
} finally {
tracker?.destroy();
@@ -1535,13 +1584,13 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
{
sourcePath: '/tmp/Little Witch Academia S02E05.mkv',
parsedEpisode: 5,
animeTitle: 'Little Witch Academia',
animeTitle: 'Little Witch Academia Season 2',
anilistId: null,
},
{
sourcePath: '/tmp/Little Witch Academia S02E06.mkv',
parsedEpisode: 6,
animeTitle: 'Little Witch Academia',
animeTitle: 'Little Witch Academia Season 2',
anilistId: null,
},
],
@@ -1552,6 +1601,81 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
}
});
test('handleMediaChange splits matching parsed titles across distinct seasons', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('/tmp/KonoSuba/Season 1/KonoSuba S01E05.mkv', 'Episode 5');
await waitForPendingAnimeMetadata(tracker);
tracker.handleMediaChange('/tmp/KonoSuba/Season 2/KonoSuba S02E05.mkv', 'Episode 5');
await waitForPendingAnimeMetadata(tracker);
const privateApi = tracker as unknown as {
db: DatabaseSync;
};
const rows = privateApi.db
.prepare(
`
SELECT
v.source_path,
v.anime_id,
v.parsed_season,
a.canonical_title AS anime_title,
a.normalized_title_key
FROM imm_videos v
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.source_path IN (?, ?)
ORDER BY v.source_path
`,
)
.all(
'/tmp/KonoSuba/Season 1/KonoSuba S01E05.mkv',
'/tmp/KonoSuba/Season 2/KonoSuba S02E05.mkv',
) as Array<{
source_path: string | null;
anime_id: number | null;
parsed_season: number | null;
anime_title: string | null;
normalized_title_key: string | null;
}>;
assert.equal(rows.length, 2);
assert.ok(rows[0]?.anime_id);
assert.ok(rows[1]?.anime_id);
assert.notEqual(rows[0]?.anime_id, rows[1]?.anime_id);
assert.deepEqual(
rows.map((row) => ({
sourcePath: row.source_path,
parsedSeason: row.parsed_season,
animeTitle: row.anime_title,
normalizedTitleKey: row.normalized_title_key,
})),
[
{
sourcePath: '/tmp/KonoSuba/Season 1/KonoSuba S01E05.mkv',
parsedSeason: 1,
animeTitle: 'KonoSuba Season 1',
normalizedTitleKey: 'konosuba season 1',
},
{
sourcePath: '/tmp/KonoSuba/Season 2/KonoSuba S02E05.mkv',
parsedSeason: 2,
animeTitle: 'KonoSuba Season 2',
normalizedTitleKey: 'konosuba season 2',
},
],
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin playback metadata links stream videos to existing series title', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -1595,8 +1719,41 @@ test('Jellyfin playback metadata links stream videos to existing series title',
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
'The Beginning After the End S02E02 The Princess Begins Adventuring',
);
tracker.handleMediaChange(null, null);
tracker.recordJellyfinPlaybackMetadata({
mediaPath:
'http://jellyfin.local/Videos/item-3/stream?static=true&api_key=token&MediaSourceId=ms-2',
displayTitle: 'The Beginning After the End S02E03 Dragon Has Left the Building',
itemTitle: 'Dragon Has Left the Building',
seriesTitle: 'The Beginning After the End',
seasonNumber: 2,
episodeNumber: 3,
itemId: 'item-3',
});
tracker.handleMediaChange(
'http://jellyfin.local/Videos/item-3/stream?static=true&api_key=token&MediaSourceId=ms-2&AudioStreamIndex=3&SubtitleStreamIndex=4',
'The Beginning After the End S02E03 Dragon Has Left the Building',
);
await waitForPendingAnimeMetadata(tracker);
const privateApi = tracker as unknown as { db: DatabaseSync };
const videoRows = privateApi.db
.prepare(
`
SELECT source_url, canonical_title AS video_title
FROM imm_videos
ORDER BY video_id
`,
)
.all() as Array<{ source_url: string | null; video_title: string }>;
assert.equal(videoRows.length, 3);
assert.equal(
videoRows.some(
(row) => row.source_url?.includes('api_key=') || row.video_title.includes('api_key='),
),
false,
);
const rows = privateApi.db
.prepare(
`
@@ -1623,7 +1780,7 @@ test('Jellyfin playback metadata links stream videos to existing series title',
anime_title: string;
}>;
assert.equal(rows.length, 2);
assert.equal(rows.length, 3);
assert.equal(new Set(rows.map((row) => row.anime_title)).size, 1);
const jellyfinRow = rows.find(
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-2',
@@ -1637,7 +1794,250 @@ test('Jellyfin playback metadata links stream videos to existing series title',
assert.equal(jellyfinRow.parsed_season, 2);
assert.equal(jellyfinRow.parsed_episode, 2);
assert.equal(jellyfinRow.parser_source, 'jellyfin');
assert.equal(jellyfinRow.anime_title, 'The Beginning After the End');
assert.equal(jellyfinRow.anime_title, 'The Beginning After the End Season 2');
const streamVariantRow = rows.find(
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-3',
);
assert.ok(streamVariantRow);
assert.equal(
streamVariantRow.video_title,
'The Beginning After the End S02E03 Dragon Has Left the Building',
);
assert.equal(streamVariantRow.source_url?.includes('api_key='), false);
assert.equal(streamVariantRow.video_title.includes('api_key='), false);
assert.equal(streamVariantRow.video_title.includes('stream?'), false);
assert.equal(streamVariantRow.parsed_title, 'The Beginning After the End');
assert.equal(streamVariantRow.parsed_season, 2);
assert.equal(streamVariantRow.parsed_episode, 3);
assert.equal(streamVariantRow.parser_source, 'jellyfin');
assert.equal(streamVariantRow.anime_title, 'The Beginning After the End Season 2');
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('startup repairs existing Jellyfin stream video links to metadata rows', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const streamUrl =
'http://jellyfin.local/Videos/item-9/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4';
tracker.handleMediaChange(
streamUrl,
'stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
);
tracker.handleMediaChange(null, null);
const titledStreamUrl =
'http://jellyfin.local/Videos/item-10/stream?static=true&api_key=secret-token&MediaSourceId=ms-2';
tracker.handleMediaChange(titledStreamUrl, 'KonoSuba S01E06 Decision! Class Rep');
tracker.handleMediaChange(null, null);
tracker.recordJellyfinPlaybackMetadata({
mediaPath: 'http://jellyfin.local/Videos/item-9/stream?static=true&api_key=secret-token',
displayTitle: 'Frieren S01E09 Aura the Guillotine',
itemTitle: 'Aura the Guillotine',
seriesTitle: 'Frieren',
seasonNumber: 1,
episodeNumber: 9,
itemId: 'item-9',
});
tracker.destroy();
tracker = null;
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
const videoRows = privateApi.db
.prepare(
`
SELECT
video_id,
video_key,
source_url,
canonical_title,
parser_source,
parsed_basename,
parsed_title,
parse_metadata_json
FROM imm_videos
ORDER BY video_id
`,
)
.all() as Array<{
video_id: number;
video_key: string;
source_url: string | null;
canonical_title: string;
parser_source: string | null;
parsed_basename: string | null;
parsed_title: string | null;
parse_metadata_json: string | null;
}>;
assert.equal(videoRows.length, 3);
const frierenRows = videoRows.filter(
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-9',
);
assert.equal(frierenRows.length, 2);
for (const row of frierenRows) {
assert.equal(row.source_url, 'jellyfin://jellyfin.local/item/item-9');
assert.equal(row.canonical_title, 'Frieren S01E09 Aura the Guillotine');
assert.equal(row.parser_source, 'jellyfin');
assert.equal(row.video_key.includes('api_key='), false);
assert.equal(row.source_url?.includes('api_key='), false);
assert.equal(row.canonical_title.includes('api_key='), false);
}
const titledRow = videoRows.find(
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-10',
);
assert.ok(titledRow);
assert.equal(titledRow.canonical_title, 'KonoSuba S01E06 Decision! Class Rep');
assert.equal(titledRow.video_key.includes('api_key='), false);
assert.equal(titledRow.source_url?.includes('api_key='), false);
assert.equal(JSON.stringify(videoRows).includes('api_key='), false);
assert.equal(JSON.stringify(videoRows).includes('secret-token'), false);
const animeRows = privateApi.db
.prepare(
`
SELECT canonical_title, normalized_title_key
FROM imm_anime
ORDER BY anime_id
`,
)
.all() as Array<{ canonical_title: string; normalized_title_key: string }>;
assert.equal(JSON.stringify(animeRows).includes('api_key='), false);
assert.equal(JSON.stringify(animeRows).includes('api key'), false);
assert.equal(JSON.stringify(animeRows).includes('secret-token'), false);
const sessionRows = privateApi.db
.prepare(
`
SELECT v.source_url, v.canonical_title
FROM imm_sessions s
JOIN imm_videos v ON v.video_id = s.video_id
ORDER BY s.session_id
`,
)
.all() as Array<{ source_url: string | null; canonical_title: string }>;
assert.deepEqual(
sessionRows.map((row) => row.canonical_title),
['Frieren S01E09 Aura the Guillotine', 'KonoSuba S01E06 Decision! Class Rep'],
);
assert.equal(
sessionRows.some((row) => row.source_url?.includes('api_key=')),
false,
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin link repair removes merged leaked anime rows and sanitizes orphan video titles', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
const db = privateApi.db;
const timestamp = toDbTimestamp(trackerNowMs());
const leakedTitle =
'http://jellyfin.local/Videos/item-20/stream?static=true&api_key=secret-token&MediaSourceId=ms-1';
const orphanLeakedTitle =
'http://jellyfin.local/Videos/item-21/stream?static=true&api_key=secret-token&MediaSourceId=ms-2&AudioStreamIndex=3';
const existingAnime = db
.prepare(
`
INSERT INTO imm_anime (
normalized_title_key,
canonical_title,
CREATED_DATE,
LAST_UPDATE_DATE
)
VALUES ('frieren', 'Frieren', ?, ?)
RETURNING anime_id
`,
)
.get(timestamp, timestamp) as { anime_id: number };
const leakedAnime = db
.prepare(
`
INSERT INTO imm_anime (
normalized_title_key,
canonical_title,
CREATED_DATE,
LAST_UPDATE_DATE
)
VALUES ('http jellyfin local videos item 20 stream static true api key secret token mediasourceid ms 1', ?, ?, ?)
RETURNING anime_id
`,
)
.get(leakedTitle, timestamp, timestamp) as { anime_id: number };
db.prepare(
`
INSERT INTO imm_videos (
video_key,
anime_id,
canonical_title,
source_type,
source_url,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
)
VALUES (?, ?, 'Frieren', 2, ?, 0, ?, ?)
`,
).run(`remote:${leakedTitle}`, leakedAnime.anime_id, leakedTitle, timestamp, timestamp);
db.prepare(
`
INSERT INTO imm_videos (
video_key,
anime_id,
canonical_title,
source_type,
source_url,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
)
VALUES (?, NULL, ?, 2, ?, 0, ?, ?)
`,
).run(
`remote:${orphanLeakedTitle}`,
orphanLeakedTitle,
orphanLeakedTitle,
timestamp,
timestamp,
);
const summary = repairJellyfinStreamVideoLinks(db);
assert.equal(summary.repaired, 3);
const leakedAnimeRow = db
.prepare('SELECT anime_id FROM imm_anime WHERE anime_id = ?')
.get(leakedAnime.anime_id);
assert.equal(leakedAnimeRow, undefined);
const reparentedCount = db
.prepare('SELECT COUNT(*) AS count FROM imm_videos WHERE anime_id = ?')
.get(existingAnime.anime_id) as { count: number };
assert.equal(reparentedCount.count, 1);
const orphanVideo = db
.prepare(
`
SELECT canonical_title
FROM imm_videos
WHERE source_url = 'jellyfin://jellyfin.local/item/item-21'
`,
)
.get() as { canonical_title: string };
assert.equal(orphanVideo.canonical_title, 'Jellyfin Video');
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
+62 -7
View File
@@ -55,6 +55,7 @@ import {
getStatsExcludedWords,
getVocabularyStats,
replaceStatsExcludedWords,
searchSubtitleSentences,
getWordAnimeAppearances,
getWordDetail,
getWordOccurrences,
@@ -89,6 +90,7 @@ import {
markVideoWatched,
upsertCoverArt,
} from './immersion-tracker/query-maintenance';
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
import {
buildVideoKey,
deriveCanonicalTitle,
@@ -148,6 +150,8 @@ import {
type MediaLibraryRow,
type NewAnimePerDayRow,
type QueuedWrite,
type SentenceSearchOptions,
type SentenceSearchResultRow,
type SessionEventRow,
type SessionState,
type SessionSummaryQueryRow,
@@ -328,6 +332,34 @@ function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string
}
}
const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [
'api_key',
'StartTimeTicks',
'AudioStreamIndex',
'SubtitleStreamIndex',
];
function deleteSearchParamsCaseInsensitive(searchParams: URLSearchParams, names: string[]): void {
const loweredNames = new Set(names.map((name) => name.toLowerCase()));
for (const key of [...searchParams.keys()]) {
if (loweredNames.has(key.toLowerCase())) {
searchParams.delete(key);
}
}
}
function buildJellyfinMediaPathAliasCandidates(mediaPath: string): string[] {
const candidates = new Set<string>([mediaPath]);
try {
const parsed = new URL(mediaPath);
deleteSearchParamsCaseInsensitive(parsed.searchParams, JELLYFIN_MEDIA_ALIAS_QUERY_KEYS);
candidates.add(parsed.toString());
} catch {
// Non-URL fallback paths are already represented by the raw candidate.
}
return [...candidates];
}
export class ImmersionTrackerService {
private readonly logger = createLogger('main:immersion-tracker');
private readonly db: DatabaseSync;
@@ -437,6 +469,12 @@ export class ImmersionTrackerService {
`Recovered stale active sessions on startup: reconciledSessions=${reconciledSessions}`,
);
}
const jellyfinRepair = repairJellyfinStreamVideoLinks(this.db);
if (jellyfinRepair.repaired > 0) {
this.logger.info(
`Repaired Jellyfin stats links on startup: scanned=${jellyfinRepair.scanned} repaired=${jellyfinRepair.repaired}`,
);
}
if (shouldBackfillLifetimeSummaries(this.db)) {
const result = rebuildLifetimeSummaryTables(this.db);
if (result.appliedSessions > 0) {
@@ -568,6 +606,14 @@ export class ImmersionTrackerService {
return getKanjiOccurrences(this.db, kanji, limit, offset);
}
async searchSubtitleSentences(
query: string,
limit = 50,
options?: SentenceSearchOptions,
): Promise<SentenceSearchResultRow[]> {
return searchSubtitleSentences(this.db, query, limit, options);
}
async getSessionEvents(
sessionId: number,
limit = 500,
@@ -1149,7 +1195,9 @@ export class ImmersionTrackerService {
return;
}
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
this.mediaPathAliases.set(rawPath, normalizedPath);
for (const alias of buildJellyfinMediaPathAliasCandidates(rawPath)) {
this.mediaPathAliases.set(alias, normalizedPath);
}
const displayTitle =
normalizeText(metadata.displayTitle) ||
@@ -1158,6 +1206,8 @@ export class ImmersionTrackerService {
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
const seriesTitle = normalizeText(metadata.seriesTitle);
const libraryTitle = seriesTitle || itemTitle;
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
if (!libraryTitle) {
return;
}
@@ -1181,12 +1231,13 @@ export class ImmersionTrackerService {
itemTitle,
seriesTitle: seriesTitle || null,
displayTitle,
seasonNumber: normalizeMetadataInt(metadata.seasonNumber),
episodeNumber: normalizeMetadataInt(metadata.episodeNumber),
seasonNumber,
episodeNumber,
});
const animeId = getOrCreateAnimeRecord(this.db, {
parsedTitle: libraryTitle,
canonicalTitle: libraryTitle,
seasonScope: seasonNumber,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
@@ -1197,8 +1248,8 @@ export class ImmersionTrackerService {
animeId,
parsedBasename: null,
parsedTitle: libraryTitle,
parsedSeason: normalizeMetadataInt(metadata.seasonNumber),
parsedEpisode: normalizeMetadataInt(metadata.episodeNumber),
parsedSeason: seasonNumber,
parsedEpisode: episodeNumber,
parserSource: 'jellyfin',
parserConfidence: 1,
parseMetadataJson: metadataJson,
@@ -1221,7 +1272,10 @@ export class ImmersionTrackerService {
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
const rawPath = normalizeMediaPath(mediaPath);
const normalizedPath = this.mediaPathAliases.get(rawPath) ?? rawPath;
const normalizedPath =
buildJellyfinMediaPathAliasCandidates(rawPath)
.map((alias) => this.mediaPathAliases.get(alias))
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
const normalizedTitle = normalizeText(mediaTitle);
this.logger.info(
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
@@ -1294,7 +1348,7 @@ export class ImmersionTrackerService {
const cleaned = normalizeText(text);
if (!cleaned) return;
if (!endSec || endSec <= 0) {
if (!Number.isFinite(startSec) || !Number.isFinite(endSec) || endSec <= startSec) {
return;
}
@@ -1826,6 +1880,7 @@ export class ImmersionTrackerService {
const animeId = getOrCreateAnimeRecord(this.db, {
parsedTitle: parsed.parsedTitle,
canonicalTitle: parsed.parsedTitle,
seasonScope: parsed.parsedSeason,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
test('getRollupGroupsForSessions uses only localtime rollup keys', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'src/core/services/immersion-tracker/maintenance.ts'),
'utf8',
);
const start = source.indexOf('export function getRollupGroupsForSessions');
const end = source.indexOf('export function refreshRollupsForGroupsInTransaction');
const functionSource = source.slice(start, end);
assert.match(functionSource, /'unixepoch', 'localtime'/);
assert.doesNotMatch(functionSource, /UNION/);
assert.doesNotMatch(functionSource, /86400000/);
});
@@ -356,6 +356,81 @@ test('split session and lexical helpers return distinct-headword, detail, appear
}
});
test('similar words use same reading and shared kanji without kana suffix noise', () => {
const { db, dbPath, stmts } = createDb();
try {
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Similar Words Anime',
canonicalTitle: 'Similar Words Anime',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/similar-words.mkv', {
canonicalTitle: 'Similar Words Episode',
sourcePath: '/tmp/similar-words.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const sessionId = startSessionRecord(db, videoId, 1_000_000).sessionId;
const araiId = insertWordOccurrence(db, stmts, {
sessionId,
videoId,
animeId,
lineIndex: 1,
text: '荒い息',
word: { headword: '荒い', word: '荒い', reading: 'あらい' },
});
insertWordOccurrence(db, stmts, {
sessionId,
videoId,
animeId,
lineIndex: 2,
text: '洗い物',
word: { headword: '洗い', word: '洗い', reading: 'あらい' },
});
insertWordOccurrence(db, stmts, {
sessionId,
videoId,
animeId,
lineIndex: 3,
text: '荒波',
word: { headword: '荒波', word: '荒波', reading: 'あらなみ' },
});
for (let lineIndex = 4; lineIndex < 9; lineIndex++) {
insertWordOccurrence(db, stmts, {
sessionId,
videoId,
animeId,
lineIndex,
text: '良い',
word: { headword: '良い', word: '良い', reading: 'よい' },
});
}
insertWordOccurrence(db, stmts, {
sessionId,
videoId,
animeId,
lineIndex: 9,
text: 'お構いなく',
word: { headword: 'お構いなく', word: 'お構いなく', reading: 'おかまいなく' },
});
assert.deepEqual(
getSimilarWords(db, araiId, 10).map((row) => row.headword),
['洗い', '荒波'],
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('split library helpers return anime/media session and analytics rows', () => {
const { db, dbPath, stmts } = createDb();
@@ -605,6 +680,79 @@ test('split maintenance helpers update anime metadata and watched state', () =>
}
});
test('deleteSessions refreshes only rollups affected by deleted sessions', () => {
const { db, dbPath } = createDb();
try {
const keepVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-keep.mkv', {
canonicalTitle: 'Rollup Keep',
sourcePath: '/tmp/rollup-keep.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const dropVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-drop.mkv', {
canonicalTitle: 'Rollup Drop',
sourcePath: '/tmp/rollup-drop.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const keepStartedAtMs = 1_700_000_000_000;
const dropStartedAtMs = 1_700_086_400_000;
const keepSessionId = startSessionRecord(db, keepVideoId, keepStartedAtMs).sessionId;
const dropSessionId = startSessionRecord(db, dropVideoId, dropStartedAtMs).sessionId;
finalizeSessionMetrics(db, keepSessionId, keepStartedAtMs, {
activeWatchedMs: 30_000,
cardsMined: 1,
});
finalizeSessionMetrics(db, dropSessionId, dropStartedAtMs, {
activeWatchedMs: 60_000,
cardsMined: 2,
});
const keepDay = getLocalEpochDay(db, keepStartedAtMs);
const dropDay = getLocalEpochDay(db, dropStartedAtMs);
const keepMonth = 202311;
const dropMonth = 202311;
const insertDaily = db.prepare(`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertMonthly = db.prepare(`
INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertDaily.run(keepDay, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
insertDaily.run(dropDay, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
insertMonthly.run(keepMonth, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
insertMonthly.run(dropMonth, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
deleteSessions(db, [dropSessionId]);
const dailyRows = db
.prepare('SELECT rollup_day, video_id, total_cards FROM imm_daily_rollups ORDER BY video_id')
.all() as Array<{ rollup_day: number; video_id: number; total_cards: number }>;
const monthlyRows = db
.prepare(
'SELECT rollup_month, video_id, total_cards FROM imm_monthly_rollups ORDER BY video_id',
)
.all() as Array<{ rollup_month: number; video_id: number; total_cards: number }>;
assert.deepEqual(dailyRows, [{ rollup_day: keepDay, video_id: keepVideoId, total_cards: 1 }]);
assert.deepEqual(monthlyRows, [
{ rollup_month: keepMonth, video_id: keepVideoId, total_cards: 1 },
]);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('split maintenance helpers delete multiple sessions and whole videos with dependent rows', () => {
const { db, dbPath, stmts } = createDb();
@@ -35,9 +35,11 @@ import {
getSessionTimeline,
getSessionWordsByLine,
getWordOccurrences,
searchSubtitleSentences,
upsertCoverArt,
} from '../query.js';
import {
getLocalEpochDay,
getShiftedLocalDaySec,
getStartOfLocalDayTimestamp,
toDbTimestamp,
@@ -759,6 +761,10 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
assert.equal(dashboard.progress.watchTime[1]?.value, 75);
assert.equal(dashboard.progress.lookups[1]?.value, 18);
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
assert.equal(dashboard.ratios.cardsPerHour[0]?.value, +(2 / (30 / 60)).toFixed(1));
assert.equal(dashboard.ratios.cardsPerHour[1]?.value, +(3 / (45 / 60)).toFixed(1));
assert.equal(dashboard.ratios.readingSpeed[0]?.value, +(120 / 30).toFixed(1));
assert.equal(dashboard.ratios.readingSpeed[1]?.value, +(140 / 45).toFixed(1));
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime');
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
assert.equal(
@@ -771,6 +777,84 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
}
});
test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const rawStreamTitle =
'stream?static true&api key secret-token&MediaSourceId ms-1&AudioStreamIndex 3&SubtitleStreamIndex 4';
const videoId = getOrCreateVideoRecord(
db,
'remote:http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
{
canonicalTitle: rawStreamTitle,
sourcePath: null,
sourceUrl:
'http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
sourceType: SOURCE_TYPE_REMOTE,
},
);
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: rawStreamTitle,
canonicalTitle: rawStreamTitle,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename:
'stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
parsedTitle: rawStreamTitle,
parsedSeason: null,
parsedEpisode: null,
parserSource: 'guessit',
parserConfidence: 1,
parseMetadataJson: null,
});
const startedAtMs = 1_700_000_000_000;
const session = startSessionRecord(db, videoId, startedAtMs);
db.prepare(
`
UPDATE imm_sessions
SET
ended_at_ms = ?,
total_watched_ms = ?,
active_watched_ms = ?,
tokens_seen = ?
WHERE session_id = ?
`,
).run(`${startedAtMs + 30 * 60_000}`, 30 * 60_000, 30 * 60_000, 120, session.sessionId);
db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards
) VALUES (?, ?, ?, ?, ?, ?, ?)
`,
).run(Math.floor(startedAtMs / 86_400_000), videoId, 1, 30, 10, 120, 0);
const dashboard = getTrendsDashboard(db, 'all', 'day');
const titles = [
...dashboard.animeCumulative.watchTime.map((point) => point.animeTitle),
...dashboard.librarySummary.map((row) => row.title),
];
assert.deepEqual([...new Set(titles)], ['Jellyfin Video']);
assert.equal(titles.some((title) => title.includes('api_key=')), false);
assert.equal(titles.some((title) => title.includes('api key')), false);
assert.equal(titles.some((title) => title.includes('secret-token')), false);
assert.equal(titles.some((title) => title.includes('stream?')), false);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
@@ -3686,6 +3770,187 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
}
});
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Dungeon Meshi',
canonicalTitle: 'Dungeon Meshi',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: '{"source":"test"}',
});
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/dungeon-meshi-01.mkv', {
canonicalTitle: 'Episode 1',
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: 'Dungeon Meshi 01.mkv',
parsedTitle: 'Dungeon Meshi',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: '{"episode":1}',
});
const { sessionId } = startSessionRecord(db, videoId, 3_000_000);
db.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
sessionId,
null,
videoId,
animeId,
7,
4_000,
5_500,
'魔物を食べるなんて信じられない',
'I cannot believe we are eating monsters',
3_000,
3_000,
);
db.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
sessionId,
null,
videoId,
animeId,
8,
6_000,
7_000,
'これは別の行です',
'Another line',
2_000,
2_000,
);
const rows = searchSubtitleSentences(db, '魔物 食べる', 10);
assert.deepEqual(rows, [
{
animeId,
animeTitle: 'Dungeon Meshi',
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
secondaryText: 'I cannot believe we are eating monsters',
videoId,
videoTitle: 'Episode 1',
sessionId,
lineIndex: 7,
segmentStartMs: 4_000,
segmentEndMs: 5_500,
text: '魔物を食べるなんて信じられない',
},
]);
assert.deepEqual(searchSubtitleSentences(db, 'monsters', 10), []);
assert.doesNotThrow(() => searchSubtitleSentences(db, '魔物', Number.POSITIVE_INFINITY));
assert.equal(searchSubtitleSentences(db, '魔物', -1).length, 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Little Witch Academia',
canonicalTitle: 'Little Witch Academia',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: '{"source":"test"}',
});
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/lwa-05.mkv', {
canonicalTitle: 'Episode 5',
sourcePath: '/tmp/Little Witch Academia S01E05.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: 'Little Witch Academia S01E05.mkv',
parsedTitle: 'Little Witch Academia',
parsedSeason: 1,
parsedEpisode: 5,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: '{"episode":5}',
});
const { sessionId } = startSessionRecord(db, videoId, 4_000_000);
const lineResult = db
.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
sessionId,
null,
videoId,
animeId,
20,
247_000,
250_000,
'ああ、名無しが何だか知らねえが',
null,
4_000,
4_000,
);
const wordResult = db
.prepare(
`INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run('知る', '知らねえ', 'しらねえ', 'verb', '動詞', '自立', '', 4_000, 4_000, 1);
db.prepare(
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
VALUES (?, ?, ?)`,
).run(Number(lineResult.lastInsertRowid), Number(wordResult.lastInsertRowid), 1);
assert.deepEqual(searchSubtitleSentences(db, '知らない', 10), []);
const rows = searchSubtitleSentences(db, '知らない', 10, {
headwordTerms: [{ term: '知らない', headwords: ['知る'] }],
});
assert.deepEqual(
rows.map((row) => row.text),
['ああ、名無しが何だか知らねえが'],
);
assert.deepEqual(
searchSubtitleSentences(db, '知らねえ', 10).map((row) => row.text),
['ああ、名無しが何だか知らねえが'],
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
@@ -4100,8 +4365,14 @@ test('deleteSession removes zero-session media from library and trends', () => {
const startedAtMs = 9_000_000;
const endedAtMs = startedAtMs + 120_000;
const rollupDay = Math.floor(startedAtMs / 86_400_000);
const rollupMonth = 197001;
const rollupDay = getLocalEpochDay(db, startedAtMs);
const rollupMonth = (
db
.prepare(
"SELECT CAST(strftime('%Y%m', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollupMonth",
)
.get(startedAtMs) as { rollupMonth: number }
).rollupMonth;
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
db.prepare(
@@ -0,0 +1,413 @@
import type { DatabaseSync } from './sqlite';
import { normalizeText } from './reducer';
import { normalizeAnimeIdentityKey } from './storage';
import { nowMs } from './time';
import { toDbTimestamp } from './query-shared';
import type { JellyfinLinkRepairSummary } from './types';
type LegacyJellyfinVideoRow = {
video_id: number;
video_key: string;
source_url: string | null;
canonical_title: string;
};
type JellyfinTargetVideoRow = {
video_id: number;
anime_id: number | null;
canonical_title: string;
parsed_basename: string | null;
parsed_title: string | null;
parsed_season: number | null;
parsed_episode: number | null;
parser_source: string | null;
parser_confidence: number | null;
parse_metadata_json: string | null;
};
type LeakedAnimeTitleRow = {
anime_id: number;
canonical_title: string;
normalized_title_key: string;
title_romaji: string | null;
title_english: string | null;
title_native: string | null;
linked_video_title: string | null;
};
function looksLikeLeakedJellyfinTitle(value: string | null): boolean {
if (!value) return false;
const lowered = value.toLowerCase();
const hasApiKey = /api[\s_-]*key(?:\s|=|$)/i.test(value);
return (
hasApiKey &&
(lowered.includes('stream?') ||
lowered.includes('/stream?') ||
lowered.includes('/videos/') ||
lowered.includes('mediasourceid'))
);
}
function chooseSafeAnimeTitle(row: LeakedAnimeTitleRow): string | null {
const candidates = [
row.title_english,
row.title_romaji,
row.title_native,
row.linked_video_title?.replace(/^\[Jellyfin\/direct]\s*/i, ''),
];
for (const candidate of candidates) {
const normalized = candidate?.trim();
if (normalized && !looksLikeLeakedJellyfinTitle(normalized)) {
return normalized;
}
}
return null;
}
function parseLegacyJellyfinStreamUrl(value: string | null): URL | null {
if (!value) return null;
const trimmed = value.trim();
const urlText = trimmed.startsWith('remote:') ? trimmed.slice('remote:'.length) : trimmed;
try {
const url = new URL(urlText);
const pathSegments = url.pathname.split('/').filter(Boolean);
const videosIndex = pathSegments.findIndex((segment) => segment.toLowerCase() === 'videos');
if (
videosIndex < 0 ||
pathSegments[videosIndex + 1] === undefined ||
pathSegments[videosIndex + 2]?.toLowerCase() !== 'stream'
) {
return null;
}
if (!url.searchParams.has('api_key')) {
return null;
}
return url;
} catch {
return null;
}
}
function buildJellyfinStatsUrlFromLegacyStream(url: URL): string | null {
const pathSegments = url.pathname.split('/').filter(Boolean);
const videosIndex = pathSegments.findIndex((segment) => segment.toLowerCase() === 'videos');
const itemId = normalizeText(pathSegments[videosIndex + 1]);
if (!itemId) return null;
return `jellyfin://${url.host}/item/${encodeURIComponent(itemId)}`;
}
function buildSanitizedJellyfinVideoKey(
db: DatabaseSync,
videoId: number,
statsUrl: string,
): string {
const baseKey = `remote:${statsUrl}`;
const existing = db
.prepare('SELECT video_id FROM imm_videos WHERE video_key = ?')
.get(baseKey) as { video_id: number } | null;
if (!existing || existing.video_id === videoId) {
return baseKey;
}
return `${baseKey}#legacy-${videoId}`;
}
function repairLeakedJellyfinAnimeTitles(db: DatabaseSync, currentTimestamp: string): number {
const candidates = (
db
.prepare(
`
SELECT
a.anime_id,
a.normalized_title_key,
a.canonical_title,
a.title_romaji,
a.title_english,
a.title_native,
(
SELECT v.canonical_title
FROM imm_videos v
WHERE v.anime_id = a.anime_id
AND v.canonical_title NOT LIKE '%api_key=%'
AND lower(v.canonical_title) NOT LIKE '%api key%'
ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC
LIMIT 1
) AS linked_video_title
FROM imm_anime a
WHERE a.canonical_title LIKE '%api_key=%'
OR lower(a.canonical_title) LIKE '%api key%'
OR lower(a.normalized_title_key) LIKE '%api key%'
`,
)
.all() as LeakedAnimeTitleRow[]
).filter(
(row) =>
looksLikeLeakedJellyfinTitle(row.canonical_title) ||
looksLikeLeakedJellyfinTitle(row.normalized_title_key),
);
let repaired = 0;
for (const candidate of candidates) {
const replacementTitle = chooseSafeAnimeTitle(candidate);
if (!replacementTitle) {
continue;
}
const replacementKey = normalizeAnimeIdentityKey(replacementTitle);
if (!replacementKey) {
continue;
}
const existing = db
.prepare(
`
SELECT anime_id
FROM imm_anime
WHERE normalized_title_key = ?
AND anime_id != ?
`,
)
.get(replacementKey, candidate.anime_id) as { anime_id: number } | null;
if (existing) {
const videoUpdate = db
.prepare(
`
UPDATE imm_videos
SET anime_id = ?, LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
)
.run(existing.anime_id, currentTimestamp, candidate.anime_id) as { changes: number };
const subtitleUpdate = db
.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?, LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
)
.run(existing.anime_id, currentTimestamp, candidate.anime_id) as { changes: number };
const animeDelete = db
.prepare(
`
DELETE FROM imm_anime
WHERE anime_id = ?
AND NOT EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
AND NOT EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
`,
)
.run(candidate.anime_id, candidate.anime_id, candidate.anime_id) as { changes: number };
if (videoUpdate.changes > 0 || subtitleUpdate.changes > 0) {
repaired += 1;
} else if (animeDelete.changes > 0) {
repaired += 1;
}
continue;
}
const updated = db
.prepare(
`
UPDATE imm_anime
SET
normalized_title_key = ?,
canonical_title = ?,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
)
.run(replacementKey, replacementTitle, currentTimestamp, candidate.anime_id) as {
changes: number;
};
if (updated.changes > 0) {
repaired += 1;
}
}
return repaired;
}
function repairLeakedJellyfinVideoParseMetadata(
db: DatabaseSync,
currentTimestamp: string,
): number {
const updated = db
.prepare(
`
UPDATE imm_videos
SET
parsed_basename = NULL,
parsed_title = NULL,
parse_metadata_json = NULL,
parser_source = CASE
WHEN parser_source = 'guessit' THEN 'jellyfin'
ELSE parser_source
END,
LAST_UPDATE_DATE = ?
WHERE source_type = 2
AND (
parsed_basename LIKE '%api_key=%'
OR lower(parsed_basename) LIKE '%api key%'
OR parsed_title LIKE '%api_key=%'
OR lower(parsed_title) LIKE '%api key%'
OR parse_metadata_json LIKE '%api_key=%'
OR lower(parse_metadata_json) LIKE '%api key%'
)
`,
)
.run(currentTimestamp) as { changes: number };
return updated.changes;
}
export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRepairSummary {
const candidates = db
.prepare(
`
SELECT video_id, video_key, source_url, canonical_title
FROM imm_videos
WHERE source_type = 2
AND (
video_key LIKE '%api_key=%'
OR lower(video_key) LIKE '%api key%'
OR source_url LIKE '%api_key=%'
OR lower(source_url) LIKE '%api key%'
OR canonical_title LIKE '%api_key=%'
OR lower(canonical_title) LIKE '%api key%'
)
`,
)
.all() as LegacyJellyfinVideoRow[];
const summary: JellyfinLinkRepairSummary = {
scanned: candidates.length,
repaired: 0,
};
if (candidates.length === 0) {
const currentTimestamp = toDbTimestamp(nowMs());
const repaired =
repairLeakedJellyfinAnimeTitles(db, currentTimestamp) +
repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
summary.repaired += repaired;
return summary;
}
const currentTimestamp = toDbTimestamp(nowMs());
db.exec('BEGIN IMMEDIATE');
try {
for (const candidate of candidates) {
const legacyUrl =
parseLegacyJellyfinStreamUrl(candidate.source_url) ??
parseLegacyJellyfinStreamUrl(candidate.video_key);
if (!legacyUrl) {
continue;
}
const statsUrl = buildJellyfinStatsUrlFromLegacyStream(legacyUrl);
if (!statsUrl) {
continue;
}
const sanitizedVideoKey = buildSanitizedJellyfinVideoKey(db, candidate.video_id, statsUrl);
const sanitizedCanonicalTitle = looksLikeLeakedJellyfinTitle(candidate.canonical_title)
? 'Jellyfin Video'
: candidate.canonical_title;
const target = db
.prepare(
`
SELECT
video_id,
anime_id,
canonical_title,
parsed_basename,
parsed_title,
parsed_season,
parsed_episode,
parser_source,
parser_confidence,
parse_metadata_json
FROM imm_videos
WHERE video_id != ?
AND (video_key = ? OR source_url = ?)
ORDER BY parser_source = 'jellyfin' DESC, video_id DESC
LIMIT 1
`,
)
.get(candidate.video_id, `remote:${statsUrl}`, statsUrl) as JellyfinTargetVideoRow | null;
if (!target) {
const updated = db
.prepare(
`
UPDATE imm_videos
SET
video_key = ?,
source_url = ?,
canonical_title = ?,
parser_source = COALESCE(parser_source, 'jellyfin'),
LAST_UPDATE_DATE = ?
WHERE video_id = ?
AND (video_key != ? OR source_url != ? OR canonical_title != ?)
`,
)
.run(
sanitizedVideoKey,
statsUrl,
sanitizedCanonicalTitle,
currentTimestamp,
candidate.video_id,
sanitizedVideoKey,
statsUrl,
sanitizedCanonicalTitle,
) as { changes: number };
if (updated.changes > 0) {
summary.repaired += 1;
}
continue;
}
db.prepare(
`
UPDATE imm_videos
SET
video_key = ?,
anime_id = ?,
canonical_title = ?,
source_url = ?,
parsed_basename = ?,
parsed_title = ?,
parsed_season = ?,
parsed_episode = ?,
parser_source = ?,
parser_confidence = ?,
parse_metadata_json = ?,
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
).run(
sanitizedVideoKey,
target.anime_id,
target.canonical_title,
statsUrl,
target.parsed_basename,
target.parsed_title,
target.parsed_season,
target.parsed_episode,
target.parser_source,
target.parser_confidence,
target.parse_metadata_json,
currentTimestamp,
candidate.video_id,
);
if (target.anime_id !== null) {
db.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?, LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
).run(target.anime_id, currentTimestamp, candidate.video_id);
}
summary.repaired += 1;
}
summary.repaired += repairLeakedJellyfinAnimeTitles(db, currentTimestamp);
summary.repaired += repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
return summary;
}
+168 -44
View File
@@ -60,6 +60,34 @@ interface RetainedSessionRow {
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,
MAX(COALESCE(t.active_watched_ms, s.active_watched_ms, 0), 0) AS active_ms,
MAX(COALESCE(t.cards_mined, s.cards_mined, 0), 0) AS cards_mined,
MAX(COALESCE(t.lines_seen, s.lines_seen, 0), 0) AS lines_seen,
MAX(COALESCE(t.tokens_seen, s.tokens_seen, 0), 0) 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,
@@ -154,54 +182,150 @@ function rebuildLifetimeSummariesInternal(
db: DatabaseSync,
rebuiltAtMs: number,
): LifetimeRebuildSummary {
const rows = db
.prepare(
`
SELECT
session_id AS sessionId,
video_id AS videoId,
started_at_ms AS startedAtMs,
ended_at_ms AS endedAtMs,
ended_media_ms AS lastMediaMs,
total_watched_ms AS totalWatchedMs,
active_watched_ms AS activeWatchedMs,
lines_seen AS linesSeen,
tokens_seen AS tokensSeen,
cards_mined AS cardsMined,
lookup_count AS lookupCount,
lookup_hits AS lookupHits,
yomitan_lookup_count AS yomitanLookupCount,
pause_count AS pauseCount,
pause_ms AS pauseMs,
seek_forward_count AS seekForwardCount,
seek_backward_count AS seekBackwardCount,
media_buffer_events AS mediaBufferEvents
FROM imm_sessions
WHERE ended_at_ms IS NOT NULL
ORDER BY started_at_ms ASC, session_id ASC
`,
)
.all() as Array<
Omit<RetainedSessionRow, 'startedAtMs' | 'endedAtMs' | 'lastMediaMs'> & {
startedAtMs: number | string;
endedAtMs: number | string;
lastMediaMs: number | string | null;
}
>;
const sessions = rows.map((row) => ({
...row,
startedAtMs: row.startedAtMs,
endedAtMs: row.endedAtMs,
lastMediaMs: row.lastMediaMs === null ? null : Number(row.lastMediaMs),
})) as RetainedSessionRow[];
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);
for (const session of sessions) {
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs);
}
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: sessions.length,
appliedSessions,
rebuiltAtMs,
};
}
@@ -1,6 +1,6 @@
import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
import { subtractDbTimestamp, toDbTimestamp } from './query-shared';
import { makePlaceholders, subtractDbTimestamp, toDbTimestamp } from './query-shared';
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
const DAILY_MS = 86_400_000;
@@ -20,6 +20,12 @@ interface RollupTelemetryResult {
maxSampleMs: number | null;
}
export interface RollupGroup {
rollupDay: number;
rollupMonth: number;
videoId: number;
}
interface RawRetentionResult {
deletedSessionEvents: number;
deletedTelemetryRows: number;
@@ -164,6 +170,26 @@ function upsertDailyRollupsForGroups(
}
const upsertStmt = db.prepare(`
WITH matching_sessions AS (
SELECT *
FROM imm_sessions
WHERE CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ?
AND video_id = ?
),
session_metrics AS (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards,
MAX(t.lookup_count) AS max_lookups,
MAX(t.lookup_hits) AS max_hits
FROM imm_session_telemetry t
JOIN matching_sessions s
ON s.session_id = t.session_id
GROUP BY t.session_id
)
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, cards_per_hour,
@@ -197,20 +223,8 @@ function upsertDailyRollupsForGroups(
END AS lookup_hit_rate,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM imm_sessions s
LEFT JOIN (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards,
MAX(t.lookup_count) AS max_lookups,
MAX(t.lookup_hits) AS max_hits
FROM imm_session_telemetry t
GROUP BY t.session_id
) sm ON s.session_id = sm.session_id
WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ? AND s.video_id = ?
FROM matching_sessions s
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
GROUP BY rollup_day, s.video_id
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
total_sessions = excluded.total_sessions,
@@ -226,7 +240,7 @@ function upsertDailyRollupsForGroups(
`);
for (const { rollupDay, videoId } of groups) {
upsertStmt.run(rollupNowMs, rollupNowMs, rollupDay, videoId);
upsertStmt.run(rollupDay, videoId, rollupNowMs, rollupNowMs);
}
}
@@ -240,6 +254,24 @@ function upsertMonthlyRollupsForGroups(
}
const upsertStmt = db.prepare(`
WITH matching_sessions AS (
SELECT *
FROM imm_sessions
WHERE CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) = ?
AND video_id = ?
),
session_metrics AS (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards
FROM imm_session_telemetry t
JOIN matching_sessions s
ON s.session_id = t.session_id
GROUP BY t.session_id
)
INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
@@ -254,18 +286,8 @@ function upsertMonthlyRollupsForGroups(
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM imm_sessions s
LEFT JOIN (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards
FROM imm_session_telemetry t
GROUP BY t.session_id
) sm ON s.session_id = sm.session_id
WHERE CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) = ? AND s.video_id = ?
FROM matching_sessions s
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
GROUP BY rollup_month, s.video_id
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
total_sessions = excluded.total_sessions,
@@ -278,10 +300,75 @@ function upsertMonthlyRollupsForGroups(
`);
for (const { rollupMonth, videoId } of groups) {
upsertStmt.run(rollupNowMs, rollupNowMs, rollupMonth, videoId);
upsertStmt.run(rollupMonth, videoId, rollupNowMs, rollupNowMs);
}
}
export function getRollupGroupsForSessions(db: DatabaseSync, sessionIds: number[]): RollupGroup[] {
if (sessionIds.length === 0) {
return [];
}
const placeholders = makePlaceholders(sessionIds);
const rows = db
.prepare(
`
SELECT DISTINCT
CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS rollup_day,
CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollup_month,
video_id
FROM imm_sessions
WHERE session_id IN (${placeholders})
`,
)
.all(...sessionIds) as RollupGroupRow[];
return rows.map((row) => ({
rollupDay: row.rollup_day,
rollupMonth: row.rollup_month,
videoId: row.video_id,
}));
}
export function refreshRollupsForGroupsInTransaction(
db: DatabaseSync,
groups: RollupGroup[],
): void {
if (groups.length === 0) {
return;
}
const rollupNowMs = toDbTimestamp(nowMs());
const dailyGroups = dedupeGroups(
groups.map((group) => ({
rollupDay: group.rollupDay,
videoId: group.videoId,
})),
);
const monthlyGroups = dedupeGroups(
groups.map((group) => ({
rollupMonth: group.rollupMonth,
videoId: group.videoId,
})),
);
const deleteDailyStmt = db.prepare(
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
);
const deleteMonthlyStmt = db.prepare(
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
);
for (const { rollupDay, videoId } of dailyGroups) {
deleteDailyStmt.run(rollupDay, videoId);
}
for (const { rollupMonth, videoId } of monthlyGroups) {
deleteMonthlyStmt.run(rollupMonth, videoId);
}
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
}
function getAffectedRollupGroups(
db: DatabaseSync,
lastRollupSampleMs: number | string,
@@ -179,6 +179,32 @@ test('guessAnimeVideoMetadata uses guessit basename output first when available'
});
});
test('guessAnimeVideoMetadata keeps season directory scope when guessit omits season', async () => {
const parsed = await guessAnimeVideoMetadata(
'/tmp/KonoSuba/Season 2/KonoSuba - 05.mkv',
'Episode 5',
{
runGuessit: async () =>
JSON.stringify({
title: 'KonoSuba',
}),
},
);
assert.deepEqual(parsed, {
parsedBasename: 'KonoSuba - 05.mkv',
parsedTitle: 'KonoSuba',
parsedSeason: 2,
parsedEpisode: null,
parserSource: 'guessit',
parserConfidence: 1,
parseMetadataJson: JSON.stringify({
filename: 'KonoSuba - 05.mkv',
source: 'guessit',
}),
});
});
test('guessAnimeVideoMetadata falls back to parser when guessit throws', async () => {
const parsed = await guessAnimeVideoMetadata(
'/tmp/Little Witch Academia S02E05.mkv',
@@ -7,6 +7,8 @@ import type {
KanjiOccurrenceRow,
KanjiStatsRow,
KanjiWordRow,
SentenceSearchOptions,
SentenceSearchResultRow,
SessionEventRow,
SimilarWordRow,
StatsExcludedWordRow,
@@ -20,6 +22,56 @@ import { nowMs } from './time';
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
const SENTENCE_SEARCH_MAX_LIMIT = 100;
const KANJI_PATTERN = /\p{Script=Han}/gu;
function resolveSentenceSearchLimit(limit: number): number {
if (!Number.isFinite(limit)) return SENTENCE_SEARCH_DEFAULT_LIMIT;
const normalized = Math.floor(limit);
if (normalized <= 0) return SENTENCE_SEARCH_DEFAULT_LIMIT;
return Math.min(normalized, SENTENCE_SEARCH_MAX_LIMIT);
}
export function splitSentenceSearchTerms(query: string): string[] {
return query
.trim()
.split(/\s+/)
.map((term) => term.trim())
.filter(Boolean)
.slice(0, 8);
}
function escapeLikeTerm(term: string): string {
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
}
function uniqueNonEmptyTerms(values: readonly string[] | undefined): string[] {
const seen = new Set<string>();
const terms: string[] = [];
for (const value of values ?? []) {
const term = value.trim();
if (!term || seen.has(term)) continue;
seen.add(term);
terms.push(term);
}
return terms;
}
function getHeadwordCandidatesForSentenceSearchTerm(
term: string,
options: SentenceSearchOptions | undefined,
): string[] {
const headwords =
options?.headwordTerms
?.filter((entry) => entry.term === term)
.flatMap((entry) => entry.headwords) ?? [];
return uniqueNonEmptyTerms(headwords);
}
function uniqueKanji(text: string): string[] {
return Array.from(new Set(text.match(KANJI_PATTERN) ?? []));
}
function toVocabularyToken(row: VocabularyStatsRow): MergedToken {
const partOfSpeech =
@@ -211,6 +263,70 @@ export function getKanjiOccurrences(
.all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[];
}
export function searchSubtitleSentences(
db: DatabaseSync,
query: string,
limit = SENTENCE_SEARCH_DEFAULT_LIMIT,
options?: SentenceSearchOptions,
): SentenceSearchResultRow[] {
const terms = splitSentenceSearchTerms(query);
if (terms.length === 0) return [];
const resolvedLimit = resolveSentenceSearchLimit(limit);
const clauses: string[] = [];
const params: string[] = [];
for (const term of terms) {
const likeTerm = `%${escapeLikeTerm(term)}%`;
const headwords = getHeadwordCandidatesForSentenceSearchTerm(term, options);
const headwordClause =
headwords.length > 0
? `
OR EXISTS (
SELECT 1
FROM imm_word_line_occurrences o
JOIN imm_words w ON w.id = o.word_id
WHERE o.line_id = l.line_id
AND w.headword IN (${headwords.map(() => '?').join(', ')})
)
`
: '';
clauses.push(`
(
l.text LIKE ? ESCAPE '\\'
OR v.canonical_title LIKE ? ESCAPE '\\'
OR COALESCE(a.canonical_title, '') LIKE ? ESCAPE '\\'
${headwordClause}
)
`);
params.push(likeTerm, likeTerm, likeTerm, ...headwords);
}
return db
.prepare(
`
SELECT
l.anime_id AS animeId,
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,
l.segment_end_ms AS segmentEndMs,
l.text AS text
FROM imm_subtitle_lines l
JOIN imm_videos v ON v.video_id = l.video_id
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
WHERE ${clauses.join(' AND ')}
ORDER BY l.CREATED_DATE DESC, l.line_id DESC
LIMIT ?
`,
)
.all(...params, resolvedLimit) as unknown as SentenceSearchResultRow[];
}
export function getSessionEvents(
db: DatabaseSync,
sessionId: number,
@@ -287,24 +403,38 @@ export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): S
reading: string;
} | null;
if (!word || word.headword.trim() === '') return [];
const clauses: string[] = [];
const params: string[] = [];
const reading = word.reading.trim();
if (reading !== '') {
clauses.push('reading = ?');
params.push(word.reading);
}
for (const kanji of uniqueKanji(word.headword)) {
clauses.push("headword LIKE ? ESCAPE '\\'");
params.push(`%${escapeLikeTerm(kanji)}%`);
}
if (clauses.length === 0) return [];
const orderBy =
reading !== '' ? 'CASE WHEN reading = ? THEN 0 ELSE 1 END, frequency DESC' : 'frequency DESC';
const orderParams = reading !== '' ? [word.reading] : [];
return db
.prepare(
`
SELECT id AS wordId, headword, word, reading, frequency
FROM imm_words
WHERE id != ?
AND (reading = ? OR headword LIKE ? OR headword LIKE ?)
ORDER BY frequency DESC
AND (${clauses.join(' OR ')})
ORDER BY ${orderBy}
LIMIT ?
`,
)
.all(
wordId,
word.reading,
`%${word.headword.charAt(0)}%`,
`%${word.headword.charAt(word.headword.length - 1)}%`,
limit,
) as SimilarWordRow[];
.all(wordId, ...params, ...orderParams, limit) as SimilarWordRow[];
}
export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null {
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import type { DatabaseSync } from './sqlite';
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
import { rebuildRollupsInTransaction } from './maintenance';
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
import { nowMs } from './time';
import { PartOfSpeech, type MergedToken } from '../../../types';
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
@@ -474,13 +474,14 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
const sessionIds = [sessionId];
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
db.exec('BEGIN IMMEDIATE');
try {
deleteSessionsByIds(db, sessionIds);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
rebuildLifetimeSummariesInTransaction(db);
rebuildRollupsInTransaction(db);
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
@@ -492,13 +493,14 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
if (sessionIds.length === 0) return;
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
db.exec('BEGIN IMMEDIATE');
try {
deleteSessionsByIds(db, sessionIds);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
rebuildLifetimeSummariesInTransaction(db);
rebuildRollupsInTransaction(db);
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
@@ -536,7 +538,6 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
rebuildLifetimeSummariesInTransaction(db);
rebuildRollupsInTransaction(db);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
@@ -74,6 +74,8 @@ export interface TrendsDashboardQueryResult {
};
ratios: {
lookupsPerHundred: TrendChartPoint[];
cardsPerHour: TrendChartPoint[];
readingSpeed: TrendChartPoint[];
};
animeCumulative: {
watchTime: TrendPerAnimePoint[];
@@ -176,11 +178,31 @@ function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSe
return session.tokensSeen;
}
function looksLikeJellyfinStreamTitle(title: string): boolean {
const lowered = title.toLowerCase();
const hasApiKey = /api[\s_-]*key(?:\s|=|$)/i.test(title);
return (
hasApiKey &&
(lowered.includes('stream?') ||
lowered.includes('/stream?') ||
lowered.includes('/videos/') ||
lowered.includes('mediasourceid'))
);
}
function sanitizeTrendTitle(title: string): string {
const normalized = title.trim();
if (!normalized) {
return 'Unknown';
}
return looksLikeJellyfinStreamTitle(normalized) ? 'Jellyfin Video' : normalized;
}
function resolveTrendAnimeTitle(value: {
animeTitle: string | null;
canonicalTitle: string | null;
}): string {
return value.animeTitle ?? value.canonicalTitle ?? 'Unknown';
return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown');
}
function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
@@ -225,6 +247,26 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
}));
}
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
cardsPerHour: TrendChartPoint[];
readingSpeed: TrendChartPoint[];
} {
const cardsPerHour: TrendChartPoint[] = [];
const readingSpeed: TrendChartPoint[] = [];
for (const row of rows) {
const hours = row.activeMin / 60;
cardsPerHour.push({
label: row.label,
value: hours > 0 ? +(row.cards / hours).toFixed(1) : 0,
});
readingSpeed.push({
label: row.label,
value: row.activeMin > 0 ? +(row.words / row.activeMin).toFixed(1) : 0,
});
}
return { cardsPerHour, readingSpeed };
}
function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
const totals = new Array(7).fill(0);
for (const session of sessions) {
@@ -449,7 +491,7 @@ function getVideoAnimeTitleMap(
)
.all(...uniqueIds) as Array<{ videoId: number; animeTitle: string }>;
return new Map(rows.map((row) => [row.videoId, row.animeTitle]));
return new Map(rows.map((row) => [row.videoId, sanitizeTrendTitle(row.animeTitle)]));
}
function resolveVideoAnimeTitle(
@@ -675,6 +717,7 @@ export function getTrendsDashboard(
);
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
const efficiency = buildEfficiencyRates(aggregatedRows);
const activity = {
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
cards: aggregatedRows.map((row) => ({ label: row.label, value: row.cards })),
@@ -724,6 +767,8 @@ export function getTrendsDashboard(
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
cardsPerHour: efficiency.cardsPerHour,
readingSpeed: efficiency.readingSpeed,
},
animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
@@ -813,7 +813,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
.all() as Array<{ canonical_title: string }>;
assert.deepEqual(
animeRows.map((row) => row.canonical_title),
['Frieren', 'Little Witch Academia'],
['Frieren', 'Little Witch Academia Season 2'],
);
const littleWitchRows = db
@@ -855,7 +855,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
})),
[
{
animeTitle: 'Little Witch Academia',
animeTitle: 'Little Witch Academia Season 2',
parsedTitle: 'Little Witch Academia',
parsedBasename: 'Little Witch Academia S02E05.mkv',
parsedSeason: 2,
@@ -863,7 +863,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
parserSource: 'fallback',
},
{
animeTitle: 'Little Witch Academia',
animeTitle: 'Little Witch Academia Season 2',
parsedTitle: 'Little Witch Academia',
parsedBasename: 'Little Witch Academia S02E06.mkv',
parsedSeason: 2,
+35 -3
View File
@@ -23,6 +23,7 @@ export interface TrackerPreparedStatements {
export interface AnimeRecordInput {
parsedTitle: string;
canonicalTitle: string;
seasonScope?: number | null;
anilistId: number | null;
titleRomaji: string | null;
titleEnglish: string | null;
@@ -300,6 +301,31 @@ export function normalizeAnimeIdentityKey(title: string): string {
.replace(/\s+/g, ' ');
}
function normalizeSeasonScope(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
return null;
}
return value;
}
function titleAlreadyHasSeasonScope(title: string, season: number): boolean {
const normalized = title.normalize('NFKC').toLowerCase();
const padded = String(season).padStart(2, '0');
return (
new RegExp(`\\bseason\\s*0?${season}\\b`, 'i').test(normalized) ||
new RegExp(`\\bs0?${season}\\b`, 'i').test(normalized) ||
new RegExp(`\\bs${padded}\\b`, 'i').test(normalized)
);
}
function buildSeasonScopedAnimeTitle(title: string, season: number | null): string {
const trimmed = title.trim();
if (!trimmed || season === null || titleAlreadyHasSeasonScope(trimmed, season)) {
return trimmed;
}
return `${trimmed} Season ${season}`;
}
function looksLikeEpisodeOnlyTitle(title: string): boolean {
const normalized = title.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
return /^(episode|ep)\s*\d{1,3}$/.test(normalized) || /^第\s*\d{1,3}\s*話$/.test(normalized);
@@ -478,7 +504,12 @@ function ensureStatsExcludedWordsTable(db: DatabaseSync): void {
}
export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number {
const normalizedTitleKey = normalizeAnimeIdentityKey(input.parsedTitle);
const seasonScope = normalizeSeasonScope(input.seasonScope);
const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope);
const canonicalTitle =
buildSeasonScopedAnimeTitle(input.canonicalTitle || input.parsedTitle, seasonScope) ||
identityTitle;
const normalizedTitleKey = normalizeAnimeIdentityKey(identityTitle);
if (!normalizedTitleKey) {
throw new Error('parsedTitle is required to create or update an anime record');
}
@@ -508,7 +539,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
WHERE anime_id = ?
`,
).run(
input.canonicalTitle,
canonicalTitle,
input.anilistId,
input.titleRomaji,
input.titleEnglish,
@@ -539,7 +570,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
)
.run(
normalizedTitleKey,
input.canonicalTitle,
canonicalTitle,
input.anilistId,
input.titleRomaji,
input.titleEnglish,
@@ -648,6 +679,7 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: parsed.title,
canonicalTitle: parsed.title,
seasonScope: parsed.season,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
@@ -52,6 +52,11 @@ export interface ImmersionTrackerPolicy {
};
}
export interface JellyfinLinkRepairSummary {
scanned: number;
repaired: number;
}
export interface TelemetryAccumulator {
totalWatchedMs: number;
activeWatchedMs: number;
@@ -367,6 +372,29 @@ export interface KanjiOccurrenceRow {
occurrenceCount: number;
}
export interface SentenceSearchResultRow {
animeId: number | null;
animeTitle: string | null;
videoId: number;
videoTitle: string;
sourcePath: string | null;
secondaryText: string | null;
sessionId: number;
lineIndex: number;
segmentStartMs: number | null;
segmentEndMs: number | null;
text: string;
}
export interface SentenceSearchHeadwordTerm {
term: string;
headwords: string[];
}
export interface SentenceSearchOptions {
headwordTerms?: SentenceSearchHeadwordTerm[];
}
export interface SessionEventRow {
eventType: number;
tsMs: number;
+21
View File
@@ -235,6 +235,27 @@ test('dispatchMpvProtocolMessage prefers the already selected matching secondary
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
});
test('dispatchMpvProtocolMessage skips signs and songs when choosing secondary subtitles', async () => {
const { deps, state } = createDeps({
getResolvedConfig: () => ({
secondarySub: { secondarySubLanguages: ['eng', 'en'] },
}),
});
await dispatchMpvProtocolMessage(
{
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
data: [
{ type: 'sub', id: 2, lang: 'eng', title: 'English Signs & Songs' },
{ type: 'sub', id: 3, lang: 'eng', title: 'English Dialogue' },
],
},
deps,
);
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
});
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
const { deps, state } = createDeps();
+14 -2
View File
@@ -149,6 +149,11 @@ function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string {
return `id:${track.id}`;
}
function isSignsOrSongsSubtitleTrack(track: SubtitleTrackCandidate): boolean {
const label = `${track.title} ${track.externalFilename ?? ''}`.toLowerCase();
return /\b(signs?|songs?)\b/.test(label);
}
function pickSecondarySubtitleTrackId(
tracks: Array<Record<string, unknown>>,
preferredLanguages: string[],
@@ -177,12 +182,19 @@ function pickSecondarySubtitleTrackId(
const uniqueTracks = [...dedupedTracks.values()];
for (const language of normalizedLanguages) {
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
const languageTracks = uniqueTracks.filter((track) => track.lang === language);
if (languageTracks.length === 0) {
continue;
}
const cleanTracks = languageTracks.filter((track) => !isSignsOrSongsSubtitleTrack(track));
const candidateTracks = cleanTracks.length > 0 ? cleanTracks : languageTracks;
const selectedMatch = candidateTracks.find((track) => track.selected);
if (selectedMatch) {
return selectedMatch.id;
}
const match = uniqueTracks.find((track) => track.lang === language);
const match = candidateTracks[0];
if (match) {
return match.id;
}
@@ -0,0 +1,484 @@
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runCommand, type CommandResult } from '../../subsync/utils';
import { parseSubtitleCues, type SubtitleCue } from './subtitle-cue-parser.js';
import { isEnglishYoutubeLang, normalizeYoutubeLangCode } from './youtube/labels.js';
const DEFAULT_SECONDARY_SUBTITLE_LANGUAGES = ['en', 'eng', 'english', 'en-us', 'enus'];
const DEFAULT_PRIMARY_SUBTITLE_LANGUAGES = ['ja', 'jpn', 'jp', 'japanese'];
const SUPPORTED_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass', '.ssa']);
const TIMING_TOLERANCE_SECONDS = 0.25;
const SAME_TIMING_EPSILON_SECONDS = 0.001;
const RETIMED_SUBTITLE_TIMEOUT_MS = 30_000;
const FALLBACK_ALASS_PATHS = [
'/opt/homebrew/bin/alass-cli',
'/opt/homebrew/bin/alass',
'/usr/local/bin/alass-cli',
'/usr/local/bin/alass',
'/usr/bin/alass',
];
type SidecarCandidate = {
path: string;
languageRank: number;
extensionRank: number;
name: string;
};
type RetimedSubtitleCacheEntry = {
path: string;
cleanupDir: string;
promise?: Promise<string>;
};
export type RetimedSubtitleCommandRunner = (
alassPath: string,
referencePath: string,
inputPath: string,
outputPath: string,
) => Promise<CommandResult>;
export type RetimedSecondarySubtitleInput = {
sourcePath: string;
startMs: number;
endMs: number;
languages?: readonly string[];
primaryLanguages?: readonly string[];
alassPath?: string | null;
runAlass?: RetimedSubtitleCommandRunner;
};
const retimedSubtitleCache = new Map<string, RetimedSubtitleCacheEntry>();
let retimedSubtitleCleanupRegistered = false;
function unique(values: string[]): string[] {
return values.filter((value, index) => value.length > 0 && values.indexOf(value) === index);
}
function expandPreferredLanguages(
languages: readonly string[] | undefined,
fallback: readonly string[],
): string[] {
const normalized = unique(
(languages ?? []).map((language) => normalizeYoutubeLangCode(language)).filter(Boolean),
);
const base = normalized.length > 0 ? normalized : [...fallback];
const expanded: string[] = [];
for (const language of base) {
expanded.push(language);
if (isEnglishYoutubeLang(language)) {
expanded.push(...DEFAULT_SECONDARY_SUBTITLE_LANGUAGES);
}
}
return unique(expanded);
}
function isExecutableFile(filePath: string): boolean {
try {
return statSync(filePath).isFile();
} catch {
return false;
}
}
function pathEntries(): string[] {
const entries = (process.env.PATH ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
return unique([...entries, ...FALLBACK_ALASS_PATHS.map((candidate) => path.dirname(candidate))]);
}
function executableNames(name: string): string[] {
if (process.platform !== 'win32') return [name];
const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT')
.split(';')
.map((entry) => entry.trim())
.filter(Boolean);
if (path.extname(name)) return [name];
return [name, ...extensions.map((extension) => `${name}${extension}`)];
}
function findExecutable(names: readonly string[]): string {
for (const name of names) {
if (path.dirname(name) !== '.') {
return isExecutableFile(name) ? name : '';
}
}
for (const dir of pathEntries()) {
for (const name of names) {
for (const executableName of executableNames(name)) {
const candidate = path.join(dir, executableName);
if (isExecutableFile(candidate)) return candidate;
}
}
}
for (const candidate of FALLBACK_ALASS_PATHS) {
if (isExecutableFile(candidate)) return candidate;
}
return '';
}
function resolveAlassPath(configuredPath: string | null | undefined): string {
const trimmed = configuredPath?.trim() ?? '';
if (trimmed) {
return findExecutable([trimmed]);
}
return findExecutable(['alass', 'alass-cli']);
}
function fileSignature(filePath: string): string | null {
try {
const stats = statSync(filePath);
if (!stats.isFile()) return null;
return `${stats.size}:${stats.mtimeMs}`;
} catch {
return null;
}
}
function retimedCacheKey(
alassPath: string,
primaryPath: string,
secondaryPath: string,
): string | null {
const primarySignature = fileSignature(primaryPath);
const secondarySignature = fileSignature(secondaryPath);
if (!primarySignature || !secondarySignature) return null;
return [alassPath, primaryPath, primarySignature, secondaryPath, secondarySignature].join('\0');
}
function cleanupRetimedSubtitleCache(): void {
for (const entry of retimedSubtitleCache.values()) {
try {
rmSync(entry.cleanupDir, { recursive: true, force: true });
} catch {
// Best-effort temp cleanup.
}
}
retimedSubtitleCache.clear();
}
function registerRetimedSubtitleCleanup(): void {
if (retimedSubtitleCleanupRegistered) return;
retimedSubtitleCleanupRegistered = true;
process.once('exit', cleanupRetimedSubtitleCache);
}
export function clearRetimedSecondarySubtitleCache(): void {
cleanupRetimedSubtitleCache();
}
function splitLanguageSuffix(value: string): string[] {
const normalizedWhole = normalizeYoutubeLangCode(value);
const tokens = value
.split(/[^A-Za-z0-9-]+/g)
.map((token) => normalizeYoutubeLangCode(token))
.filter(Boolean);
return unique([normalizedWhole, ...tokens]);
}
function languageTokenMatches(token: string, preferredLanguage: string): boolean {
if (token === preferredLanguage) {
return true;
}
if (token.startsWith(`${preferredLanguage}-`) || preferredLanguage.startsWith(`${token}-`)) {
return true;
}
return isEnglishYoutubeLang(token) && isEnglishYoutubeLang(preferredLanguage);
}
function resolveLanguageRank(suffix: string, preferredLanguages: string[]): number {
const tokens = splitLanguageSuffix(suffix);
for (let index = 0; index < preferredLanguages.length; index += 1) {
const preferredLanguage = preferredLanguages[index]!;
if (tokens.some((token) => languageTokenMatches(token, preferredLanguage))) {
return index;
}
}
return Number.POSITIVE_INFINITY;
}
function extensionRank(ext: string): number {
if (ext === '.srt') return 0;
if (ext === '.vtt') return 1;
if (ext === '.ass') return 2;
if (ext === '.ssa') return 3;
return 4;
}
function findSidecarSubtitleCandidates(
sourcePath: string,
preferredLanguages: string[],
): SidecarCandidate[] {
const source = path.parse(sourcePath);
let entries: string[];
try {
entries = readdirSync(source.dir);
} catch {
return [];
}
const prefix = `${source.name}.`;
return entries
.map((entry) => {
const parsed = path.parse(entry);
const ext = parsed.ext.toLowerCase();
if (!SUPPORTED_SUBTITLE_EXTENSIONS.has(ext) || !parsed.name.startsWith(prefix)) {
return null;
}
const suffix = parsed.name.slice(prefix.length);
const languageRank = resolveLanguageRank(suffix, preferredLanguages);
if (!Number.isFinite(languageRank)) {
return null;
}
return {
path: path.join(source.dir, entry),
languageRank,
extensionRank: extensionRank(ext),
name: entry,
};
})
.filter((candidate): candidate is SidecarCandidate => candidate !== null)
.sort((left, right) => {
if (left.languageRank !== right.languageRank) return left.languageRank - right.languageRank;
if (left.extensionRank !== right.extensionRank)
return left.extensionRank - right.extensionRank;
return left.name.localeCompare(right.name);
});
}
function combineCueText(cues: SubtitleCue[]): string {
return unique(cues.map((cue) => cue.text.trim()).filter(Boolean))
.join('\n')
.trim();
}
function overlapSeconds(cue: SubtitleCue, startSeconds: number, endSeconds: number): number {
return (
Math.min(cue.endTime, endSeconds + TIMING_TOLERANCE_SECONDS) -
Math.max(cue.startTime, startSeconds - TIMING_TOLERANCE_SECONDS)
);
}
function isSameCueTiming(left: SubtitleCue, right: SubtitleCue): boolean {
return (
Math.abs(left.startTime - right.startTime) <= SAME_TIMING_EPSILON_SECONDS &&
Math.abs(left.endTime - right.endTime) <= SAME_TIMING_EPSILON_SECONDS
);
}
function compareCueTimingMatch(
startSeconds: number,
endSeconds: number,
left: { cue: SubtitleCue; overlap: number },
right: { cue: SubtitleCue; overlap: number },
): number {
if (left.overlap !== right.overlap) {
return right.overlap - left.overlap;
}
const leftStartDistance = Math.abs(left.cue.startTime - startSeconds);
const rightStartDistance = Math.abs(right.cue.startTime - startSeconds);
if (leftStartDistance !== rightStartDistance) {
return leftStartDistance - rightStartDistance;
}
const leftEndDistance = Math.abs(left.cue.endTime - endSeconds);
const rightEndDistance = Math.abs(right.cue.endTime - endSeconds);
if (leftEndDistance !== rightEndDistance) {
return leftEndDistance - rightEndDistance;
}
return left.cue.startTime - right.cue.startTime;
}
function findCueTextAtTiming(cues: SubtitleCue[], startMs: number, endMs: number): string {
const startSeconds = startMs / 1000;
const endSeconds = endMs / 1000;
const midpointSeconds = (startSeconds + endSeconds) / 2;
const midpointMatches = cues
.filter(
(cue) =>
cue.startTime - TIMING_TOLERANCE_SECONDS <= midpointSeconds &&
cue.endTime + TIMING_TOLERANCE_SECONDS >= midpointSeconds,
)
.map((cue) => ({ cue, overlap: overlapSeconds(cue, startSeconds, endSeconds) }))
.sort((left, right) => compareCueTimingMatch(startSeconds, endSeconds, left, right));
const [bestMidpointMatch] = midpointMatches;
const midpointText = bestMidpointMatch
? combineCueText(
midpointMatches
.filter((match) => isSameCueTiming(match.cue, bestMidpointMatch.cue))
.map((match) => match.cue),
)
: '';
if (midpointText) {
return midpointText;
}
const [bestOverlap] = cues
.map((cue) => ({ cue, overlap: overlapSeconds(cue, startSeconds, endSeconds) }))
.filter((entry) => entry.overlap > 0)
.sort((left, right) => compareCueTimingMatch(startSeconds, endSeconds, left, right));
return bestOverlap ? bestOverlap.cue.text.trim() : '';
}
function readCueTextAtTiming(filePath: string, startMs: number, endMs: number): string {
const content = readFileSync(filePath, 'utf8');
const cues = parseSubtitleCues(content, filePath);
return findCueTextAtTiming(cues, startMs, endMs);
}
async function defaultRunAlass(
alassPath: string,
referencePath: string,
inputPath: string,
outputPath: string,
): Promise<CommandResult> {
return runCommand(alassPath, [referencePath, inputPath, outputPath], RETIMED_SUBTITLE_TIMEOUT_MS);
}
async function retimeSecondarySubtitle(input: {
alassPath: string;
primaryPath: string;
secondaryPath: string;
runAlass: RetimedSubtitleCommandRunner;
}): Promise<string> {
const key = retimedCacheKey(input.alassPath, input.primaryPath, input.secondaryPath);
if (!key) return '';
const cached = retimedSubtitleCache.get(key);
if (cached?.promise) {
return cached.promise;
}
if (cached && existsSync(cached.path)) {
return cached.path;
}
if (cached) {
retimedSubtitleCache.delete(key);
try {
rmSync(cached.cleanupDir, { recursive: true, force: true });
} catch {}
}
registerRetimedSubtitleCleanup();
const cleanupDir = mkdtempSync(path.join(os.tmpdir(), 'subminer-retimed-secondary-'));
const parsedSecondary = path.parse(input.secondaryPath);
const outputPath = path.join(
cleanupDir,
`${parsedSecondary.name}.retimed${parsedSecondary.ext || '.srt'}`,
);
const entry: RetimedSubtitleCacheEntry = { path: outputPath, cleanupDir };
entry.promise = input
.runAlass(input.alassPath, input.primaryPath, input.secondaryPath, outputPath)
.then((result) => {
if (!result.ok || !existsSync(outputPath)) {
rmSync(cleanupDir, { recursive: true, force: true });
retimedSubtitleCache.delete(key);
return '';
}
entry.promise = undefined;
return outputPath;
})
.catch(() => {
rmSync(cleanupDir, { recursive: true, force: true });
retimedSubtitleCache.delete(key);
return '';
});
retimedSubtitleCache.set(key, entry);
return entry.promise;
}
export function resolveSecondarySubtitleTextFromSidecar(input: {
sourcePath: string;
startMs: number;
endMs: number;
languages?: readonly string[];
}): string {
if (!input.sourcePath || !existsSync(input.sourcePath)) {
return '';
}
try {
if (!statSync(input.sourcePath).isFile()) {
return '';
}
} catch {
return '';
}
const preferredLanguages = expandPreferredLanguages(
input.languages,
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
);
const candidates = findSidecarSubtitleCandidates(input.sourcePath, preferredLanguages);
for (const candidate of candidates) {
try {
const text = readCueTextAtTiming(candidate.path, input.startMs, input.endMs);
if (text) {
return text;
}
} catch {
// Try the next matching sidecar.
}
}
return '';
}
export async function resolveRetimedSecondarySubtitleTextFromSidecar(
input: RetimedSecondarySubtitleInput,
): Promise<string> {
if (!input.sourcePath || !existsSync(input.sourcePath)) {
return '';
}
try {
if (!statSync(input.sourcePath).isFile()) {
return '';
}
} catch {
return '';
}
const alassPath = resolveAlassPath(input.alassPath);
if (!alassPath) return '';
const primaryLanguages = expandPreferredLanguages(
input.primaryLanguages,
DEFAULT_PRIMARY_SUBTITLE_LANGUAGES,
);
const secondaryLanguages = expandPreferredLanguages(
input.languages,
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
);
const primaryCandidates = findSidecarSubtitleCandidates(input.sourcePath, primaryLanguages);
const secondaryCandidates = findSidecarSubtitleCandidates(input.sourcePath, secondaryLanguages);
const runAlass = input.runAlass ?? defaultRunAlass;
for (const primary of primaryCandidates) {
for (const secondary of secondaryCandidates) {
if (primary.path === secondary.path) continue;
try {
const retimedPath = await retimeSecondarySubtitle({
alassPath,
primaryPath: primary.path,
secondaryPath: secondary.path,
runAlass,
});
if (!retimedPath) continue;
const text = readCueTextAtTiming(retimedPath, input.startMs, input.endMs);
if (text) return text;
} catch {
// Try the next sidecar pair.
}
}
}
return '';
}
+487 -56
View File
@@ -1,5 +1,6 @@
import { Hono } from 'hono';
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import { splitSentenceSearchTerms } from './immersion-tracker/query-lexical.js';
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import { basename, extname, resolve, sep } from 'node:path';
import { readFileSync, existsSync, statSync } from 'node:fs';
@@ -7,6 +8,7 @@ import { Readable } from 'node:stream';
import { MediaGenerator } from '../../media-generator.js';
import { AnkiConnectClient } from '../../anki-connect.js';
import type { AnkiConnectConfig } from '../../types.js';
import { createLogger } from '../../logger.js';
import {
getConfiguredSentenceFieldName,
getConfiguredTranslationFieldName,
@@ -15,18 +17,50 @@ import {
} from '../../anki-field-config.js';
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
import {
resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar,
type RetimedSecondarySubtitleInput,
} from './secondary-subtitle-sidecar.js';
type StatsServerNoteInfo = {
noteId: number;
fields: Record<string, { value: string }>;
};
type StatsServerMediaGenerator = {
generateAudio: (...args: Parameters<MediaGenerator['generateAudio']>) => Promise<Buffer | null>;
generateScreenshot: (
...args: Parameters<MediaGenerator['generateScreenshot']>
) => Promise<Buffer | null>;
generateAnimatedImage: (
...args: Parameters<MediaGenerator['generateAnimatedImage']>
) => Promise<Buffer | null>;
};
export type StatsMiningTimingEvent = {
mode: 'word' | 'sentence' | 'audio';
phase: string;
elapsedMs: number;
noteId?: number;
};
type StatsExcludedWordPayload = {
headword: string;
word: string;
reading: string;
};
type StatsCoverImagePayload = {
contentType: string;
dataUrl: string;
} | null;
type StatsCoverBatchBody = {
animeIds?: unknown;
videoIds?: unknown;
};
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
if (raw === undefined) return fallback;
const n = Number(raw);
@@ -73,6 +107,62 @@ function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | nul
return words;
}
function parsePositiveIdList(raw: unknown, maxItems = 100): number[] {
if (!Array.isArray(raw)) return [];
const ids = new Set<number>();
for (const rawId of raw) {
const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN;
if (Number.isFinite(id) && id > 0) {
ids.add(Math.floor(id));
if (ids.size >= maxItems) break;
}
}
return Array.from(ids).sort((a, b) => a - b);
}
function coverImagePayload(
art: { coverBlob?: Uint8Array | null } | null | undefined,
): StatsCoverImagePayload {
if (!art?.coverBlob) return null;
const bytes = new Uint8Array(art.coverBlob);
const contentType = detectImageContentType(bytes);
return {
contentType,
dataUrl: `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`,
};
}
function detectImageContentType(bytes: Uint8Array): string {
if (
bytes.length >= 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47
) {
return 'image/png';
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg';
}
if (
bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
return 'image/webp';
}
return 'application/octet-stream';
}
function resolveStatsNoteFieldName(
noteInfo: StatsServerNoteInfo,
...preferredNames: (string | undefined)[]
@@ -87,6 +177,57 @@ function resolveStatsNoteFieldName(
return null;
}
function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const fieldName of fieldNames) {
const normalized = fieldName?.trim();
if (!normalized) continue;
const key = normalized.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(normalized);
}
return result;
}
function getStatsWordMiningAudioFieldName(
ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null,
): string {
return (
(noteInfo
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', ankiConfig.fields?.audio)
: null) ??
ankiConfig.fields?.audio ??
'ExpressionAudio'
);
}
function getStatsDirectMiningAudioFieldNames(
ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null,
mode: 'sentence' | 'audio',
): string[] {
const configuredAudioField = ankiConfig.fields?.audio ?? 'ExpressionAudio';
if (!ankiConfig.isLapis?.enabled && !ankiConfig.isKiku?.enabled) {
return [configuredAudioField];
}
const sentenceAudioField = noteInfo
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', configuredAudioField)
: 'SentenceAudio';
const expressionAudioField = noteInfo
? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
: configuredAudioField;
if (mode === 'sentence') {
return uniqueFieldNames(sentenceAudioField);
}
return uniqueFieldNames(sentenceAudioField, expressionAudioField);
}
function toFetchHeaders(headers: IncomingMessage['headers']): Headers {
const fetchHeaders = new Headers();
for (const [name, value] of Object.entries(headers)) {
@@ -256,9 +397,19 @@ export interface StatsServerConfig {
knownWordCachePath?: string;
mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
secondarySubtitleLanguages?: string[];
getSecondarySubtitleLanguages?: () => string[] | undefined;
statsMiningAlassPath?: string;
getStatsMiningAlassPath?: () => string | null | undefined;
resolveRetimedSecondarySubtitleText?: (
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
}
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
@@ -279,6 +430,52 @@ const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
'.woff2': 'font/woff2',
};
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
const statsMiningLogger = createLogger('stats:mining');
function defaultNowMs(): number {
return Date.now();
}
function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
if (raw === undefined) return fallback;
const normalized = raw.trim().toLowerCase();
if (!normalized) return fallback;
return !['0', 'false', 'no', 'off'].includes(normalized);
}
function uniqueNonEmptyStrings(values: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = value.trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
async function buildSentenceSearchOptions(
query: string,
searchByHeadword: boolean,
resolveSentenceSearchHeadwords: ((term: string) => Promise<string[]> | string[]) | undefined,
): Promise<{ headwordTerms: Array<{ term: string; headwords: string[] }> } | undefined> {
if (!searchByHeadword) return undefined;
const terms = splitSentenceSearchTerms(query);
const headwordTerms: Array<{ term: string; headwords: string[] }> = [];
for (const term of terms) {
const resolved = resolveSentenceSearchHeadwords
? await resolveSentenceSearchHeadwords(term)
: [term];
const headwords = uniqueNonEmptyStrings(resolved);
if (headwords.length > 0) {
headwordTerms.push({ term, headwords });
}
}
return headwordTerms.length > 0 ? { headwordTerms } : undefined;
}
function buildAnkiNotePreview(
fields: Record<string, { value: string }>,
@@ -340,12 +537,81 @@ export function createStatsApp(
knownWordCachePath?: string;
mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
secondarySubtitleLanguages?: string[];
getSecondarySubtitleLanguages?: () => string[] | undefined;
statsMiningAlassPath?: string;
getStatsMiningAlassPath?: () => string | null | undefined;
resolveRetimedSecondarySubtitleText?: (
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
createMediaGenerator?: () => StatsServerMediaGenerator;
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
nowMs?: () => number;
},
) {
const app = new Hono();
const nowMs = options?.nowMs ?? defaultNowMs;
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
const getSecondarySubtitleLanguages = (): string[] =>
options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? [];
const getStatsMiningAlassPath = (): string | null | undefined =>
options?.getStatsMiningAlassPath?.() ?? options?.statsMiningAlassPath;
const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise<string> => {
const configuredDeckName = ankiConfig.deck?.trim() ?? '';
if (configuredDeckName) return configuredDeckName;
try {
const yomitanDeckName = await options?.getYomitanAnkiDeckName?.();
return typeof yomitanDeckName === 'string' ? yomitanDeckName.trim() : '';
} catch (error) {
statsMiningLogger.warn(
'Failed to resolve Yomitan Anki deck for stats mining:',
error instanceof Error ? error.message : String(error),
);
return '';
}
};
const recordMiningTiming = (event: StatsMiningTimingEvent): void => {
options?.onMiningTiming?.(event);
statsMiningLogger.debug(
`[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`,
event,
);
};
const timeMiningPhase = async <T>(
mode: StatsMiningTimingEvent['mode'],
phase: string,
fn: () => Promise<T>,
details?: (value: T) => Partial<StatsMiningTimingEvent>,
): Promise<T> => {
const startedAtMs = nowMs();
try {
const value = await fn();
recordMiningTiming({
mode,
phase,
elapsedMs: nowMs() - startedAtMs,
...details?.(value),
});
return value;
} catch (err) {
recordMiningTiming({
mode,
phase,
elapsedMs: nowMs() - startedAtMs,
});
throw err;
}
};
app.get('/api/stats/overview', async (c) => {
const [rawSessions, rollups, hints] = await Promise.all([
@@ -509,6 +775,20 @@ export function createStatsApp(
return c.json(occurrences);
});
app.get('/api/stats/sentences/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json([]);
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
const searchOptions = await buildSentenceSearchOptions(
query,
searchByHeadword,
options?.resolveSentenceSearchHeadwords,
);
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
return c.json(rows);
});
app.get('/api/stats/kanji', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const kanji = await tracker.getKanjiStats(limit);
@@ -707,14 +987,36 @@ export function createStatsApp(
return c.json({ ok: true });
});
app.post('/api/stats/covers', async (c) => {
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
const animeIds = parsePositiveIdList(body?.animeIds);
const videoIds = parsePositiveIdList(body?.videoIds);
const anime: Record<number, StatsCoverImagePayload> = {};
const media: Record<number, StatsCoverImagePayload> = {};
await Promise.all(
animeIds.map(async (animeId) => {
anime[animeId] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
}),
);
await Promise.all(
videoIds.map(async (videoId) => {
media[videoId] = coverImagePayload(await tracker.getCoverArt(videoId));
}),
);
return c.json({ anime, media });
});
app.get('/api/stats/anime/:animeId/cover', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 404);
const art = await tracker.getAnimeCoverArt(animeId);
if (!art?.coverBlob) return c.body(null, 404);
return new Response(new Uint8Array(art.coverBlob), {
const bytes = new Uint8Array(art.coverBlob);
return new Response(bytes, {
headers: {
'Content-Type': 'image/jpeg',
'Content-Type': detectImageContentType(bytes),
'Cache-Control': 'public, max-age=86400',
},
});
@@ -729,9 +1031,10 @@ export function createStatsApp(
art = await tracker.getCoverArt(videoId);
}
if (!art?.coverBlob) return c.body(null, 404);
return new Response(new Uint8Array(art.coverBlob), {
const bytes = new Uint8Array(art.coverBlob);
return new Response(bytes, {
headers: {
'Content-Type': 'image/jpeg',
'Content-Type': detectImageContentType(bytes),
'Cache-Control': 'public, max-age=604800',
},
});
@@ -754,8 +1057,9 @@ export function createStatsApp(
app.post('/api/stats/anki/browse', async (c) => {
const noteId = parseIntQuery(c.req.query('noteId'), 0);
if (noteId <= 0) return c.body(null, 400);
const ankiConfig = getAnkiConnectConfig();
try {
const response = await fetch('http://127.0.0.1:8765', {
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
@@ -791,7 +1095,8 @@ export function createStatsApp(
),
);
try {
const response = await fetch('http://127.0.0.1:8765', {
const ankiConfig = getAnkiConnectConfig();
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
@@ -807,7 +1112,7 @@ export function createStatsApp(
return c.json(
(result.result ?? []).map((note) => ({
...note,
preview: buildAnkiNotePreview(note.fields, options?.ankiConnectConfig),
preview: buildAnkiNotePreview(note.fields, ankiConfig),
})),
);
} catch {
@@ -822,7 +1127,8 @@ export function createStatsApp(
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
const word = typeof body?.word === 'string' ? body.word.trim() : '';
const secondaryText = typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
const bodySecondaryText =
typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
const rawMode = c.req.query('mode');
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
@@ -830,18 +1136,51 @@ export function createStatsApp(
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400);
}
if (endMs <= startMs) {
return c.json({ error: 'endMs must be greater than startMs' }, 400);
}
if (!existsSync(sourcePath)) {
return c.json({ error: 'File not found' }, 404);
}
const ankiConfig = options?.ankiConnectConfig;
const ankiConfig = getAnkiConnectConfig();
if (!ankiConfig) {
return c.json({ error: 'AnkiConnect is not configured' }, 500);
}
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
let retimedSecondaryText = '';
if (mode === 'sentence' && !bodySecondaryText) {
try {
retimedSecondaryText = await (
options?.resolveRetimedSecondarySubtitleText ??
resolveRetimedSecondarySubtitleTextFromSidecar
)({
sourcePath,
startMs,
endMs,
languages: secondarySubtitleLanguages,
alassPath: getStatsMiningAlassPath(),
});
} catch (error) {
statsMiningLogger.warn(
'Failed to resolve retimed secondary subtitle for stats mining:',
error instanceof Error ? error.message : String(error),
);
}
}
const secondaryText =
bodySecondaryText ||
retimedSecondaryText ||
resolveSecondarySubtitleTextFromSidecar({
sourcePath,
startMs,
endMs,
languages: secondarySubtitleLanguages,
});
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
const mediaGen = new MediaGenerator();
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
@@ -865,7 +1204,9 @@ export function createStatsApp(
imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false;
const audioPromise = generateAudio
? mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding)
? timeMiningPhase(mode, 'generateAudio', () =>
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
)
: Promise.resolve(null);
const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => {
@@ -874,22 +1215,26 @@ export function createStatsApp(
}
if (imageType === 'avif') {
return mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
fps: ankiConfig.media?.animatedFps ?? 10,
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
maxHeight: ankiConfig.media?.animatedMaxHeight,
crf: ankiConfig.media?.animatedCrf ?? 35,
leadingStillDuration: animatedLeadInSeconds,
});
return timeMiningPhase(mode, 'generateAnimatedImage', () =>
mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
fps: ankiConfig.media?.animatedFps ?? 10,
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
maxHeight: ankiConfig.media?.animatedMaxHeight,
crf: ankiConfig.media?.animatedCrf ?? 35,
leadingStillDuration: animatedLeadInSeconds,
}),
);
}
const midpointSec = (startSec + clampedEndSec) / 2;
return mediaGen.generateScreenshot(sourcePath, midpointSec, {
format: ankiConfig.media?.imageFormat ?? 'jpg',
quality: ankiConfig.media?.imageQuality ?? 92,
maxWidth: ankiConfig.media?.imageMaxWidth,
maxHeight: ankiConfig.media?.imageMaxHeight,
});
return timeMiningPhase(mode, 'generateScreenshot', () =>
mediaGen.generateScreenshot(sourcePath, midpointSec, {
format: ankiConfig.media?.imageFormat ?? 'jpg',
quality: ankiConfig.media?.imageQuality ?? 92,
maxWidth: ankiConfig.media?.imageMaxWidth,
maxHeight: ankiConfig.media?.imageMaxHeight,
}),
);
};
const imagePromise =
@@ -899,6 +1244,25 @@ export function createStatsApp(
const errors: string[] = [];
let noteId: number;
let effectiveDeckNamePromise: Promise<string> | null = null;
const getEffectiveDeckNameForRequest = (): Promise<string> => {
effectiveDeckNamePromise ??= getEffectiveMiningDeckName(ankiConfig);
return effectiveDeckNamePromise;
};
const moveNoteToConfiguredDeck = async (id: number): Promise<void> => {
const deckName = await getEffectiveDeckNameForRequest();
if (!deckName) {
return;
}
try {
const cardIds = await timeMiningPhase(mode, 'findCards', () =>
client.findCards(`nid:${id}`),
);
await timeMiningPhase(mode, 'changeDeck', () => client.changeDeck(cardIds, deckName));
} catch (err) {
errors.push(`deck: ${(err as Error).message}`);
}
};
if (mode === 'word') {
if (!options?.addYomitanNote) {
@@ -906,7 +1270,12 @@ export function createStatsApp(
}
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
options.addYomitanNote(word),
timeMiningPhase(
'word',
'addYomitanNote',
() => options.addYomitanNote!(word),
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
),
audioPromise,
imagePromise,
]);
@@ -921,6 +1290,7 @@ export function createStatsApp(
}
noteId = yomitanResult.value;
await moveNoteToConfiguredDeck(noteId);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`);
@@ -928,10 +1298,19 @@ export function createStatsApp(
errors.push(`image: ${(imageResult.reason as Error).message}`);
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (syncAnimatedImageToWordAudio && generateImage) {
let noteInfo: StatsServerNoteInfo | null = null;
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) {
try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
const noteInfo = noteInfoResult[0] ?? null;
noteInfo = noteInfoResult[0] ?? null;
} catch (err) {
if (syncAnimatedImageToWordAudio && generateImage) {
errors.push(`image: ${(err as Error).message}`);
}
}
}
if (syncAnimatedImageToWordAudio && generateImage) {
try {
const animatedLeadInSeconds = noteInfo
? await resolveAnimatedImageLeadInSeconds({
config: ankiConfig,
@@ -946,22 +1325,27 @@ export function createStatsApp(
errors.push(`image: ${(err as Error).message}`);
}
}
if (generateAudio && !audioBuffer && audioResult.status === 'fulfilled') {
errors.push('audio: no audio generated');
}
if (generateImage && !imageBuffer) {
errors.push('image: no image generated');
}
const mediaFields: Record<string, string> = {};
const timestamp = Date.now();
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo);
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
mediaFields[sentenceFieldName] = highlightedSentence;
if (secondaryText) {
mediaFields[ankiConfig.fields?.translation ?? 'SelectionText'] = secondaryText;
}
if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}.mp3`;
try {
await client.storeMediaFile(audioFilename, audioBuffer);
await timeMiningPhase('word', 'uploadAudio', () =>
client.storeMediaFile(audioFilename, audioBuffer),
);
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
} catch (err) {
errors.push(`audio upload: ${(err as Error).message}`);
@@ -972,7 +1356,9 @@ export function createStatsApp(
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
try {
await client.storeMediaFile(imageFilename, imageBuffer);
await timeMiningPhase('word', 'uploadImage', () =>
client.storeMediaFile(imageFilename, imageBuffer),
);
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
} catch (err) {
errors.push(`image upload: ${(err as Error).message}`);
@@ -1000,7 +1386,9 @@ export function createStatsApp(
if (Object.keys(mediaFields).length > 0) {
try {
await client.updateNoteFields(noteId, mediaFields);
await timeMiningPhase('word', 'updateNoteFields', () =>
client.updateNoteFields(noteId, mediaFields),
);
} catch (err) {
errors.push(`update fields: ${(err as Error).message}`);
}
@@ -1009,32 +1397,24 @@ export function createStatsApp(
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
}
const [audioResult, imageResult] = await Promise.allSettled([audioPromise, imagePromise]);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
const fields: Record<string, string> = {
[sentenceFieldName]: highlightedSentence,
[sentenceFieldName]: mode === 'sentence' ? sentence : highlightedSentence,
};
if (secondaryText) {
if (mode === 'sentence' && secondaryText) {
fields[translationFieldName] = secondaryText;
}
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
if (word) {
if (mode === 'sentence') {
fields[wordFieldName] = sentence;
} else if (word) {
fields[wordFieldName] = word;
}
if (mode === 'sentence') {
@@ -1045,23 +1425,62 @@ export function createStatsApp(
}
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
const deck = ankiConfig.deck ?? 'Default';
const tags = ankiConfig.tags ?? ['SubMiner'];
try {
noteId = await client.addNote(deck, model, fields, tags);
} catch (err) {
return c.json({ error: `Failed to add note: ${(err as Error).message}` }, 502);
const addNotePromise = timeMiningPhase(
mode,
'addNote',
async () =>
client.addNote((await getEffectiveDeckNameForRequest()) || 'Default', model, fields, tags),
(id) => ({
noteId: id,
}),
);
const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([
audioPromise,
imagePromise,
addNotePromise,
]);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
if (addNoteResult.status === 'rejected') {
return c.json(
{ error: `Failed to add note: ${(addNoteResult.reason as Error).message}` },
502,
);
}
noteId = addNoteResult.value;
await moveNoteToConfiguredDeck(noteId);
const mediaFields: Record<string, string> = {};
const timestamp = Date.now();
let noteInfo: StatsServerNoteInfo | null = null;
if (audioBuffer) {
try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
noteInfo = noteInfoResult[0] ?? null;
} catch {
noteInfo = null;
}
}
if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}.mp3`;
try {
await client.storeMediaFile(audioFilename, audioBuffer);
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
await timeMiningPhase(mode, 'uploadAudio', () =>
client.storeMediaFile(audioFilename, audioBuffer),
);
const audioValue = `[sound:${audioFilename}]`;
for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo, mode)) {
mediaFields[fieldName] = audioValue;
}
} catch (err) {
errors.push(`audio upload: ${(err as Error).message}`);
}
@@ -1071,7 +1490,9 @@ export function createStatsApp(
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
try {
await client.storeMediaFile(imageFilename, imageBuffer);
await timeMiningPhase(mode, 'uploadImage', () =>
client.storeMediaFile(imageFilename, imageBuffer),
);
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
} catch (err) {
errors.push(`image upload: ${(err as Error).message}`);
@@ -1099,7 +1520,9 @@ export function createStatsApp(
if (Object.keys(mediaFields).length > 0) {
try {
await client.updateNoteFields(noteId, mediaFields);
await timeMiningPhase(mode, 'updateNoteFields', () =>
client.updateNoteFields(noteId, mediaFields),
);
} catch (err) {
errors.push(`update fields: ${(err as Error).message}`);
}
@@ -1139,9 +1562,17 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
knownWordCachePath: config.knownWordCachePath,
mpvSocketPath: config.mpvSocketPath,
ankiConnectConfig: config.ankiConnectConfig,
getAnkiConnectConfig: config.getAnkiConnectConfig,
getYomitanAnkiDeckName: config.getYomitanAnkiDeckName,
secondarySubtitleLanguages: config.secondarySubtitleLanguages,
getSecondarySubtitleLanguages: config.getSecondarySubtitleLanguages,
statsMiningAlassPath: config.statsMiningAlassPath,
getStatsMiningAlassPath: config.getStatsMiningAlassPath,
resolveRetimedSecondarySubtitleText: config.resolveRetimedSecondarySubtitleText,
anilistRateLimiter: config.anilistRateLimiter,
addYomitanNote: config.addYomitanNote,
resolveAnkiNoteId: config.resolveAnkiNoteId,
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
});
const bunRuntime = globalThis as typeof globalThis & {
@@ -151,6 +151,56 @@ test('syncYomitanDefaultAnkiServer injects force override when enabled', async (
assert.match(scriptValue, /forceOverride = true/);
});
test('syncYomitanDefaultAnkiServer updates the active profile Anki deck', async () => {
const optionsFull = {
profileCurrent: 0,
profiles: [
{
options: {
anki: {
server: 'http://127.0.0.1:8766',
cardFormats: [
{ type: 'term', deck: 'Default', model: 'Mining Note', fields: {} },
{ type: 'kanji', deck: 'Kanji', model: 'Kanji Note', fields: {} },
],
terms: { deck: 'Default', model: 'Legacy Note', fields: {} },
},
},
},
],
};
let savedOptions: typeof optionsFull | null = null;
const deps = createDeps((script) =>
runInjectedYomitanScript(script, (action, params) => {
if (action === 'optionsGetFull') {
return JSON.parse(JSON.stringify(optionsFull));
}
if (action === 'setAllSettings') {
savedOptions = (params as { value: typeof optionsFull }).value;
return true;
}
throw new Error(`Unexpected action: ${action}`);
}),
);
const synced = await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
deps,
{
error: () => undefined,
info: () => undefined,
},
{ deck: 'Minecraft', forceOverride: true },
);
assert.equal(synced, true);
assert.ok(savedOptions);
const saved = savedOptions as typeof optionsFull;
assert.equal(saved.profiles[0]?.options.anki.cardFormats[0]?.deck, 'Minecraft');
assert.equal(saved.profiles[0]?.options.anki.cardFormats[1]?.deck, 'Kanji');
assert.equal(saved.profiles[0]?.options.anki.terms.deck, 'Minecraft');
});
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
const deps = createDeps(async () => {
throw new Error('execute failed');
@@ -1783,6 +1783,7 @@ export async function syncYomitanDefaultAnkiServer(
logger: LoggerLike,
options?: {
forceOverride?: boolean;
deck?: string;
},
): Promise<boolean> {
const normalizedTargetServer = serverUrl.trim();
@@ -1790,6 +1791,7 @@ export async function syncYomitanDefaultAnkiServer(
return false;
}
const forceOverride = options?.forceOverride === true;
const normalizedTargetDeck = options?.deck?.trim() ?? '';
const isReady = await ensureYomitanParserWindow(deps, logger);
const parserWindow = deps.getYomitanParserWindow();
@@ -1819,6 +1821,7 @@ export async function syncYomitanDefaultAnkiServer(
});
const targetServer = ${JSON.stringify(normalizedTargetServer)};
const targetDeck = ${JSON.stringify(normalizedTargetDeck)};
const forceOverride = ${forceOverride ? 'true' : 'false'};
const optionsFull = await invoke("optionsGetFull", undefined);
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
@@ -1843,18 +1846,54 @@ export async function syncYomitanDefaultAnkiServer(
const currentServerRaw = targetProfile.options.anki.server;
const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : "";
if (currentServer === targetServer) {
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer };
}
const canReplaceCurrent =
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
if (!canReplaceCurrent) {
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
let changed = false;
if (currentServer !== targetServer) {
const canReplaceCurrent =
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
if (!canReplaceCurrent) {
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
}
targetProfile.options.anki.server = targetServer;
changed = true;
}
if (targetDeck) {
const cardFormats = Array.isArray(targetProfile.options.anki.cardFormats)
? targetProfile.options.anki.cardFormats
: [];
for (const cardFormat of cardFormats) {
if (
!cardFormat ||
typeof cardFormat !== "object" ||
cardFormat.type !== "term" ||
cardFormat.enabled === false
) {
continue;
}
const currentDeck = typeof cardFormat.deck === "string" ? cardFormat.deck.trim() : "";
if (currentDeck !== targetDeck) {
cardFormat.deck = targetDeck;
changed = true;
}
}
const terms = targetProfile.options.anki.terms;
if (terms && typeof terms === "object") {
const currentTermDeck = typeof terms.deck === "string" ? terms.deck.trim() : "";
if (currentTermDeck !== targetDeck) {
terms.deck = targetDeck;
changed = true;
}
}
}
if (!changed) {
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer, targetDeck };
}
targetProfile.options.anki.server = targetServer;
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
return { updated: true, matched: true, currentServer, targetServer };
return { updated: true, matched: true, currentServer, targetServer, targetDeck };
})();
`;
+57 -18
View File
@@ -1833,6 +1833,31 @@ function getCurrentAutoplaySubtitlePayload(): SubtitleData | null {
return payload;
}
async function resolveSentenceSearchHeadwords(term: string): Promise<string[]> {
const fallback = term.trim() ? [term.trim()] : [];
try {
const tokenized = tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(term) : null;
const tokens = tokenized?.tokens ?? [];
if (tokens.length === 0) return fallback;
const seen = new Set<string>();
const headwords: string[] = [];
for (const token of tokens) {
const headword = (token.headword || token.surface).trim();
if (!headword || seen.has(headword)) continue;
seen.add(headword);
headwords.push(headword);
}
return headwords.length > 0 ? headwords : fallback;
} catch (error) {
logger.debug(
'Failed to resolve sentence-search headwords:',
error instanceof Error ? error.message : String(error),
);
return fallback;
}
}
function signalCurrentSubtitleAutoplayReady(): void {
autoplayReadyGate.flushPendingAutoplayReadySignal();
const payload = getCurrentAutoplaySubtitlePayload();
@@ -2240,6 +2265,18 @@ const configHotReloadRuntime = createConfigHotReloadRuntime(
buildConfigHotReloadRuntimeMainDepsHandler(),
);
async function getCurrentYomitanAnkiDeckNameForRuntime(): Promise<string> {
await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), {
error: (message, ...args) => {
logger.error(message, ...args);
},
info: (message, ...args) => {
logger.info(message, ...args);
},
});
}
const configSettingsRuntime = createConfigSettingsRuntime({
fields: configSettingsFields,
getConfigPath: () => configService.getConfigPath(),
@@ -2250,17 +2287,7 @@ const configSettingsRuntime = createConfigSettingsRuntime({
onHotReloadApplied: applyConfigHotReloadDiff,
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: (url) => new AnkiConnectClient(url),
getYomitanAnkiDeckName: async () => {
await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), {
error: (message, ...args) => {
logger.error(message, ...args);
},
info: (message, ...args) => {
logger.info(message, ...args);
},
});
},
getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime,
getSettingsWindow: () => appState.configSettingsWindow,
setSettingsWindow: (window) => {
appState.configSettingsWindow = window as BrowserWindow | null;
@@ -4441,14 +4468,20 @@ const startLocalStatsServer = (): void => {
tracker,
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
mpvSocketPath: appState.mpvSocketPath,
ankiConnectConfig: getResolvedConfig().ankiConnect,
getAnkiConnectConfig: () => getResolvedConfig().ankiConnect,
getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime,
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => getResolvedConfig().subsync.alass_path,
anilistRateLimiter,
resolveAnkiNoteId: (noteId: number) =>
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
resolveSentenceSearchHeadwords,
addYomitanNote: async (word: string) => {
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
const ankiConnectConfig = getResolvedConfig().ankiConnect;
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: true,
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
deck: ankiConnectConfig.deck,
});
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
if (result.noteId && result.duplicateNoteIds.length > 0) {
@@ -5640,7 +5673,7 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
return extension;
}
let lastSyncedYomitanAnkiServer: string | null = null;
let lastSyncedYomitanAnkiSettingsKey: string | null = null;
function getPreferredYomitanAnkiServerUrl(): string {
return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect);
@@ -5671,7 +5704,10 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
}
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
const ankiConnectConfig = getResolvedConfig().ankiConnect;
const targetDeck = ankiConnectConfig?.deck?.trim() ?? '';
const targetSettingsKey = `${targetUrl}\n${targetDeck}`;
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
return;
}
@@ -5687,12 +5723,15 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
},
},
{
forceOverride: shouldForceOverrideYomitanAnkiServer(getResolvedConfig().ankiConnect),
forceOverride: ankiConnectConfig
? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig)
: false,
deck: targetDeck,
},
);
if (synced) {
lastSyncedYomitanAnkiServer = targetUrl;
lastSyncedYomitanAnkiSettingsKey = targetSettingsKey;
}
}
+15
View File
@@ -237,6 +237,21 @@ test('warm tokenization release reuses current subtitle payload instead of synth
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
});
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
const source = readMainSource();
const startStatsServerBlock = source.match(
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
)?.groups?.body;
const addYomitanNoteBlock = startStatsServerBlock?.match(
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(addYomitanNoteBlock);
assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
});
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
const source = readMainSource();
const actionBlock = source.match(
@@ -1,6 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import { resolveConfig } from '../../config/resolve';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createConfigSettingsRuntime } from './config-settings-runtime';
@@ -10,7 +14,13 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async
fields: [],
getConfigPath: () => '/tmp/config.jsonc',
getRawConfig: () => ({}),
getConfig: () => deepCloneConfig(DEFAULT_CONFIG),
getConfig: () => ({
...deepCloneConfig(DEFAULT_CONFIG),
ankiConnect: {
...deepCloneConfig(DEFAULT_CONFIG).ankiConnect,
deck: 'Configured',
},
}),
getWarnings: () => [],
reloadConfigStrict: () =>
({
@@ -48,3 +58,62 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async
assert.ok(handler);
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Mining' });
});
test('config settings runtime persists inferred Yomitan Anki deck when config deck is empty', async () => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-settings-'));
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(configPath, '{"ankiConnect":{"deck":""}}\n', 'utf-8');
try {
let rawConfig = { ankiConnect: { deck: '' } };
let resolvedConfig = resolveConfig(rawConfig).resolved;
const runtime = createConfigSettingsRuntime({
fields: [],
getConfigPath: () => configPath,
getRawConfig: () => rawConfig,
getConfig: () => resolvedConfig,
getWarnings: () => [],
reloadConfigStrict: () => {
rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
resolvedConfig = resolveConfig(rawConfig).resolved;
return {
ok: true,
config: resolvedConfig,
warnings: [],
path: configPath,
};
},
getSettingsWindow: () => null,
setSettingsWindow: () => undefined,
createSettingsWindow: () => ({}) as never,
settingsHtmlPath: '/tmp/settings.html',
openPath: async () => '',
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: () =>
({
deckNames: async () => [],
fieldNamesForDeck: async () => [],
modelNamesForDeck: async () => [],
modelNames: async () => [],
modelFieldNames: async () => [],
}) as never,
getYomitanAnkiDeckName: async () => 'Minecraft',
ipcMain: {
handle: (channel, listener) => {
handlers.set(channel, listener);
},
},
ipcChannels: IPC_CHANNELS.request,
});
runtime.registerHandlers();
const handler = handlers.get(IPC_CHANNELS.request.getConfigSettingsYomitanAnkiDeckName);
assert.ok(handler);
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Minecraft' });
assert.equal(JSON.parse(fs.readFileSync(configPath, 'utf-8')).ankiConnect.deck, 'Minecraft');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+26 -1
View File
@@ -193,13 +193,38 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
};
}
function persistInferredYomitanDeckIfEmpty(deckName: string): void {
const normalizedDeckName = deckName.trim();
const configuredDeckName = deps.getConfig().ankiConnect?.deck?.trim() ?? '';
if (!normalizedDeckName || configuredDeckName) {
return;
}
const result = savePatch({
operations: [
{
op: 'set',
path: 'ankiConnect.deck',
value: normalizedDeckName,
},
],
});
if (!result.ok) {
deps.log?.(
`Failed to persist inferred Yomitan Anki deck: ${result.error ?? 'unknown error'}`,
);
}
}
async function getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult> {
if (!deps.getYomitanAnkiDeckName) {
return { ok: true, value: '' };
}
try {
const value = await deps.getYomitanAnkiDeckName();
return { ok: true, value: typeof value === 'string' ? value.trim() : '' };
const deckName = typeof value === 'string' ? value.trim() : '';
persistInferredYomitanDeckIfEmpty(deckName);
return { ok: true, value: deckName };
} catch (error) {
return {
ok: false,
@@ -159,6 +159,30 @@ test('mpv subtitle timing handler runs AniList without timing tracker and passes
assert.deepEqual(calls, ['immersion:line:899:901', 'post-watch:901']);
});
test('mpv subtitle timing handler skips invalid cue pairs until timing is complete', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: (text, start, end) =>
calls.push(`immersion:${text}:${start}:${end}`),
hasSubtitleTimingTracker: () => true,
recordSubtitleTiming: (text, start, end) => calls.push(`timing:${text}:${start}:${end}`),
maybeRunAnilistPostWatchUpdate: async (options) => {
calls.push(`post-watch:${options?.watchedSeconds}`);
},
logError: () => calls.push('error'),
});
handler({ text: 'line', start: 953.991, end: 953.891 });
handler({ text: 'line', start: 953.991, end: 956.56 });
assert.deepEqual(calls, [
'post-watch:953.991',
'immersion:line:953.991:956.56',
'timing:line:953.991:956.56',
'post-watch:956.56',
]);
});
test('mpv event bindings register all expected events', () => {
const seenEvents: string[] = [];
const bindHandlers = createBindMpvClientEventHandlers({
@@ -72,7 +72,7 @@ export function createHandleMpvSubtitleTimingHandler(deps: {
Number.isFinite(end) ? end : 0,
);
const options = watchedSeconds > 0 ? { watchedSeconds } : undefined;
if (text.trim()) {
if (text.trim() && Number.isFinite(start) && Number.isFinite(end) && end > start) {
deps.recordImmersionSubtitleLine(text, start, end);
if (deps.hasSubtitleTimingTracker()) {
deps.recordSubtitleTiming(text, start, end);
+6
View File
@@ -34,6 +34,12 @@ test('overlay preload buffers only latest subtitle state until renderer listener
assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/);
});
test('overlay preload does not expose the old mining image toast IPC path', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
assert.doesNotMatch(source, /MiningImagePayload|onMiningImage|IPC_CHANNELS\.event\.miningImage/);
});
test('overlay preload exposes queued pointer recovery requests', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
+25 -9
View File
@@ -15,6 +15,8 @@ import {
import { writeStatsCliCommandResponse } from './main/runtime/stats-cli-command';
import {
createInvokeStatsWordHelperHandler,
createReadStatsYomitanDeckNameHandler,
type StatsWordHelperSpawnOptions,
type StatsWordHelperResponse,
} from './stats-word-helper-client';
@@ -58,19 +60,22 @@ async function waitForWordHelperResponse(responsePath: string): Promise<StatsWor
};
}
const invokeStatsWordHelper = createInvokeStatsWordHelperHandler({
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
joinPath: (...parts) => path.join(...parts),
spawnHelper: async (options) => {
const statsWordHelperDeps = {
createTempDir: (prefix: string) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
joinPath: (...parts: string[]) => path.join(...parts),
spawnHelper: async (options: StatsWordHelperSpawnOptions) => {
const childArgs = [
options.scriptPath,
'--stats-word-helper-response-path',
options.responsePath,
'--stats-word-helper-user-data-path',
options.userDataPath,
'--stats-word-helper-word',
options.word,
];
if (options.mode === 'deck-name') {
childArgs.push('--stats-word-helper-read-deck');
} else {
childArgs.push('--stats-word-helper-word', options.word ?? '');
}
const logLevel = readFlagValue(process.argv, '--log-level');
if (logLevel) {
childArgs.push('--log-level', logLevel);
@@ -88,10 +93,13 @@ const invokeStatsWordHelper = createInvokeStatsWordHelperHandler({
});
},
waitForResponse: waitForWordHelperResponse,
removeDir: (targetPath) => {
removeDir: (targetPath: string) => {
fs.rmSync(targetPath, { recursive: true, force: true });
},
});
};
const invokeStatsWordHelper = createInvokeStatsWordHelperHandler(statsWordHelperDeps);
const readStatsYomitanDeckName = createReadStatsYomitanDeckNameHandler(statsWordHelperDeps);
const userDataPath = readFlagValue(process.argv, '--stats-user-data-path')?.trim();
const responsePath = readFlagValue(process.argv, '--stats-response-path')?.trim();
@@ -195,7 +203,15 @@ async function main(): Promise<void> {
staticDir: statsDistPath,
tracker,
knownWordCachePath,
ankiConnectConfig: config.ankiConnect,
getAnkiConnectConfig: () => configService.reloadConfig().ankiConnect,
getYomitanAnkiDeckName: async () =>
await readStatsYomitanDeckName({
helperScriptPath: wordHelperScriptPath,
userDataPath: daemonUserDataPath,
}),
getSecondarySubtitleLanguages: () =>
configService.reloadConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => configService.reloadConfig().subsync.alass_path,
addYomitanNote: async (word: string) =>
await invokeStatsWordHelper({
helperScriptPath: wordHelperScriptPath,
+37 -1
View File
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createInvokeStatsWordHelperHandler } from './stats-word-helper-client';
import {
createInvokeStatsWordHelperHandler,
createReadStatsYomitanDeckNameHandler,
} from './stats-word-helper-client';
test('word helper client returns note id when helper responds before exit', async () => {
const calls: string[] = [];
@@ -36,6 +39,39 @@ test('word helper client returns note id when helper responds before exit', asyn
]);
});
test('word helper client returns Yomitan deck name from helper read-deck mode', async () => {
const calls: string[] = [];
const handler = createReadStatsYomitanDeckNameHandler({
createTempDir: () => '/tmp/stats-word-helper',
joinPath: (...parts) => parts.join('/'),
spawnHelper: async (options) => {
calls.push(
`spawnHelper:${options.scriptPath}:${options.responsePath}:${options.userDataPath}:${options.mode}`,
);
return new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
},
waitForResponse: async (responsePath) => {
calls.push(`waitForResponse:${responsePath}`);
return { ok: true, deckName: ' Minecraft ' };
},
removeDir: (targetPath) => {
calls.push(`removeDir:${targetPath}`);
},
});
const deckName = await handler({
helperScriptPath: '/tmp/stats-word-helper.js',
userDataPath: '/tmp/SubMiner',
});
assert.equal(deckName, 'Minecraft');
assert.deepEqual(calls, [
'spawnHelper:/tmp/stats-word-helper.js:/tmp/stats-word-helper/response.json:/tmp/SubMiner:deck-name',
'waitForResponse:/tmp/stats-word-helper/response.json',
'removeDir:/tmp/stats-word-helper',
]);
});
test('word helper client throws helper response errors', async () => {
const handler = createInvokeStatsWordHelperHandler({
createTempDir: () => '/tmp/stats-word-helper',
+65 -6
View File
@@ -1,18 +1,24 @@
export type StatsWordHelperResponse = {
ok: boolean;
noteId?: number;
deckName?: string;
error?: string;
};
type StatsWordHelperMode = 'add-word' | 'deck-name';
export type StatsWordHelperSpawnOptions = {
scriptPath: string;
responsePath: string;
userDataPath: string;
mode: StatsWordHelperMode;
word?: string;
};
export function createInvokeStatsWordHelperHandler(deps: {
createTempDir: (prefix: string) => string;
joinPath: (...parts: string[]) => string;
spawnHelper: (options: {
scriptPath: string;
responsePath: string;
userDataPath: string;
word: string;
}) => Promise<number>;
spawnHelper: (options: StatsWordHelperSpawnOptions) => Promise<number>;
waitForResponse: (responsePath: string) => Promise<StatsWordHelperResponse>;
removeDir: (targetPath: string) => void;
}) {
@@ -29,6 +35,7 @@ export function createInvokeStatsWordHelperHandler(deps: {
scriptPath: options.helperScriptPath,
responsePath,
userDataPath: options.userDataPath,
mode: 'add-word',
word: options.word,
});
@@ -64,3 +71,55 @@ export function createInvokeStatsWordHelperHandler(deps: {
}
};
}
export function createReadStatsYomitanDeckNameHandler(deps: {
createTempDir: (prefix: string) => string;
joinPath: (...parts: string[]) => string;
spawnHelper: (options: StatsWordHelperSpawnOptions) => Promise<number>;
waitForResponse: (responsePath: string) => Promise<StatsWordHelperResponse>;
removeDir: (targetPath: string) => void;
}) {
return async (options: { helperScriptPath: string; userDataPath: string }): Promise<string> => {
const tempDir = deps.createTempDir('subminer-stats-word-helper-');
const responsePath = deps.joinPath(tempDir, 'response.json');
try {
const helperExitPromise = deps.spawnHelper({
scriptPath: options.helperScriptPath,
responsePath,
userDataPath: options.userDataPath,
mode: 'deck-name',
});
const startupResult = await Promise.race([
deps
.waitForResponse(responsePath)
.then((response) => ({ kind: 'response' as const, response })),
helperExitPromise.then((status) => ({ kind: 'exit' as const, status })),
]);
let response: StatsWordHelperResponse;
if (startupResult.kind === 'response') {
response = startupResult.response;
} else {
if (startupResult.status !== 0) {
throw new Error(
`Stats word helper exited before response (status ${startupResult.status}).`,
);
}
response = await deps.waitForResponse(responsePath);
}
const exitStatus = await helperExitPromise;
if (exitStatus !== 0) {
throw new Error(`Stats word helper exited with status ${exitStatus}.`);
}
if (!response.ok || typeof response.deckName !== 'string') {
throw new Error(response.error || 'Stats word helper failed.');
}
return response.deckName.trim();
} finally {
deps.removeDir(tempDir);
}
};
}
+34 -38
View File
@@ -7,6 +7,7 @@ import { createLogger, setLogLevel } from './logger';
import { loadYomitanExtension } from './core/services/yomitan-extension-loader';
import {
addYomitanNoteViaSearch,
getYomitanCurrentAnkiDeckName,
syncYomitanDefaultAnkiServer,
} from './core/services/tokenizer/yomitan-parser-runtime';
import type { StatsWordHelperResponse } from './stats-word-helper-client';
@@ -54,13 +55,14 @@ function writeResponse(responsePath: string | undefined, payload: StatsWordHelpe
const responsePath = readFlagValue(process.argv, '--stats-word-helper-response-path')?.trim();
const userDataPath = readFlagValue(process.argv, '--stats-word-helper-user-data-path')?.trim();
const word = readFlagValue(process.argv, '--stats-word-helper-word');
const readDeck = process.argv.includes('--stats-word-helper-read-deck');
const logLevel = readFlagValue(process.argv, '--log-level');
if (logLevel) {
setLogLevel(logLevel, 'cli');
}
if (!userDataPath || !word) {
if (!userDataPath || (!word && !readDeck)) {
writeResponse(responsePath, {
ok: false,
error: 'Missing stats word helper arguments.',
@@ -125,48 +127,42 @@ async function main(): Promise<void> {
throw new Error('Yomitan extension failed to load.');
}
const yomitanDeps = {
getYomitanExt: () => yomitanExt,
getYomitanSession: () => yomitanSession,
getYomitanParserWindow: () => yomitanParserWindow,
setYomitanParserWindow: (window: BrowserWindow | null) => {
yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise: Promise<void> | null) => {
yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => yomitanParserInitPromise,
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => {
yomitanParserInitPromise = promise;
},
};
if (readDeck) {
const deckName = await getYomitanCurrentAnkiDeckName(yomitanDeps, logger);
writeResponse(responsePath, {
ok: true,
deckName,
});
cleanup();
app.exit(0);
return;
}
await syncYomitanDefaultAnkiServer(
config.ankiConnect?.url || 'http://127.0.0.1:8765',
{
getYomitanExt: () => yomitanExt,
getYomitanSession: () => yomitanSession,
getYomitanParserWindow: () => yomitanParserWindow,
setYomitanParserWindow: (window) => {
yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise) => {
yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => yomitanParserInitPromise,
setYomitanParserInitPromise: (promise) => {
yomitanParserInitPromise = promise;
},
},
yomitanDeps,
logger,
{ forceOverride: true },
{ forceOverride: true, deck: config.ankiConnect?.deck },
);
const addResult = await addYomitanNoteViaSearch(
word!,
{
getYomitanExt: () => yomitanExt,
getYomitanSession: () => yomitanSession,
getYomitanParserWindow: () => yomitanParserWindow,
setYomitanParserWindow: (window) => {
yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise) => {
yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => yomitanParserInitPromise,
setYomitanParserInitPromise: (promise) => {
yomitanParserInitPromise = promise;
},
},
logger,
);
const addResult = await addYomitanNoteViaSearch(word!, yomitanDeps, logger);
const noteId = addResult.noteId;
if (typeof noteId !== 'number') {