import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { hasVideoExtension } from '../shared/video-extensions'; import { applyCollapsibleOpenStatesToTermEntries, buildDictionaryTitle, buildDictionaryZip, buildSnapshotFromCharacters, buildSnapshotImagePath, buildVaImagePath, } from './character-dictionary-runtime/build'; import { buildMergedRevision, getMergedZipPath, getSnapshotPath, normalizeMergedMediaIds, readCachedMediaResolution, readCachedSnapshots, readSnapshot, writeCachedMediaResolution, writeSnapshot, } from './character-dictionary-runtime/cache'; import { ANILIST_REQUEST_DELAY_MS, CHARACTER_DICTIONARY_MERGED_TITLE, CHARACTER_IMAGE_DOWNLOAD_DELAY_MS, } from './character-dictionary-runtime/constants'; import { downloadCharacterImage, fetchAniListMediaCandidateById, fetchCharactersForMedia, resolveAniListMediaIdFromGuess, searchAniListMediaCandidates, } from './character-dictionary-runtime/fetch'; import { buildCharacterDictionarySeriesKey, createCharacterDictionaryManualSelectionStore, } from './character-dictionary-runtime/manual-selection'; import { snapshotHasCharacterNameImages } from './character-dictionary-runtime/image-lookup'; import type { AniListMediaCandidate, CharacterDictionaryBuildResult, CharacterDictionaryGenerateOptions, CharacterDictionaryManualSelectionResult, CharacterDictionaryManualSelectionSnapshot, CharacterDictionaryRuntimeDeps, CharacterDictionarySnapshot, CharacterDictionarySnapshotImage, CharacterDictionarySnapshotProgress, CharacterDictionarySnapshotProgressCallbacks, CharacterDictionarySnapshotResult, MergedCharacterDictionaryBuildResult, ResolvedAniListMedia, } from './character-dictionary-runtime/types'; export type { CharacterDictionaryBuildResult, CharacterDictionaryGenerateOptions, CharacterDictionaryRuntimeDeps, CharacterDictionarySnapshot, CharacterDictionarySnapshotProgress, CharacterDictionarySnapshotProgressCallbacks, CharacterDictionarySnapshotResult, MergedCharacterDictionaryBuildResult, } from './character-dictionary-runtime/types'; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function expandUserPath(input: string): string { if (input.startsWith('~')) { return path.join(os.homedir(), input.slice(1)); } return input; } function isVideoFile(filePath: string): boolean { return hasVideoExtension(path.extname(filePath)); } function findFirstVideoFileInDirectory(directoryPath: string): string | null { const queue: string[] = [directoryPath]; while (queue.length > 0) { const current = queue.shift()!; let entries: fs.Dirent[] = []; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; } entries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { const fullPath = path.join(current, entry.name); if (entry.isFile() && isVideoFile(fullPath)) { return fullPath; } if (entry.isDirectory() && !entry.name.startsWith('.')) { queue.push(fullPath); } } } return null; } function resolveDictionaryGuessInputs(targetPath: string): { mediaPath: string; mediaTitle: string | null; } { const trimmed = targetPath.trim(); if (!trimmed) { throw new Error('Dictionary target path is empty.'); } const resolvedPath = path.resolve(expandUserPath(trimmed)); let stats: fs.Stats; try { stats = fs.statSync(resolvedPath); } catch { throw new Error(`Dictionary target path not found: ${targetPath}`); } if (stats.isFile()) { return { mediaPath: resolvedPath, mediaTitle: path.basename(resolvedPath), }; } if (stats.isDirectory()) { const firstVideo = findFirstVideoFileInDirectory(resolvedPath); if (firstVideo) { return { mediaPath: firstVideo, mediaTitle: path.basename(firstVideo), }; } return { mediaPath: resolvedPath, mediaTitle: path.basename(resolvedPath), }; } throw new Error(`Dictionary target must be a file or directory path: ${targetPath}`); } export function createCharacterDictionaryRuntimeService(deps: CharacterDictionaryRuntimeDeps): { getOrCreateCurrentSnapshot: ( targetPath?: string, progress?: CharacterDictionarySnapshotProgressCallbacks, ) => Promise; buildMergedDictionary: (mediaIds: number[]) => Promise; getManualSelectionSnapshot: ( targetPath?: string, searchTitle?: string, ) => Promise; setManualSelection: (request: { targetPath?: string; mediaId: number; }) => Promise; generateForCurrentMedia: ( targetPath?: string, options?: CharacterDictionaryGenerateOptions, ) => Promise; } { const outputDir = path.join(deps.userDataPath, 'character-dictionaries'); const sleepMs = deps.sleep ?? sleep; const getCollapsibleSectionOpenState = deps.getCollapsibleSectionOpenState ?? (() => false); const manualSelectionStore = createCharacterDictionaryManualSelectionStore({ userDataPath: deps.userDataPath, }); const shouldRefreshCachedSnapshot = (snapshot: CharacterDictionarySnapshot): boolean => { if (deps.getNameMatchImagesEnabled?.() !== true) { return false; } return !snapshotHasCharacterNameImages(snapshot); }; const createAniListRequestSlot = (): (() => Promise) => { let hasAniListRequest = false; return async (): Promise => { if (!hasAniListRequest) { hasAniListRequest = true; return; } await sleepMs(ANILIST_REQUEST_DELAY_MS); }; }; const resolveGuessInput = ( targetPath?: string, ): { mediaPath: string | null; mediaTitle: string | null } => { const dictionaryTarget = targetPath?.trim() || ''; return dictionaryTarget.length > 0 ? resolveDictionaryGuessInputs(dictionaryTarget) : { mediaPath: deps.getCurrentMediaPath(), mediaTitle: deps.getCurrentMediaTitle(), }; }; const guessCurrentMedia = async (targetPath?: string) => { const guessInput = resolveGuessInput(targetPath); const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath); const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, guessInput.mediaTitle); if (!guessed || !guessed.title.trim()) { throw new Error('Unable to resolve current anime from media path/title.'); } return { guessed, seriesKey: buildCharacterDictionarySeriesKey({ mediaPath: mediaPathForGuess, mediaTitle: guessInput.mediaTitle, guess: guessed, }), unscopedSeriesKey: buildCharacterDictionarySeriesKey({ mediaPath: null, mediaTitle: guessInput.mediaTitle, guess: guessed, }), }; }; const findCachedSnapshotForSeriesKey = ( seriesKey: string, fallbackSeriesKey?: string, ): CharacterDictionarySnapshot | null => { const acceptedKeys = new Set([seriesKey, fallbackSeriesKey].filter(Boolean)); return ( readCachedSnapshots(outputDir).find((snapshot) => { const snapshotSeriesKey = buildCharacterDictionarySeriesKey({ mediaPath: null, mediaTitle: snapshot.mediaTitle, guess: { title: snapshot.mediaTitle, season: null, episode: null, source: 'fallback', }, }); return acceptedKeys.has(snapshotSeriesKey); }) ?? null ); }; const resolveCurrentMedia = async ( targetPath?: string, beforeRequest?: () => Promise, ): Promise => { deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation'); const { guessed, seriesKey, unscopedSeriesKey } = await guessCurrentMedia(targetPath); deps.logInfo?.( `[dictionary] current anime guess: ${guessed.title.trim()}${ typeof guessed.episode === 'number' && guessed.episode > 0 ? ` (episode ${guessed.episode})` : '' }`, ); const override = await manualSelectionStore.getOverride(seriesKey); if (override) { deps.logInfo?.( `[dictionary] manual AniList override: ${override.mediaTitle} -> AniList ${override.mediaId}`, ); return { id: override.mediaId, title: override.mediaTitle, 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, unscopedSeriesKey); 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; }; const getOrCreateSnapshot = async ( mediaId: number, mediaTitleHint?: string, beforeRequest?: () => Promise, progress?: CharacterDictionarySnapshotProgressCallbacks, ): Promise => { const snapshotPath = getSnapshotPath(outputDir, mediaId); const cachedSnapshot = readSnapshot(snapshotPath); if (cachedSnapshot && !shouldRefreshCachedSnapshot(cachedSnapshot)) { deps.logInfo?.(`[dictionary] snapshot hit for AniList ${mediaId}`); return { mediaId: cachedSnapshot.mediaId, mediaTitle: cachedSnapshot.mediaTitle, entryCount: cachedSnapshot.entryCount, fromCache: true, updatedAt: cachedSnapshot.updatedAt, }; } if (cachedSnapshot) { deps.logInfo?.( `[dictionary] snapshot stale for AniList ${mediaId}: missing cached character images`, ); } progress?.onGenerating?.({ mediaId, mediaTitle: mediaTitleHint || `AniList ${mediaId}`, }); deps.logInfo?.(`[dictionary] snapshot miss for AniList ${mediaId}, fetching characters`); const { mediaTitle: fetchedMediaTitle, characters } = await fetchCharactersForMedia( mediaId, beforeRequest, (page) => { deps.logInfo?.( `[dictionary] downloaded AniList character page ${page} for AniList ${mediaId}`, ); }, ); if (characters.length === 0) { throw new Error(`No characters returned for AniList media ${mediaId}.`); } 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 && !seenVaIds.has(va.id)) { seenVaIds.add(va.id); allImageUrls.push({ id: va.id, url: va.imageUrl, kind: 'va' }); } } } if (allImageUrls.length > 0) { deps.logInfo?.( `[dictionary] downloading ${allImageUrls.length} images for AniList ${mediaId}`, ); } let hasAttemptedImageDownload = false; for (const entry of allImageUrls) { if (hasAttemptedImageDownload) { await sleepMs(CHARACTER_IMAGE_DOWNLOAD_DELAY_MS); } hasAttemptedImageDownload = true; const image = await downloadCharacterImage(entry.url, entry.id); if (!image) continue; if (entry.kind === 'character') { imagesByCharacterId.set(entry.id, { path: buildSnapshotImagePath(mediaId, entry.id, image.ext), dataBase64: image.bytes.toString('base64'), }); } else { imagesByVaId.set(entry.id, { path: buildVaImagePath(mediaId, entry.id, image.ext), dataBase64: image.bytes.toString('base64'), }); } } const snapshot = buildSnapshotFromCharacters( mediaId, fetchedMediaTitle || mediaTitleHint || `AniList ${mediaId}`, characters, imagesByCharacterId, imagesByVaId, deps.now(), getCollapsibleSectionOpenState, ); writeSnapshot(snapshotPath, snapshot); deps.logInfo?.( `[dictionary] stored snapshot for AniList ${mediaId}: ${snapshot.entryCount} terms`, ); return { mediaId: snapshot.mediaId, mediaTitle: snapshot.mediaTitle, entryCount: snapshot.entryCount, fromCache: false, updatedAt: snapshot.updatedAt, }; }; return { getOrCreateCurrentSnapshot: async ( targetPath?: string, progress?: CharacterDictionarySnapshotProgressCallbacks, ) => { const waitForAniListRequestSlot = createAniListRequestSlot(); const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot); progress?.onChecking?.({ mediaId: resolvedMedia.id, mediaTitle: resolvedMedia.title, }); const snapshot = await getOrCreateSnapshot( resolvedMedia.id, resolvedMedia.title, waitForAniListRequestSlot, progress, ); return { ...snapshot, staleMediaIds: resolvedMedia.staleMediaIds, }; }, buildMergedDictionary: async (mediaIds: number[]) => { const normalizedMediaIds = normalizeMergedMediaIds(mediaIds); const snapshotResults = await Promise.all( normalizedMediaIds.map((mediaId) => getOrCreateSnapshot(mediaId)), ); const snapshots = snapshotResults.map(({ mediaId }) => { const snapshot = readSnapshot(getSnapshotPath(outputDir, mediaId)); if (!snapshot) { throw new Error(`Missing character dictionary snapshot for AniList ${mediaId}.`); } return snapshot; }); const revision = buildMergedRevision(normalizedMediaIds, snapshots); const description = snapshots.length === 1 ? `Character names from ${snapshots[0]!.mediaTitle}` : `Character names from ${snapshots.length} recent anime`; const { zipPath, entryCount } = buildDictionaryZip( getMergedZipPath(outputDir), CHARACTER_DICTIONARY_MERGED_TITLE, description, revision, applyCollapsibleOpenStatesToTermEntries( snapshots.flatMap((snapshot) => snapshot.termEntries), getCollapsibleSectionOpenState, ), snapshots.flatMap((snapshot) => snapshot.images), ); deps.logInfo?.( `[dictionary] rebuilt merged dictionary: ${normalizedMediaIds.join(', ') || ''} -> ${zipPath}`, ); return { zipPath, revision, dictionaryTitle: CHARACTER_DICTIONARY_MERGED_TITLE, entryCount, }; }, getManualSelectionSnapshot: async (targetPath?: string, searchTitle?: string) => { const waitForAniListRequestSlot = createAniListRequestSlot(); const { guessed, seriesKey } = await guessCurrentMedia(targetPath); const normalizedSearchTitle = searchTitle?.trim(); const shouldUseExplicitSearch = searchTitle !== undefined; const candidateSearchTitle = shouldUseExplicitSearch ? normalizedSearchTitle : guessed.title; const candidates = candidateSearchTitle ? await searchAniListMediaCandidates(candidateSearchTitle, waitForAniListRequestSlot) : []; const [override, current] = await Promise.all([ manualSelectionStore.getOverride(seriesKey), shouldUseExplicitSearch ? Promise.resolve(null) : resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot) .then( (entry): AniListMediaCandidate => ({ id: entry.id, title: entry.title, episodes: candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null, }), ) .catch(() => null), ]); const overrideCandidate = override ? candidates.find((candidate) => candidate.id === override.mediaId) : null; return { seriesKey, guessTitle: guessed.title, current, override: override ? { id: override.mediaId, title: override.mediaTitle, episodes: overrideCandidate?.episodes ?? null, } : null, candidates, }; }, setManualSelection: async ({ targetPath, mediaId }) => { const waitForAniListRequestSlot = createAniListRequestSlot(); const { guessed, seriesKey } = await guessCurrentMedia(targetPath); const [selected, current] = await Promise.all([ fetchAniListMediaCandidateById(mediaId, waitForAniListRequestSlot), resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot).catch(() => null), ]); const staleMediaIds = current && current.id !== selected.id ? [current.id] : []; await manualSelectionStore.setOverride({ seriesKey, mediaId: selected.id, mediaTitle: selected.title, staleMediaIds, }); return { ok: true, seriesKey, selected, staleMediaIds, }; }, generateForCurrentMedia: async ( targetPath?: string, _options?: CharacterDictionaryGenerateOptions, ) => { const waitForAniListRequestSlot = createAniListRequestSlot(); const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot); const snapshot = await getOrCreateSnapshot( resolvedMedia.id, resolvedMedia.title, waitForAniListRequestSlot, ); const storedSnapshot = readSnapshot(getSnapshotPath(outputDir, resolvedMedia.id)); if (!storedSnapshot) { throw new Error(`Snapshot missing after generation for AniList ${resolvedMedia.id}.`); } const revision = String(storedSnapshot.updatedAt); const dictionaryTitle = buildDictionaryTitle(resolvedMedia.id); const description = `Character names from ${storedSnapshot.mediaTitle} [AniList media ID ${resolvedMedia.id}]`; const zipPath = path.join(outputDir, `anilist-${resolvedMedia.id}.zip`); deps.logInfo?.(`[dictionary] building ZIP for AniList ${resolvedMedia.id}`); buildDictionaryZip( zipPath, dictionaryTitle, description, revision, applyCollapsibleOpenStatesToTermEntries( storedSnapshot.termEntries, getCollapsibleSectionOpenState, ), storedSnapshot.images, ); deps.logInfo?.( `[dictionary] generated AniList ${resolvedMedia.id}: ${storedSnapshot.entryCount} terms -> ${zipPath}`, ); return { zipPath, fromCache: snapshot.fromCache, mediaId: resolvedMedia.id, mediaTitle: storedSnapshot.mediaTitle, entryCount: storedSnapshot.entryCount, dictionaryTitle, revision, }; }, }; }