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, readSnapshot, 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, fetchCharactersForMedia, resolveAniListMediaIdFromGuess, } from './character-dictionary-runtime/fetch'; import type { CharacterDictionaryBuildResult, CharacterDictionaryGenerateOptions, CharacterDictionaryRuntimeDeps, 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; 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 resolveCurrentMedia = async ( targetPath?: string, beforeRequest?: () => Promise, ): Promise => { deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation'); const dictionaryTarget = targetPath?.trim() || ''; const guessInput = dictionaryTarget.length > 0 ? resolveDictionaryGuessInputs(dictionaryTarget) : { mediaPath: deps.getCurrentMediaPath(), mediaTitle: deps.getCurrentMediaTitle(), }; const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath); const mediaTitle = guessInput.mediaTitle; const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, mediaTitle); if (!guessed || !guessed.title.trim()) { throw new Error('Unable to resolve current anime from media path/title.'); } deps.logInfo?.( `[dictionary] current anime guess: ${guessed.title.trim()}${ typeof guessed.episode === 'number' && guessed.episode > 0 ? ` (episode ${guessed.episode})` : '' }`, ); const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest); 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) { deps.logInfo?.(`[dictionary] snapshot hit for AniList ${mediaId}`); return { mediaId: cachedSnapshot.mediaId, mediaTitle: cachedSnapshot.mediaTitle, entryCount: cachedSnapshot.entryCount, fromCache: true, updatedAt: cachedSnapshot.updatedAt, }; } 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, ) => { let hasAniListRequest = false; const waitForAniListRequestSlot = async (): Promise => { if (!hasAniListRequest) { hasAniListRequest = true; return; } await sleepMs(ANILIST_REQUEST_DELAY_MS); }; const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot); progress?.onChecking?.({ mediaId: resolvedMedia.id, mediaTitle: resolvedMedia.title, }); return getOrCreateSnapshot( resolvedMedia.id, resolvedMedia.title, waitForAniListRequestSlot, progress, ); }, 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, }; }, generateForCurrentMedia: async ( targetPath?: string, _options?: CharacterDictionaryGenerateOptions, ) => { let hasAniListRequest = false; const waitForAniListRequestSlot = async (): Promise => { if (!hasAniListRequest) { hasAniListRequest = true; return; } await sleepMs(ANILIST_REQUEST_DELAY_MS); }; 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, }; }, }; }