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
This commit is contained in:
2026-03-06 17:21:19 -08:00
parent 4d60f64bea
commit 94abd0f372
2 changed files with 292 additions and 40 deletions

View File

@@ -160,8 +160,19 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
}); });
const result = await runtime.generateForCurrentMedia(); const result = await runtime.generateForCurrentMedia();
const termBank = JSON.parse(readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8')) as Array< const termBank = JSON.parse(
[string, string, string, string, number, Array<string | Record<string, unknown>>, number, string] readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>; >;
const alexia = termBank.find(([term]) => term === 'アレクシア'); 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'); assert.equal(badge.content, 'Side Character');
const descSection = children.find( const descSection = children.find(
(c) => (c as { tag?: string }).tag === 'details' && Array.isArray((c as { content?: unknown[] }).content) && (c) =>
((c as { content: Array<{ content?: string }> }).content[0]?.content === 'Description'), (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<Record<string, unknown>> } | undefined; ) as { tag: string; content: Array<Record<string, unknown>> } | undefined;
assert.ok(descSection, 'expected Description collapsible section'); assert.ok(descSection, 'expected Description collapsible section');
const descBody = descSection.content[1] as { content: string }; 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( const infoSection = children.find(
(c) => (c as { tag?: string }).tag === 'details' && Array.isArray((c as { content?: unknown[] }).content) && (c) =>
((c as { content: Array<{ content?: string }> }).content[0]?.content === 'Character Information'), (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<Record<string, unknown>> } | undefined; ) as { tag: string; content: Array<Record<string, unknown>> } | 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( 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); assert.equal(topLevelImageGlossaryEntry, undefined);
} finally { } finally {
@@ -322,8 +342,19 @@ test('generateForCurrentMedia adds kana aliases for romanized names when native
}); });
const result = await runtime.generateForCurrentMedia(); const result = await runtime.generateForCurrentMedia();
const termBank = JSON.parse(readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8')) as Array< const termBank = JSON.parse(
[string, string, string, string, number, Array<string | Record<string, unknown>>, number, string] readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>; >;
const kazuma = termBank.find(([term]) => term === 'カズマ'); 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}`); throw new Error(`Unexpected fetch URL: ${url}`);
}) as typeof globalThis.fetch; }) as typeof globalThis.fetch;
try { try {
const runtime = createCharacterDictionaryRuntimeService({ const runtime = createCharacterDictionaryRuntimeService({
userDataPath, userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
@@ -466,7 +497,16 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data',
mediaId: number; mediaId: number;
entryCount: number; entryCount: number;
termEntries: Array< termEntries: Array<
[string, string, string, string, number, Array<string | Record<string, unknown>>, number, string] [
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>; >;
}; };
assert.equal(snapshot.mediaId, 130298); 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 { const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8')) as {
formatVersion: number; formatVersion: number;
termEntries: Array< termEntries: Array<
[string, string, string, string, number, Array<string | Record<string, unknown>>, number, string] [
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>; >;
}; };
assert.equal(snapshot.formatVersion > 9, true); assert.equal(snapshot.formatVersion > 9, true);
assert.equal(snapshot.termEntries.some(([term]) => term === 'アルファ'), true); assert.equal(
assert.equal(snapshot.termEntries.some(([term]) => term === 'stale'), false); snapshot.termEntries.some(([term]) => term === 'アルファ'),
true,
);
assert.equal(
snapshot.termEntries.some(([term]) => term === 'stale'),
false,
);
} finally { } finally {
globalThis.fetch = originalFetch; 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 () => { test('buildMergedDictionary combines stored snapshots into one stable dictionary', async () => {
const userDataPath = makeTempDir(); const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch; 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 { const index = JSON.parse(readStoredZipEntry(merged.zipPath, 'index.json').toString('utf8')) as {
title: string; title: string;
}; };
const termBank = JSON.parse(readStoredZipEntry(merged.zipPath, 'term_bank_1.json').toString('utf8')) as Array< const termBank = JSON.parse(
[string, string, string, string, number, Array<string | Record<string, unknown>>, number, string] readStoredZipEntry(merged.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>; >;
const frieren = termBank.find(([term]) => term === 'フリーレン'); const frieren = termBank.find(([term]) => term === 'フリーレン');
const alpha = 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(); await runtime.generateForCurrentMedia();
assert.deepEqual(sleepCalls, [2000, 250]); 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 { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }

View File

@@ -449,20 +449,13 @@ function romanizedTokenToKatakana(token: string): string | null {
continue; continue;
} }
if ( if (current === 'n' && next.length > 0 && next !== 'y' && !'aeiou'.includes(next)) {
current === 'n' &&
next.length > 0 &&
next !== 'y' &&
!'aeiou'.includes(next)
) {
output += 'ン'; output += 'ン';
i += 1; i += 1;
continue; continue;
} }
const digraph = ROMANIZED_KANA_DIGRAPHS.find(([romaji]) => const digraph = ROMANIZED_KANA_DIGRAPHS.find(([romaji]) => normalized.startsWith(romaji, i));
normalized.startsWith(romaji, i),
);
if (digraph) { if (digraph) {
output += digraph[1]; output += digraph[1];
i += digraph[0].length; i += digraph[0].length;
@@ -750,7 +743,13 @@ function writeSnapshot(snapshotPath: string, snapshot: CharacterDictionarySnapsh
} }
function roleBadgeStyle(role: CharacterDictionaryRole): Record<string, string> { function roleBadgeStyle(role: CharacterDictionaryRole): Record<string, string> {
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 === 'main') return { ...base, backgroundColor: '#4a8c3f' };
if (role === 'primary') return { ...base, backgroundColor: '#5c82b0' }; if (role === 'primary') return { ...base, backgroundColor: '#5c82b0' };
if (role === 'side') return { ...base, backgroundColor: '#7889a0' }; if (role === 'side') return { ...base, backgroundColor: '#7889a0' };
@@ -788,7 +787,9 @@ function buildVoicedByContent(
const va = voiceActors[0]!; const va = voiceActors[0]!;
const vaImgPath = vaImagePaths.get(va.id); const vaImgPath = vaImagePaths.get(va.id);
const vaLabel = va.nativeName const vaLabel = va.nativeName
? va.fullName ? `${va.nativeName} (${va.fullName})` : va.nativeName ? va.fullName
? `${va.nativeName} (${va.fullName})`
: va.nativeName
: va.fullName; : va.fullName;
if (vaImgPath) { if (vaImgPath) {
@@ -799,7 +800,12 @@ function buildVoicedByContent(
content: [ content: [
{ {
tag: 'td', tag: 'td',
style: { verticalAlign: 'top', padding: '0', paddingRight: '0.4em', borderWidth: '0' }, style: {
verticalAlign: 'top',
padding: '0',
paddingRight: '0.4em',
borderWidth: '0',
},
content: { content: {
tag: 'img', tag: 'img',
path: vaImgPath, path: vaImgPath,
@@ -829,7 +835,9 @@ function buildVoicedByContent(
const items: Array<Record<string, unknown>> = []; const items: Array<Record<string, unknown>> = [];
for (const va of voiceActors) { for (const va of voiceActors) {
const vaLabel = va.nativeName const vaLabel = va.nativeName
? va.fullName ? `${va.nativeName} (${va.fullName})` : va.nativeName ? va.fullName
? `${va.nativeName} (${va.fullName})`
: va.nativeName
: va.fullName; : va.fullName;
items.push({ tag: 'li', content: vaLabel }); items.push({ tag: 'li', content: vaLabel });
} }
@@ -844,9 +852,7 @@ function createDefinitionGlossary(
): CharacterDictionaryGlossaryEntry[] { ): CharacterDictionaryGlossaryEntry[] {
const displayName = character.nativeName || character.fullName || `Character ${character.id}`; const displayName = character.nativeName || character.fullName || `Character ${character.id}`;
const secondaryName = const secondaryName =
character.nativeName && character.nativeName && character.fullName && character.fullName !== character.nativeName
character.fullName &&
character.fullName !== character.nativeName
? character.fullName ? character.fullName
: null; : null;
const { fields, text: descriptionText } = parseCharacterDescription(character.description); 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; filename: string;
ext: string; ext: string;
bytes: Buffer; bytes: Buffer;
@@ -1379,7 +1388,10 @@ function buildDictionaryZip(
const zipFiles: Array<{ name: string; data: Buffer }> = [ const zipFiles: Array<{ name: string; data: Buffer }> = [
{ {
name: 'index.json', 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', name: 'tag_bank_1.json',
@@ -1454,7 +1466,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
} }
deps.logInfo?.( deps.logInfo?.(
`[dictionary] current anime guess: ${guessed.title.trim()}${ `[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); const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
@@ -1486,7 +1500,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
mediaId, mediaId,
beforeRequest, beforeRequest,
(page) => { (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) { if (characters.length === 0) {
@@ -1496,12 +1512,14 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
const imagesByCharacterId = new Map<number, CharacterDictionarySnapshotImage>(); const imagesByCharacterId = new Map<number, CharacterDictionarySnapshotImage>();
const imagesByVaId = new Map<number, CharacterDictionarySnapshotImage>(); const imagesByVaId = new Map<number, CharacterDictionarySnapshotImage>();
const allImageUrls: Array<{ id: number; url: string; kind: 'character' | 'va' }> = []; const allImageUrls: Array<{ id: number; url: string; kind: 'character' | 'va' }> = [];
const seenVaIds = new Set<number>();
for (const character of characters) { for (const character of characters) {
if (character.imageUrl) { if (character.imageUrl) {
allImageUrls.push({ id: character.id, url: character.imageUrl, kind: 'character' }); allImageUrls.push({ id: character.id, url: character.imageUrl, kind: 'character' });
} }
for (const va of character.voiceActors) { 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' }); allImageUrls.push({ id: va.id, url: va.imageUrl, kind: 'va' });
} }
} }
@@ -1601,7 +1619,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
entryCount, entryCount,
}; };
}, },
generateForCurrentMedia: async (targetPath?: string, _options?: CharacterDictionaryGenerateOptions) => { generateForCurrentMedia: async (
targetPath?: string,
_options?: CharacterDictionaryGenerateOptions,
) => {
let hasAniListRequest = false; let hasAniListRequest = false;
const waitForAniListRequestSlot = async (): Promise<void> => { const waitForAniListRequestSlot = async (): Promise<void> => {
if (!hasAniListRequest) { if (!hasAniListRequest) {