fix(character-dictionary): cache completed MeCab refreshes (#212)

This commit is contained in:
2026-08-19 01:49:15 -07:00
committed by GitHub
parent 72c312810a
commit 20797772f8
9 changed files with 186 additions and 35 deletions
@@ -0,0 +1,5 @@
type: fixed
area: character dictionary
- Reuse character dictionaries after MeCab completes without finding any name splits instead of regenerating character data and portraits on every launch.
- Restore inline character portraits when a cached portrait index finishes loading after subtitles have already been tokenized.
+10 -6
View File
@@ -2623,6 +2623,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({ const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
userDataPath: USER_DATA_PATH, userDataPath: USER_DATA_PATH,
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(), getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
onIndexReady: () => refreshCurrentSubtitleAnnotations(),
onIndexReadyError: (error) =>
logger.warn(
'Failed to refresh subtitle annotations after character portrait index became ready.',
error,
),
}); });
// Lets the Yomitan scan runtime skip name lookups at positions where no // Lets the Yomitan scan runtime skip name lookups at positions where no
@@ -4047,7 +4053,7 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count, noteIds); appState.immersionTracker?.recordCardsMined(count, noteIds);
}; };
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => { function refreshCurrentSubtitleAnnotations(): void {
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0; const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
if (hasCurrentSubtitle) { if (hasCurrentSubtitle) {
subtitlePrefetchService?.pause(); subtitlePrefetchService?.pause();
@@ -4058,7 +4064,7 @@ const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
// Idle controller: no settle is coming to release the pause above. // Idle controller: no settle is coming to release the pause above.
subtitlePrefetchService?.resume(); subtitlePrefetchService?.resume();
} }
}; }
let hasAttemptedImmersionTrackerStartup = false; let hasAttemptedImmersionTrackerStartup = false;
const ensureImmersionTrackerStarted = (): void => { const ensureImmersionTrackerStarted = (): void => {
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) { if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
@@ -5081,9 +5087,7 @@ function initializeOverlayRuntime(): void {
overlayModalRuntime.primeModalWindow(); overlayModalRuntime.primeModalWindow();
} }
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
refreshCurrentSubtitleAfterKnownWordUpdate,
);
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
@@ -5876,7 +5880,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.ankiIntegration = integration; appState.ankiIntegration = integration;
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
refreshCurrentSubtitleAfterKnownWordUpdate, refreshCurrentSubtitleAnnotations,
); );
appState.ankiIntegration?.setSubtitleMiningContextConsumer( appState.ankiIntegration?.setSubtitleMiningContextConsumer(
consumePendingSubtitleMiningContext, consumePendingSubtitleMiningContext,
+3 -3
View File
@@ -450,7 +450,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
} }
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable(); const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
const resolvedNameSplits = nameSplitTokenizerAvailable const nameSplitResolution = nameSplitTokenizerAvailable
? await resolveJapaneseNameSplits( ? await resolveJapaneseNameSplits(
characters, characters,
deps.tokenizeJapaneseName!, deps.tokenizeJapaneseName!,
@@ -466,8 +466,8 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
}, },
) )
: undefined; : undefined;
const nameSplitSource = const resolvedNameSplits = nameSplitResolution?.splits;
resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic'; const nameSplitSource = nameSplitResolution?.kind === 'complete' ? 'mecab' : 'heuristic';
progress?.onGenerateProgress?.({ progress?.onGenerateProgress?.({
mediaId, mediaId,
@@ -198,6 +198,66 @@ test('createCharacterDictionaryImageLookup can scope duplicate names to the curr
assert.equal(scoped.alt, 'Kazuma'); assert.equal(scoped.alt, 'Kazuma');
}); });
test('createCharacterDictionaryImageLookup reports and retries a failed index-ready callback', async () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId: 21858,
mediaTitle: 'Little Witch Academia',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [
[
'ダイアナ',
'だいあな',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: {
tag: 'img',
path: 'img/m21858-c81709.png',
alt: 'ダイアナ・キャベンディッシュ',
},
},
],
0,
'',
],
],
images: [{ path: 'img/m21858-c81709.png', dataBase64: PNG_1X1_BASE64 }],
};
await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
const callbackError = new Error('annotation refresh failed');
const reportingError = new Error('error reporter failed');
let readyCount = 0;
const reportedErrors: unknown[] = [];
const lookup = createCharacterDictionaryImageLookup({
outputDir,
onIndexReady: () => {
readyCount += 1;
if (readyCount === 1) {
throw callbackError;
}
},
onIndexReadyError: (error) => {
reportedErrors.push(error);
throw reportingError;
},
});
assert.equal(lookup.get('ダイアナ', snapshot.mediaId), null);
await waitForRefresh(() => (reportedErrors.length === 1 ? true : null));
assert.ok(lookup.get('ダイアナ', snapshot.mediaId));
assert.equal(readyCount, 2);
assert.deepEqual(reportedErrors, [callbackError]);
lookup.get('ダイアナ', snapshot.mediaId);
assert.equal(readyCount, 2);
});
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => { test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => {
const outputDir = makeTempDir(); const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = { const snapshot: CharacterDictionarySnapshot = {
@@ -218,6 +218,8 @@ export function createCharacterDictionaryImageLookup(deps: {
userDataPath?: string; userDataPath?: string;
outputDir?: string; outputDir?: string;
getCurrentMediaId?: () => number | null | undefined; getCurrentMediaId?: () => number | null | undefined;
onIndexReady?: () => void;
onIndexReadyError?: (error: unknown) => void;
}): { }): {
get: (term: string, mediaId?: number | null) => CharacterNameImage | null; get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
invalidate: () => void; invalidate: () => void;
@@ -229,6 +231,24 @@ export function createCharacterDictionaryImageLookup(deps: {
let index = new Map<string, CharacterNameImage>(); let index = new Map<string, CharacterNameImage>();
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>(); let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
let refreshInFlight = false; let refreshInFlight = false;
let indexReadyDeliveryPending = false;
function deliverIndexReadyIfPending(): void {
if (!indexReadyDeliveryPending || !deps.onIndexReady) {
return;
}
indexReadyDeliveryPending = false;
try {
deps.onIndexReady();
} catch (error) {
indexReadyDeliveryPending = true;
try {
deps.onIndexReadyError?.(error);
} catch {
// Error reporting must not reject the detached index refresh task.
}
}
}
// Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run // Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups // synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
@@ -241,6 +261,7 @@ export function createCharacterDictionaryImageLookup(deps: {
signature = ''; signature = '';
return; return;
} }
deliverIndexReadyIfPending();
const nextSignature = getSnapshotDirectorySignature(outputDir); const nextSignature = getSnapshotDirectorySignature(outputDir);
if (nextSignature === signature || refreshInFlight) { if (nextSignature === signature || refreshInFlight) {
return; return;
@@ -262,6 +283,8 @@ export function createCharacterDictionaryImageLookup(deps: {
index = nextIndex; index = nextIndex;
indexByMediaId = nextIndexByMediaId; indexByMediaId = nextIndexByMediaId;
signature = nextSignature; signature = nextSignature;
indexReadyDeliveryPending = deps.onIndexReady !== undefined;
deliverIndexReadyIfPending();
} finally { } finally {
refreshInFlight = false; refreshInFlight = false;
} }
@@ -43,7 +43,8 @@ test('resolveJapaneseNameSplits splits a single-kanji surname via person-name PO
}), }),
); );
assert.deepEqual(splits.get('東紫乃'), { family: '東', given: '紫乃' }); assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('東紫乃'), { family: '東', given: '紫乃' });
}); });
test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => { test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => {
@@ -64,7 +65,8 @@ test('resolveJapaneseNameSplits corrects a hint-length-misleading surname bounda
}), }),
); );
assert.deepEqual(splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' }); assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
}); });
test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => { test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => {
@@ -85,7 +87,8 @@ test('resolveJapaneseNameSplits falls back to hint readings when POS tags are ge
}), }),
); );
assert.deepEqual(splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' }); assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
}); });
test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => { test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => {
@@ -96,7 +99,8 @@ test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the
}), }),
); );
assert.equal(splits.size, 0); assert.equal(splits.kind, 'complete');
assert.equal(splits.splits.size, 0);
}); });
test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => { test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => {
@@ -117,7 +121,8 @@ test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', asyn
}), }),
); );
assert.equal(splits.size, 0); assert.equal(splits.kind, 'complete');
assert.equal(splits.splits.size, 0);
}); });
test('resolveJapaneseNameSplits survives tokenizer failures', async () => { test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
@@ -130,7 +135,8 @@ test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
(message) => warnings.push(message), (message) => warnings.push(message),
); );
assert.equal(splits.size, 0); assert.equal(splits.kind, 'incomplete');
assert.equal(splits.splits.size, 0);
assert.equal(warnings.length, 1); assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /mecab unavailable/); assert.match(warnings[0]!, /mecab unavailable/);
}); });
@@ -7,6 +7,10 @@ import type {
ResolvedNameSplit, ResolvedNameSplit,
} from './types'; } from './types';
export type JapaneseNameSplitResolution =
| { kind: 'complete'; splits: Map<string, ResolvedNameSplit> }
| { kind: 'incomplete'; splits: Map<string, ResolvedNameSplit> };
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/; const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
function joinSurfaces(tokens: NameSplitToken[]): string { function joinSurfaces(tokens: NameSplitToken[]): string {
@@ -87,8 +91,9 @@ export async function resolveJapaneseNameSplits(
tokenize: NameSplitTokenizer, tokenize: NameSplitTokenizer,
logWarn?: (message: string) => void, logWarn?: (message: string) => void,
onCharacterResolved?: (completed: number, total: number) => void, onCharacterResolved?: (completed: number, total: number) => void,
): Promise<Map<string, ResolvedNameSplit>> { ): Promise<JapaneseNameSplitResolution> {
const splits = new Map<string, ResolvedNameSplit>(); const splits = new Map<string, ResolvedNameSplit>();
let tokenizerFailed = false;
let resolvedCharacters = 0; let resolvedCharacters = 0;
for (const character of characters) { for (const character of characters) {
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || ''); const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
@@ -99,12 +104,17 @@ export async function resolveJapaneseNameSplits(
try { try {
tokens = await tokenize(name); tokens = await tokenize(name);
} catch (err) { } catch (err) {
tokenizerFailed = true;
logWarn?.( logWarn?.(
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`, `[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
); );
continue; continue;
} }
if (!tokens || tokens.length < 2 || joinSurfaces(tokens) !== name) continue; if (!tokens) {
tokenizerFailed = true;
continue;
}
if (tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
const splitIndex = const splitIndex =
splitIndexFromPersonNamePos(tokens) ?? splitIndexFromPersonNamePos(tokens) ??
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading); splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
@@ -118,5 +128,5 @@ export async function resolveJapaneseNameSplits(
resolvedCharacters += 1; resolvedCharacters += 1;
onCharacterResolved?.(resolvedCharacters, characters.length); onCharacterResolved?.(resolvedCharacters, characters.length);
} }
return splits; return tokenizerFailed ? { kind: 'incomplete', splits } : { kind: 'complete', splits };
} }
@@ -7,7 +7,7 @@ import test from 'node:test';
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime'; import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
import { getSnapshotPath, writeSnapshot } from './cache'; import { getSnapshotPath, writeSnapshot } from './cache';
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants'; import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
import type { CharacterDictionarySnapshot } from './types'; import type { CharacterDictionarySnapshot, NameSplitTokenizer } from './types';
const GRAPHQL_URL = 'https://graphql.anilist.co'; const GRAPHQL_URL = 'https://graphql.anilist.co';
const PNG_1X1 = Buffer.from( const PNG_1X1 = Buffer.from(
@@ -121,7 +121,12 @@ test('generateForCurrentMedia refreshes same-version snapshots missing images wh
} }
}); });
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => { async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokenizer): Promise<{
characterPageRequests: number;
firstResultFromCache: boolean;
refreshedNameSplitSource: CharacterDictionarySnapshot['nameSplitSource'];
secondResultFromCache: boolean;
}> {
const userDataPath = makeTempDir(); const userDataPath = makeTempDir();
const outputDir = path.join(userDataPath, 'character-dictionaries'); const outputDir = path.join(userDataPath, 'character-dictionaries');
await writeSnapshot(getSnapshotPath(outputDir, 130298), { await writeSnapshot(getSnapshotPath(outputDir, 130298), {
@@ -172,7 +177,6 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
}) as typeof globalThis.fetch; }) as typeof globalThis.fetch;
try { try {
let tokenizerCalls = 0;
const runtime = createCharacterDictionaryRuntimeService({ const runtime = createCharacterDictionaryRuntimeService({
userDataPath, userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
@@ -185,29 +189,54 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
source: 'fallback', source: 'fallback',
}), }),
getNameMatchImagesEnabled: () => false, getNameMatchImagesEnabled: () => false,
tokenizeJapaneseName: async () => { tokenizeJapaneseName,
tokenizerCalls += 1;
return null;
},
getJapaneseNameTokenizerAvailable: () => true, getJapaneseNameTokenizerAvailable: () => true,
now: () => 1_700_000_000_500, now: () => 1_700_000_000_500,
}); });
const result = await runtime.generateForCurrentMedia(); const firstResult = await runtime.generateForCurrentMedia();
const refreshedSnapshot = JSON.parse( const refreshedSnapshot = JSON.parse(
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'), fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
) as CharacterDictionarySnapshot; ) as CharacterDictionarySnapshot;
const secondResult = await runtime.generateForCurrentMedia();
assert.equal(result.fromCache, false); return {
assert.equal(refreshedSnapshot.nameSplitSource, 'heuristic'); characterPageRequests,
firstResultFromCache: firstResult.fromCache,
const retriedResult = await runtime.generateForCurrentMedia(); refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
assert.equal(retriedResult.fromCache, false); secondResultFromCache: secondResult.fromCache,
assert.equal(characterPageRequests, 2); };
assert.equal(tokenizerCalls, 2);
} finally { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
}
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
let tokenizerCalls = 0;
const result = await runNameSplitRefreshScenario(async () => {
tokenizerCalls += 1;
return null;
});
assert.equal(result.firstResultFromCache, false);
assert.equal(result.refreshedNameSplitSource, 'heuristic');
assert.equal(result.secondResultFromCache, false);
assert.equal(result.characterPageRequests, 2);
assert.equal(tokenizerCalls, 2);
});
test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async () => {
let tokenizerCalls = 0;
const result = await runNameSplitRefreshScenario(async () => {
tokenizerCalls += 1;
return [];
});
assert.equal(result.firstResultFromCache, false);
assert.equal(result.refreshedNameSplitSource, 'mecab');
assert.equal(result.secondResultFromCache, true);
assert.equal(result.characterPageRequests, 1);
assert.equal(tokenizerCalls, 1);
}); });
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => { test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
+16 -2
View File
@@ -482,10 +482,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/); assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
}); });
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => { test('subtitle annotation updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(
/const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?<body>[\s\S]*?)\n\};/, /function refreshCurrentSubtitleAnnotations\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body; )?.groups?.body;
assert.ok(actionBlock); assert.ok(actionBlock);
@@ -503,6 +503,20 @@ test('known-word updates invalidate prefetched tokenizations before refreshing c
); );
}); });
test('character portrait index readiness refreshes cached subtitle annotations', () => {
const source = readMainSource();
const lookupDeps = source.match(
/const characterDictionaryImageLookup = createCharacterDictionaryImageLookup\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
assert.ok(lookupDeps);
assert.match(lookupDeps, /onIndexReady: \(\) => refreshCurrentSubtitleAnnotations\(\),/);
assert.match(
lookupDeps,
/onIndexReadyError: \(error\) =>[\s\S]*?logger\.warn\([\s\S]*?character portrait index became ready\.[\s\S]*?error,/,
);
});
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => { test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
const source = readMainSource(); const source = readMainSource();
const depsBlock = source.match( const depsBlock = source.match(