From 94abd0f3727bb40afea1816d3c27cc50e3fd5ed1 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 6 Mar 2026 17:21:19 -0800 Subject: [PATCH] Deduplicate voice actor image downloads per AniList person ID - Avoid repeated downloads when multiple characters share the same voice actor - Add coverage for shared voice actor image download behavior --- src/main/character-dictionary-runtime.test.ts | 267 ++++++++++++++++-- src/main/character-dictionary-runtime.ts | 65 +++-- 2 files changed, 292 insertions(+), 40 deletions(-) diff --git a/src/main/character-dictionary-runtime.test.ts b/src/main/character-dictionary-runtime.test.ts index 0d8b918..032a0f2 100644 --- a/src/main/character-dictionary-runtime.test.ts +++ b/src/main/character-dictionary-runtime.test.ts @@ -160,8 +160,19 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w }); const result = await runtime.generateForCurrentMedia(); - const termBank = JSON.parse(readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8')) as Array< - [string, string, string, string, number, Array>, number, string] + const termBank = JSON.parse( + readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), + ) as Array< + [ + string, + string, + string, + string, + number, + Array>, + number, + string, + ] >; const alexia = termBank.find(([term]) => term === 'アレクシア'); @@ -205,8 +216,10 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w assert.equal(badge.content, 'Side Character'); const descSection = children.find( - (c) => (c as { tag?: string }).tag === 'details' && Array.isArray((c as { content?: unknown[] }).content) && - ((c as { content: Array<{ content?: string }> }).content[0]?.content === 'Description'), + (c) => + (c as { tag?: string }).tag === 'details' && + Array.isArray((c as { content?: unknown[] }).content) && + (c as { content: Array<{ content?: string }> }).content[0]?.content === 'Description', ) as { tag: string; content: Array> } | undefined; assert.ok(descSection, 'expected Description collapsible section'); const descBody = descSection.content[1] as { content: string }; @@ -215,13 +228,20 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w ); const infoSection = children.find( - (c) => (c as { tag?: string }).tag === 'details' && Array.isArray((c as { content?: unknown[] }).content) && - ((c as { content: Array<{ content?: string }> }).content[0]?.content === 'Character Information'), + (c) => + (c as { tag?: string }).tag === 'details' && + Array.isArray((c as { content?: unknown[] }).content) && + (c as { content: Array<{ content?: string }> }).content[0]?.content === + 'Character Information', ) as { tag: string; content: Array> } | undefined; - assert.ok(infoSection, 'expected Character Information collapsible section with parsed __Race:__ field'); + assert.ok( + infoSection, + 'expected Character Information collapsible section with parsed __Race:__ field', + ); const topLevelImageGlossaryEntry = glossary.find( - (item) => typeof item === 'object' && item !== null && (item as { type?: string }).type === 'image', + (item) => + typeof item === 'object' && item !== null && (item as { type?: string }).type === 'image', ); assert.equal(topLevelImageGlossaryEntry, undefined); } finally { @@ -322,8 +342,19 @@ test('generateForCurrentMedia adds kana aliases for romanized names when native }); const result = await runtime.generateForCurrentMedia(); - const termBank = JSON.parse(readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8')) as Array< - [string, string, string, string, number, Array>, number, string] + const termBank = JSON.parse( + readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), + ) as Array< + [ + string, + string, + string, + string, + number, + Array>, + number, + string, + ] >; const kazuma = termBank.find(([term]) => term === 'カズマ'); @@ -430,7 +461,7 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', throw new Error(`Unexpected fetch URL: ${url}`); }) as typeof globalThis.fetch; - try { + try { const runtime = createCharacterDictionaryRuntimeService({ userDataPath, getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', @@ -466,7 +497,16 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', mediaId: number; entryCount: number; termEntries: Array< - [string, string, string, string, number, Array>, number, string] + [ + string, + string, + string, + string, + number, + Array>, + number, + string, + ] >; }; assert.equal(snapshot.mediaId, 130298); @@ -600,12 +640,27 @@ test('getOrCreateCurrentSnapshot rebuilds snapshots written with an older format const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8')) as { formatVersion: number; termEntries: Array< - [string, string, string, string, number, Array>, number, string] + [ + string, + string, + string, + string, + number, + Array>, + number, + string, + ] >; }; assert.equal(snapshot.formatVersion > 9, true); - assert.equal(snapshot.termEntries.some(([term]) => term === 'アルファ'), true); - assert.equal(snapshot.termEntries.some(([term]) => term === 'stale'), false); + assert.equal( + snapshot.termEntries.some(([term]) => term === 'アルファ'), + true, + ); + assert.equal( + snapshot.termEntries.some(([term]) => term === 'stale'), + false, + ); } finally { globalThis.fetch = originalFetch; } @@ -737,6 +792,168 @@ test('generateForCurrentMedia logs progress while resolving and rebuilding snaps } }); +test('generateForCurrentMedia downloads shared voice actor images once per AniList person id', async () => { + const userDataPath = makeTempDir(); + const originalFetch = globalThis.fetch; + const fetchedImageUrls: string[] = []; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === GRAPHQL_URL) { + const body = JSON.parse(String(init?.body ?? '{}')) as { + query?: string; + }; + + if (body.query?.includes('Page(perPage: 10)')) { + return new Response( + JSON.stringify({ + data: { + Page: { + media: [ + { + id: 130298, + episodes: 20, + title: { + romaji: 'Kage no Jitsuryokusha ni Naritakute!', + english: 'The Eminence in Shadow', + native: '陰の実力者になりたくて!', + }, + }, + ], + }, + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ); + } + + if (body.query?.includes('characters(page: $page')) { + return new Response( + JSON.stringify({ + data: { + Media: { + title: { + romaji: 'Kage no Jitsuryokusha ni Naritakute!', + english: 'The Eminence in Shadow', + native: '陰の実力者になりたくて!', + }, + characters: { + pageInfo: { hasNextPage: false }, + edges: [ + { + role: 'MAIN', + voiceActors: [ + { + id: 9001, + name: { + full: 'Kana Hanazawa', + native: '花澤香菜', + }, + image: { + large: null, + medium: 'https://example.com/kana.png', + }, + }, + ], + node: { + id: 321, + description: 'Alpha is the second-in-command of Shadow Garden.', + image: { + large: 'https://example.com/alpha.png', + medium: null, + }, + name: { + full: 'Alpha', + native: 'アルファ', + }, + }, + }, + { + role: 'SUPPORTING', + voiceActors: [ + { + id: 9001, + name: { + full: 'Kana Hanazawa', + native: '花澤香菜', + }, + image: { + large: null, + medium: 'https://example.com/kana.png', + }, + }, + ], + node: { + id: 654, + description: 'Beta documents Shadow Garden operations.', + image: { + large: 'https://example.com/beta.png', + medium: null, + }, + name: { + full: 'Beta', + native: 'ベータ', + }, + }, + }, + ], + }, + }, + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ); + } + } + + if ( + url === 'https://example.com/alpha.png' || + url === 'https://example.com/beta.png' || + url === 'https://example.com/kana.png' + ) { + fetchedImageUrls.push(url); + return new Response(PNG_1X1, { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }) as typeof globalThis.fetch; + + try { + const runtime = createCharacterDictionaryRuntimeService({ + userDataPath, + getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', + getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', + resolveMediaPathForJimaku: (mediaPath) => mediaPath, + guessAnilistMediaInfo: async () => ({ + title: 'The Eminence in Shadow', + episode: 5, + source: 'fallback', + }), + now: () => 1_700_000_000_100, + sleep: async () => undefined, + }); + + await runtime.generateForCurrentMedia(); + + assert.deepEqual(fetchedImageUrls, [ + 'https://example.com/alpha.png', + 'https://example.com/kana.png', + 'https://example.com/beta.png', + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('buildMergedDictionary combines stored snapshots into one stable dictionary', async () => { const userDataPath = makeTempDir(); const originalFetch = globalThis.fetch; @@ -913,8 +1130,19 @@ test('buildMergedDictionary combines stored snapshots into one stable dictionary const index = JSON.parse(readStoredZipEntry(merged.zipPath, 'index.json').toString('utf8')) as { title: string; }; - const termBank = JSON.parse(readStoredZipEntry(merged.zipPath, 'term_bank_1.json').toString('utf8')) as Array< - [string, string, string, string, number, Array>, number, string] + const termBank = JSON.parse( + readStoredZipEntry(merged.zipPath, 'term_bank_1.json').toString('utf8'), + ) as Array< + [ + string, + string, + string, + string, + number, + Array>, + number, + string, + ] >; const frieren = termBank.find(([term]) => term === 'フリーレン'); const alpha = termBank.find(([term]) => term === 'アルファ'); @@ -1064,7 +1292,10 @@ test('generateForCurrentMedia paces AniList requests and character image downloa await runtime.generateForCurrentMedia(); assert.deepEqual(sleepCalls, [2000, 250]); - assert.deepEqual(imageRequests, ['https://example.com/alpha.png', 'https://example.com/beta.png']); + assert.deepEqual(imageRequests, [ + 'https://example.com/alpha.png', + 'https://example.com/beta.png', + ]); } finally { globalThis.fetch = originalFetch; } diff --git a/src/main/character-dictionary-runtime.ts b/src/main/character-dictionary-runtime.ts index b2e1bcf..3d8fc6f 100644 --- a/src/main/character-dictionary-runtime.ts +++ b/src/main/character-dictionary-runtime.ts @@ -449,20 +449,13 @@ function romanizedTokenToKatakana(token: string): string | null { continue; } - if ( - current === 'n' && - next.length > 0 && - next !== 'y' && - !'aeiou'.includes(next) - ) { + if (current === 'n' && next.length > 0 && next !== 'y' && !'aeiou'.includes(next)) { output += 'ン'; i += 1; continue; } - const digraph = ROMANIZED_KANA_DIGRAPHS.find(([romaji]) => - normalized.startsWith(romaji, i), - ); + const digraph = ROMANIZED_KANA_DIGRAPHS.find(([romaji]) => normalized.startsWith(romaji, i)); if (digraph) { output += digraph[1]; i += digraph[0].length; @@ -750,7 +743,13 @@ function writeSnapshot(snapshotPath: string, snapshot: CharacterDictionarySnapsh } function roleBadgeStyle(role: CharacterDictionaryRole): Record { - const base = { borderRadius: '4px', padding: '0.15em 0.5em', fontSize: '0.8em', fontWeight: 'bold', color: '#fff' }; + const base = { + borderRadius: '4px', + padding: '0.15em 0.5em', + fontSize: '0.8em', + fontWeight: 'bold', + color: '#fff', + }; if (role === 'main') return { ...base, backgroundColor: '#4a8c3f' }; if (role === 'primary') return { ...base, backgroundColor: '#5c82b0' }; if (role === 'side') return { ...base, backgroundColor: '#7889a0' }; @@ -788,7 +787,9 @@ function buildVoicedByContent( const va = voiceActors[0]!; const vaImgPath = vaImagePaths.get(va.id); const vaLabel = va.nativeName - ? va.fullName ? `${va.nativeName} (${va.fullName})` : va.nativeName + ? va.fullName + ? `${va.nativeName} (${va.fullName})` + : va.nativeName : va.fullName; if (vaImgPath) { @@ -799,7 +800,12 @@ function buildVoicedByContent( content: [ { tag: 'td', - style: { verticalAlign: 'top', padding: '0', paddingRight: '0.4em', borderWidth: '0' }, + style: { + verticalAlign: 'top', + padding: '0', + paddingRight: '0.4em', + borderWidth: '0', + }, content: { tag: 'img', path: vaImgPath, @@ -829,7 +835,9 @@ function buildVoicedByContent( const items: Array> = []; for (const va of voiceActors) { const vaLabel = va.nativeName - ? va.fullName ? `${va.nativeName} (${va.fullName})` : va.nativeName + ? va.fullName + ? `${va.nativeName} (${va.fullName})` + : va.nativeName : va.fullName; items.push({ tag: 'li', content: vaLabel }); } @@ -844,9 +852,7 @@ function createDefinitionGlossary( ): CharacterDictionaryGlossaryEntry[] { const displayName = character.nativeName || character.fullName || `Character ${character.id}`; const secondaryName = - character.nativeName && - character.fullName && - character.fullName !== character.nativeName + character.nativeName && character.fullName && character.fullName !== character.nativeName ? character.fullName : null; const { fields, text: descriptionText } = parseCharacterDescription(character.description); @@ -1275,7 +1281,10 @@ async function fetchCharactersForMedia( }; } -async function downloadCharacterImage(imageUrl: string, charId: number): Promise<{ +async function downloadCharacterImage( + imageUrl: string, + charId: number, +): Promise<{ filename: string; ext: string; bytes: Buffer; @@ -1379,7 +1388,10 @@ function buildDictionaryZip( const zipFiles: Array<{ name: string; data: Buffer }> = [ { name: 'index.json', - data: Buffer.from(JSON.stringify(createIndex(dictionaryTitle, description, revision), null, 2), 'utf8'), + data: Buffer.from( + JSON.stringify(createIndex(dictionaryTitle, description, revision), null, 2), + 'utf8', + ), }, { name: 'tag_bank_1.json', @@ -1454,7 +1466,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar } deps.logInfo?.( `[dictionary] current anime guess: ${guessed.title.trim()}${ - typeof guessed.episode === 'number' && guessed.episode > 0 ? ` (episode ${guessed.episode})` : '' + typeof guessed.episode === 'number' && guessed.episode > 0 + ? ` (episode ${guessed.episode})` + : '' }`, ); const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest); @@ -1486,7 +1500,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar mediaId, beforeRequest, (page) => { - deps.logInfo?.(`[dictionary] downloaded AniList character page ${page} for AniList ${mediaId}`); + deps.logInfo?.( + `[dictionary] downloaded AniList character page ${page} for AniList ${mediaId}`, + ); }, ); if (characters.length === 0) { @@ -1496,12 +1512,14 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar const imagesByCharacterId = new Map(); const imagesByVaId = new Map(); const allImageUrls: Array<{ id: number; url: string; kind: 'character' | 'va' }> = []; + const seenVaIds = new Set(); for (const character of characters) { if (character.imageUrl) { allImageUrls.push({ id: character.id, url: character.imageUrl, kind: 'character' }); } for (const va of character.voiceActors) { - if (va.imageUrl && !allImageUrls.some((u) => u.kind === 'va' && u.id === va.id)) { + if (va.imageUrl && !seenVaIds.has(va.id)) { + seenVaIds.add(va.id); allImageUrls.push({ id: va.id, url: va.imageUrl, kind: 'va' }); } } @@ -1601,7 +1619,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar entryCount, }; }, - generateForCurrentMedia: async (targetPath?: string, _options?: CharacterDictionaryGenerateOptions) => { + generateForCurrentMedia: async ( + targetPath?: string, + _options?: CharacterDictionaryGenerateOptions, + ) => { let hasAniListRequest = false; const waitForAniListRequestSlot = async (): Promise => { if (!hasAniListRequest) {