fix: pace AniList character dictionary requests

This commit is contained in:
2026-03-05 23:57:38 -08:00
parent 72b18110b5
commit ac4fd60098
7 changed files with 269 additions and 32 deletions

View File

@@ -0,0 +1,64 @@
---
id: TASK-85.1
title: 'Address PR #14 character dictionary review follow-ups'
status: Done
assignee:
- codex
created_date: '2026-03-06 07:48'
updated_date: '2026-03-06 07:56'
labels: []
dependencies: []
references:
- >-
/home/sudacode/projects/japanese/SubMiner/launcher/commands/dictionary-command.ts
- >-
/home/sudacode/projects/japanese/SubMiner/src/main/character-dictionary-runtime.ts
- /home/sudacode/projects/japanese/SubMiner/launcher/types.ts
documentation:
- 'https://docs.anilist.co/guide/rate-limiting'
parent_task_id: TASK-85
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Apply the accepted follow-up fixes from Claude's PR review for the AniList character dictionary work: remove dead launcher code, deduplicate video extension handling where practical, and add explicit pacing for AniList character-page requests / character image downloads so the integration stays within AniList rate-limiting expectations.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launcher dictionary command no longer contains unreachable dead code after the app handoff.
- [x] #2 Character dictionary runtime no longer maintains a separate ad hoc video extension list when existing shared extension data can be reused safely.
- [x] #3 Character dictionary generation spaces outbound AniList-related requests with explicit named delays, and tests cover the pacing behavior and unchanged command forwarding behavior.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing tests for dictionary command handoff semantics and dictionary runtime request pacing.
2. Remove unreachable boolean return path from the launcher dictionary command while preserving call sites.
3. Reuse the shared launcher video extension set inside the character dictionary runtime with extname normalization, then add named AniList pacing constants for page fetches and character image downloads.
4. Run targeted tests, then broader relevant test slices, and update acceptance criteria / notes with the validated result.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added a shared `src/shared/video-extensions.ts` source and rewired both launcher/runtime consumers to remove the duplicated runtime extension list.
Replaced the hardcoded AniList page sleep with a per-generation AniList request pacer (2000ms between API requests) plus 250ms spacing between character image download attempts, including failed image fetches.
Hardened `runDictionaryCommand` so an unexpected return from the `never`-typed app handoff throws immediately instead of silently falling through.
Validated with targeted and adjacent test slices plus `bun run tsc --noEmit`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Removed the dead post-handoff return from the launcher dictionary command and replaced it with an explicit invariant error if the `never`-typed app handoff ever returns unexpectedly. Extracted video extension data into `src/shared/video-extensions.ts` so the launcher and character dictionary runtime share one source of truth.
Adjusted character dictionary generation to use a per-run AniList request pacer with a conservative 2000ms delay between AniList API calls, and added 250ms spacing between character image download attempts so repeated image fetches are not bursty even when an image URL fails. Added regression coverage for the pacing behavior and the launcher handoff invariant.
Validation: `bun test src/main/character-dictionary-runtime.test.ts`, `bun test launcher/commands/command-modules.test.ts`, `bun test launcher/main.test.ts launcher/parse-args.test.ts src/cli/args.test.ts src/core/services/cli-command.test.ts src/main/runtime/character-dictionary-auto-sync.test.ts`, `bun run tsc --noEmit`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -115,3 +115,16 @@ test('dictionary command forwards --dictionary and target path to app binary', (
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]); assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]);
}); });
test('dictionary command throws if app handoff unexpectedly returns', () => {
const context = createContext();
context.args.dictionary = true;
assert.throws(
() =>
runDictionaryCommand(context, {
runAppCommandWithInherit: () => undefined as never,
}),
/unexpectedly returned/,
);
});

View File

@@ -27,5 +27,5 @@ export function runDictionaryCommand(
} }
deps.runAppCommandWithInherit(appPath, forwarded); deps.runAppCommandWithInherit(appPath, forwarded);
return true; throw new Error('Dictionary command app handoff unexpectedly returned.');
} }

View File

@@ -1,18 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
export const VIDEO_EXTENSIONS = new Set([
'mkv',
'mp4',
'avi',
'webm',
'mov',
'flv',
'wmv',
'm4v',
'ts',
'm2ts',
]);
export const ROFI_THEME_FILE = 'subminer.rasi'; export const ROFI_THEME_FILE = 'subminer.rasi';
export const DEFAULT_SOCKET_PATH = '/tmp/subminer-socket'; export const DEFAULT_SOCKET_PATH = '/tmp/subminer-socket';

View File

@@ -344,3 +344,143 @@ test('generateForCurrentMedia regenerates dictionary when cached format version
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
}); });
test('generateForCurrentMedia paces AniList requests and character image downloads', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
const sleepCalls: number[] = [];
const imageRequests: 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',
node: {
id: 111,
description: 'First character.',
image: {
large: 'https://example.com/alpha.png',
medium: null,
},
name: {
full: 'Alpha',
native: 'アルファ',
},
},
},
{
role: 'SUPPORTING',
node: {
id: 222,
description: 'Second character.',
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') {
imageRequests.push(url);
return new Response('missing', {
status: 404,
headers: { 'content-type': 'text/plain' },
});
}
if (url === 'https://example.com/beta.png') {
imageRequests.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_000,
sleep: async (ms) => {
sleepCalls.push(ms);
},
});
await runtime.generateForCurrentMedia();
assert.deepEqual(sleepCalls, [2000, 250]);
assert.deepEqual(imageRequests, ['https://example.com/alpha.png', 'https://example.com/beta.png']);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -2,8 +2,11 @@ import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater'; import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
import { hasVideoExtension } from '../shared/video-extensions';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'; const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
const ANILIST_REQUEST_DELAY_MS = 2000;
const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
const HONORIFIC_SUFFIXES = [ const HONORIFIC_SUFFIXES = [
'さん', 'さん',
'様', '様',
@@ -21,19 +24,6 @@ const HONORIFIC_SUFFIXES = [
'社長', '社長',
'部長', '部長',
] as const; ] as const;
const VIDEO_EXTENSIONS = new Set([
'.mkv',
'.mp4',
'.avi',
'.webm',
'.mov',
'.flv',
'.wmv',
'.m4v',
'.ts',
'.m2ts',
]);
type CharacterDictionaryRole = 'main' | 'primary' | 'side' | 'appears'; type CharacterDictionaryRole = 'main' | 'primary' | 'side' | 'appears';
type CharacterDictionaryCacheEntry = { type CharacterDictionaryCacheEntry = {
@@ -137,6 +127,7 @@ export interface CharacterDictionaryRuntimeDeps {
mediaTitle: string | null, mediaTitle: string | null,
) => Promise<AnilistMediaGuess | null>; ) => Promise<AnilistMediaGuess | null>;
now: () => number; now: () => number;
sleep?: (ms: number) => Promise<void>;
logInfo?: (message: string) => void; logInfo?: (message: string) => void;
logWarn?: (message: string) => void; logWarn?: (message: string) => void;
} }
@@ -325,8 +316,7 @@ function expandUserPath(input: string): string {
} }
function isVideoFile(filePath: string): boolean { function isVideoFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase(); return hasVideoExtension(path.extname(filePath));
return VIDEO_EXTENSIONS.has(ext);
} }
function findFirstVideoFileInDirectory(directoryPath: string): string | null { function findFirstVideoFileInDirectory(directoryPath: string): string | null {
@@ -604,7 +594,11 @@ function createStoredZip(files: Array<{ name: string; data: Buffer }>): Buffer {
async function fetchAniList<T>( async function fetchAniList<T>(
query: string, query: string,
variables: Record<string, unknown>, variables: Record<string, unknown>,
beforeRequest?: () => Promise<void>,
): Promise<T> { ): Promise<T> {
if (beforeRequest) {
await beforeRequest();
}
const response = await fetch(ANILIST_GRAPHQL_URL, { const response = await fetch(ANILIST_GRAPHQL_URL, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -634,6 +628,7 @@ async function fetchAniList<T>(
async function resolveAniListMediaIdFromGuess( async function resolveAniListMediaIdFromGuess(
guess: AnilistMediaGuess, guess: AnilistMediaGuess,
beforeRequest?: () => Promise<void>,
): Promise<ResolvedAniListMedia> { ): Promise<ResolvedAniListMedia> {
const data = await fetchAniList<AniListSearchResponse>( const data = await fetchAniList<AniListSearchResponse>(
` `
@@ -654,6 +649,7 @@ async function resolveAniListMediaIdFromGuess(
{ {
search: guess.title, search: guess.title,
}, },
beforeRequest,
); );
const media = data.Page?.media ?? []; const media = data.Page?.media ?? [];
@@ -664,7 +660,10 @@ async function resolveAniListMediaIdFromGuess(
return resolved; return resolved;
} }
async function fetchCharactersForMedia(mediaId: number): Promise<{ async function fetchCharactersForMedia(
mediaId: number,
beforeRequest?: () => Promise<void>,
): Promise<{
mediaTitle: string; mediaTitle: string;
characters: CharacterRecord[]; characters: CharacterRecord[];
}> { }> {
@@ -708,6 +707,7 @@ async function fetchCharactersForMedia(mediaId: number): Promise<{
id: mediaId, id: mediaId,
page, page,
}, },
beforeRequest,
); );
const media = data.Media; const media = data.Media;
@@ -744,7 +744,6 @@ async function fetchCharactersForMedia(mediaId: number): Promise<{
break; break;
} }
page += 1; page += 1;
await sleep(300);
} }
return { return {
@@ -805,12 +804,22 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
} { } {
const outputDir = path.join(deps.userDataPath, 'character-dictionaries'); const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
const cachePath = path.join(outputDir, 'cache.json'); const cachePath = path.join(outputDir, 'cache.json');
const sleepMs = deps.sleep ?? sleep;
return { return {
generateForCurrentMedia: async ( generateForCurrentMedia: async (
targetPath?: string, targetPath?: string,
options?: CharacterDictionaryGenerateOptions, options?: CharacterDictionaryGenerateOptions,
) => { ) => {
let hasAniListRequest = false;
const waitForAniListRequestSlot = async (): Promise<void> => {
if (!hasAniListRequest) {
hasAniListRequest = true;
return;
}
await sleepMs(ANILIST_REQUEST_DELAY_MS);
};
const dictionaryTarget = targetPath?.trim() || ''; const dictionaryTarget = targetPath?.trim() || '';
const guessInput = const guessInput =
dictionaryTarget.length > 0 dictionaryTarget.length > 0
@@ -826,7 +835,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
throw new Error('Unable to resolve current anime from media path/title.'); throw new Error('Unable to resolve current anime from media path/title.');
} }
const resolvedMedia = await resolveAniListMediaIdFromGuess(guessed); const resolvedMedia = await resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot);
const cache = readCache(cachePath); const cache = readCache(cachePath);
const cached = cache.anilistById[String(resolvedMedia.id)]; const cached = cache.anilistById[String(resolvedMedia.id)];
const refreshTtlMsRaw = options?.refreshTtlMs; const refreshTtlMsRaw = options?.refreshTtlMs;
@@ -859,6 +868,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
const { mediaTitle: fetchedMediaTitle, characters } = await fetchCharactersForMedia( const { mediaTitle: fetchedMediaTitle, characters } = await fetchCharactersForMedia(
resolvedMedia.id, resolvedMedia.id,
waitForAniListRequestSlot,
); );
if (characters.length === 0) { if (characters.length === 0) {
throw new Error(`No characters returned for AniList media ${resolvedMedia.id}.`); throw new Error(`No characters returned for AniList media ${resolvedMedia.id}.`);
@@ -870,9 +880,14 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
[]; [];
const seen = new Set<string>(); const seen = new Set<string>();
let hasAttemptedCharacterImageDownload = false;
for (const character of characters) { for (const character of characters) {
let imagePath: string | null = null; let imagePath: string | null = null;
if (character.imageUrl) { if (character.imageUrl) {
if (hasAttemptedCharacterImageDownload) {
await sleepMs(CHARACTER_IMAGE_DOWNLOAD_DELAY_MS);
}
hasAttemptedCharacterImageDownload = true;
const image = await downloadCharacterImage(character.imageUrl, character.id); const image = await downloadCharacterImage(character.imageUrl, character.id);
if (image) { if (image) {
imagePath = `img/${image.filename}`; imagePath = `img/${image.filename}`;

View File

@@ -0,0 +1,17 @@
export const VIDEO_EXTENSIONS = new Set([
'mkv',
'mp4',
'avi',
'webm',
'mov',
'flv',
'wmv',
'm4v',
'ts',
'm2ts',
]);
export function hasVideoExtension(value: string): boolean {
const normalized = value.trim().toLowerCase().replace(/^\./, '');
return normalized.length > 0 && VIDEO_EXTENSIONS.has(normalized);
}