From b6272b229e16231c2986681ad6c308c75e951f4a Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 16 May 2026 16:19:22 -0700 Subject: [PATCH] fix(character-dictionary): cache AniList media resolution to skip repeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add anilist-resolution-cache.json to persist seriesKey→mediaId mappings - Skip AniList search when a cached resolution or matching snapshot exists - Expose readCachedSnapshots and readCachedMediaResolution from cache module --- .../character-dictionary-resolution-cache.md | 4 + docs-site/character-dictionary.md | 4 +- src/main/character-dictionary-runtime.test.ts | 11 ++- src/main/character-dictionary-runtime.ts | 60 ++++++++++++ .../character-dictionary-runtime/cache.ts | 96 +++++++++++++++++++ 5 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 changes/character-dictionary-resolution-cache.md diff --git a/changes/character-dictionary-resolution-cache.md b/changes/character-dictionary-resolution-cache.md new file mode 100644 index 00000000..fa9b0f9a --- /dev/null +++ b/changes/character-dictionary-resolution-cache.md @@ -0,0 +1,4 @@ +type: fixed +area: character-dictionary + +- Reused cached character-dictionary media matches so loading a title with an existing snapshot no longer sends another AniList search request. diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index 0bfdfb15..82e1857e 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -35,7 +35,7 @@ Character dictionary sync is disabled by default. To turn it on: ``` ::: tip -The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot. +The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup. ::: ::: warning @@ -139,7 +139,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine 5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation). 6. **ready** — Dictionary is live. Character names will match on the next subtitle line. -**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`: +**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search. ```jsonc { diff --git a/src/main/character-dictionary-runtime.test.ts b/src/main/character-dictionary-runtime.test.ts index 666e6c4a..13f365d7 100644 --- a/src/main/character-dictionary-runtime.test.ts +++ b/src/main/character-dictionary-runtime.test.ts @@ -1459,7 +1459,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different } }); -test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => { +test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList requests', async () => { const userDataPath = makeTempDir(); const originalFetch = globalThis.fetch; let searchQueryCount = 0; @@ -1567,11 +1567,18 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', }); const first = await runtime.getOrCreateCurrentSnapshot(); + assert.equal(searchQueryCount, 1); + assert.equal(characterQueryCount, 1); + + fs.rmSync(path.join(userDataPath, 'character-dictionaries', 'anilist-resolution-cache.json'), { + force: true, + }); + const second = await runtime.getOrCreateCurrentSnapshot(); assert.equal(first.fromCache, false); assert.equal(second.fromCache, true); - assert.equal(searchQueryCount, 2); + assert.equal(searchQueryCount, 1); assert.equal(characterQueryCount, 1); assert.equal( fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')), diff --git a/src/main/character-dictionary-runtime.ts b/src/main/character-dictionary-runtime.ts index 74cee0bb..408ccb8c 100644 --- a/src/main/character-dictionary-runtime.ts +++ b/src/main/character-dictionary-runtime.ts @@ -15,7 +15,10 @@ import { getMergedZipPath, getSnapshotPath, normalizeMergedMediaIds, + readCachedMediaResolution, + readCachedSnapshots, readSnapshot, + writeCachedMediaResolution, writeSnapshot, } from './character-dictionary-runtime/cache'; import { @@ -41,6 +44,7 @@ import type { CharacterDictionaryManualSelectionResult, CharacterDictionaryManualSelectionSnapshot, CharacterDictionaryRuntimeDeps, + CharacterDictionarySnapshot, CharacterDictionarySnapshotImage, CharacterDictionarySnapshotProgress, CharacterDictionarySnapshotProgressCallbacks, @@ -204,6 +208,26 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar }; }; + const findCachedSnapshotForSeriesKey = ( + seriesKey: string, + ): CharacterDictionarySnapshot | null => { + return ( + readCachedSnapshots(outputDir).find((snapshot) => { + const snapshotSeriesKey = buildCharacterDictionarySeriesKey({ + mediaPath: null, + mediaTitle: snapshot.mediaTitle, + guess: { + title: snapshot.mediaTitle, + season: null, + episode: null, + source: 'fallback', + }, + }); + return snapshotSeriesKey === seriesKey; + }) ?? null + ); + }; + const resolveCurrentMedia = async ( targetPath?: string, beforeRequest?: () => Promise, @@ -228,7 +252,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar staleMediaIds: override.staleMediaIds, }; } + + const cachedResolution = readCachedMediaResolution(outputDir, seriesKey); + if (cachedResolution) { + const cachedSnapshot = readSnapshot(getSnapshotPath(outputDir, cachedResolution.mediaId)); + if (cachedSnapshot) { + deps.logInfo?.( + `[dictionary] cached AniList match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`, + ); + return { + id: cachedSnapshot.mediaId, + title: cachedSnapshot.mediaTitle, + }; + } + } + + const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey); + if (cachedSnapshot) { + writeCachedMediaResolution(outputDir, { + seriesKey, + mediaId: cachedSnapshot.mediaId, + mediaTitle: cachedSnapshot.mediaTitle, + }); + deps.logInfo?.( + `[dictionary] cached snapshot match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`, + ); + return { + id: cachedSnapshot.mediaId, + title: cachedSnapshot.mediaTitle, + }; + } + const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest); + writeCachedMediaResolution(outputDir, { + seriesKey, + mediaId: resolved.id, + mediaTitle: resolved.title, + }); deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`); return resolved; }; diff --git a/src/main/character-dictionary-runtime/cache.ts b/src/main/character-dictionary-runtime/cache.ts index db5d57a0..0d5c6e68 100644 --- a/src/main/character-dictionary-runtime/cache.ts +++ b/src/main/character-dictionary-runtime/cache.ts @@ -21,6 +21,102 @@ export function getMergedZipPath(outputDir: string): string { return path.join(outputDir, 'merged.zip'); } +type MediaResolutionCacheEntry = { + seriesKey: string; + mediaId: number; + mediaTitle: string; +}; + +type MediaResolutionCacheFile = { + entries?: MediaResolutionCacheEntry[]; +}; + +function getMediaResolutionCachePath(outputDir: string): string { + return path.join(outputDir, 'anilist-resolution-cache.json'); +} + +function normalizeMediaResolutionEntry(value: unknown): MediaResolutionCacheEntry | null { + if (!value || typeof value !== 'object') return null; + const raw = value as Partial; + const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : ''; + const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : ''; + if (typeof raw.mediaId !== 'number' || !Number.isFinite(raw.mediaId)) return null; + const mediaId = Math.floor(raw.mediaId); + if (!seriesKey || mediaId <= 0 || !mediaTitle) return null; + return { + seriesKey, + mediaId, + mediaTitle, + }; +} + +function readMediaResolutionEntries(outputDir: string): MediaResolutionCacheEntry[] { + try { + const parsed = JSON.parse( + fs.readFileSync(getMediaResolutionCachePath(outputDir), 'utf8'), + ) as MediaResolutionCacheFile; + if (!Array.isArray(parsed.entries)) return []; + const byKey = new Map(); + for (const value of parsed.entries) { + const normalized = normalizeMediaResolutionEntry(value); + if (normalized) byKey.set(normalized.seriesKey, normalized); + } + return [...byKey.values()]; + } catch { + return []; + } +} + +function writeMediaResolutionEntries( + outputDir: string, + entries: MediaResolutionCacheEntry[], +): void { + ensureDir(outputDir); + fs.writeFileSync( + getMediaResolutionCachePath(outputDir), + JSON.stringify({ entries }, null, 2), + 'utf8', + ); +} + +export function readCachedMediaResolution( + outputDir: string, + seriesKey: string, +): MediaResolutionCacheEntry | null { + const normalizedKey = seriesKey.trim(); + if (!normalizedKey) return null; + return ( + readMediaResolutionEntries(outputDir).find((entry) => entry.seriesKey === normalizedKey) ?? null + ); +} + +export function writeCachedMediaResolution( + outputDir: string, + entry: MediaResolutionCacheEntry, +): void { + const normalized = normalizeMediaResolutionEntry(entry); + if (!normalized) return; + const remaining = readMediaResolutionEntries(outputDir).filter( + (existing) => existing.seriesKey !== normalized.seriesKey, + ); + writeMediaResolutionEntries(outputDir, [...remaining, normalized]); +} + +export function readCachedSnapshots(outputDir: string): CharacterDictionarySnapshot[] { + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true }); + } catch { + return []; + } + + return entries + .filter((entry) => entry.isFile() && /^anilist-\d+\.json$/.test(entry.name)) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => readSnapshot(path.join(getSnapshotsDir(outputDir), entry.name))) + .filter((snapshot): snapshot is CharacterDictionarySnapshot => snapshot !== null); +} + export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null { try { const raw = fs.readFileSync(snapshotPath, 'utf8');