import { clearYomitanParserCachesForWindow, deleteYomitanDictionaryByTitle, getYomitanDictionaryInfo, importYomitanDictionaryFromZip, upsertYomitanDictionarySettings, } from '../../core/services'; import { guessAnilistMediaInfo } from '../../core/services/anilist/anilist-updater'; import type { Logger } from '../../logger'; import type { ResolvedConfig } from '../../types'; import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime'; import { createCharacterDictionaryImageLookup } from '../character-dictionary-runtime/image-lookup'; import { createCharacterNameCandidateLookup } from '../character-dictionary-runtime/name-candidates'; import { applyCharacterDictionarySelection } from '../character-dictionary-selection'; import { createCharacterDictionaryAutoSyncRuntimeService, getCharacterDictionaryManagerSnapshot, moveCharacterDictionaryManagedEntry, removeCharacterDictionaryManagedEntry, replaceCharacterDictionaryManagedEntry, } from '../runtime/character-dictionary-auto-sync'; import { handleCharacterDictionaryAutoSyncComplete } from '../runtime/character-dictionary-auto-sync-completion'; import { notifyCharacterDictionaryAutoSyncStatus, type CharacterDictionaryAutoSyncNotificationDeps, } from '../runtime/character-dictionary-auto-sync-notifications'; import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime'; import { assertYomitanDictionaryMutationSafe } from '../runtime/yomitan-dictionary-integrity'; import type { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy'; import { formatSkippedYomitanWriteAction } from '../runtime/yomitan-read-only-log'; import type { AppState } from '../state'; import type { CliSlice, IpcSlice } from './slices'; type YomitanParserRuntimeDeps = Parameters[0]; /** * What the character dictionary needs from the rest of the app. Siblings built after this * assembly in `main.ts` are reached through getters or callbacks, which run only once the app does. */ export interface CharacterDictionaryAssemblyDeps { userDataPath: string; appState: Pick< AppState, | 'currentMediaPath' | 'mpvClient' | 'currentMediaTitle' | 'mecabTokenizer' | 'yomitanParserWindow' | 'currentSubText' >; getConfig: () => ResolvedConfig; logger: Pick; startupOsdSequencer: CharacterDictionaryAutoSyncNotificationDeps['startupOsdSequencer']; resolveMediaPathForJimaku: (mediaPath: string | null) => string | null; getYomitanProfilePolicy: () => Pick< ReturnType, | 'isCharacterDictionaryEnabled' | 'getCharacterDictionaryDisabledReason' | 'isExternalReadOnlyMode' | 'logSkippedWrite' >; isYoutubePlaybackActive: () => boolean; waitForMediaTokenizationReady: (mediaPath: string | null) => Promise; ensureYomitanExtensionLoaded: () => Promise; getYomitanParserRuntimeDeps: () => YomitanParserRuntimeDeps; getOverlayNotifications: () => Pick< ReturnType, 'getConfiguredStatusNotificationType' | 'showOverlayNotification' >; showMpvOsd: (message: string) => void; showDesktopNotification: CharacterDictionaryAutoSyncNotificationDeps['showDesktopNotification']; invalidateTokenizationCache: () => void; refreshCurrentSubtitle: (text: string) => void; /** Re-runs subtitle prefetch from the current playback position. */ refreshSubtitlePrefetch: () => void; refreshCurrentSubtitleAnnotations: () => void; /** A manual selection invalidates AniList's guess for the current media. */ resetAnilistMediaGuessState: () => void; } export type CharacterDictionaryAssembly = ReturnType; /** * Owns the AniList character dictionary: snapshot generation, auto-sync into Yomitan, and the * portrait and name-candidate lookups the overlay reads. `main.ts` builds it once. */ export function createCharacterDictionaryAssembly(deps: CharacterDictionaryAssemblyDeps) { const { appState, logger } = deps; const runtime = createCharacterDictionaryRuntimeService({ userDataPath: deps.userDataPath, getCurrentMediaPath: () => appState.currentMediaPath, getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath, getCurrentMediaTitle: () => appState.currentMediaTitle, resolveMediaPathForJimaku: (mediaPath) => deps.resolveMediaPathForJimaku(mediaPath), guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), getNameMatchImagesEnabled: () => deps.getConfig().subtitleStyle.nameMatchImagesEnabled, getCollapsibleSectionOpenState: (section) => deps.getConfig().anilist.characterDictionary.collapsibleSections[section], tokenizeJapaneseName: async (text) => (await appState.mecabTokenizer?.tokenize(text)) ?? null, getJapaneseNameTokenizerAvailable: () => { const status = appState.mecabTokenizer?.getStatus(); return status?.available === true && status.enabled === true; }, now: () => Date.now(), logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), }); const autoSync = createCharacterDictionaryAutoSyncRuntimeService({ userDataPath: deps.userDataPath, getConfig: () => { const config = deps.getConfig().anilist.characterDictionary; return { enabled: deps.getConfig().subtitleStyle.nameMatchEnabled && deps.getYomitanProfilePolicy().isCharacterDictionaryEnabled() && !deps.isYoutubePlaybackActive(), maxLoaded: config.maxLoaded, profileScope: config.profileScope, }; }, getOrCreateCurrentSnapshot: (targetPath, progress) => runtime.getOrCreateCurrentSnapshot(targetPath, progress), buildMergedDictionary: (mediaIds) => runtime.buildMergedDictionary(mediaIds), waitForYomitanMutationReady: () => deps.waitForMediaTokenizationReady( appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, ), getYomitanDictionaryInfo: async () => { await deps.ensureYomitanExtensionLoaded(); const dictionaries = await getYomitanDictionaryInfo(deps.getYomitanParserRuntimeDeps(), { error: (message, ...args) => logger.error(message, ...args), info: (message, ...args) => logger.info(message, ...args), }); assertYomitanDictionaryMutationSafe(deps.userDataPath, dictionaries.length); return dictionaries; }, importYomitanDictionary: async (zipPath) => { const yomitanProfilePolicy = deps.getYomitanProfilePolicy(); if (yomitanProfilePolicy.isExternalReadOnlyMode()) { yomitanProfilePolicy.logSkippedWrite( formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath), ); return false; } await deps.ensureYomitanExtensionLoaded(); return await importYomitanDictionaryFromZip(zipPath, deps.getYomitanParserRuntimeDeps(), { error: (message, ...args) => logger.error(message, ...args), info: (message, ...args) => logger.info(message, ...args), warn: (message, ...args) => logger.warn(message, ...args), }); }, dictionaryImportReplacesExisting: () => deps.getYomitanParserRuntimeDeps().getYomitanExt()?.name === 'Hachidori', deleteYomitanDictionary: async (dictionaryTitle) => { const yomitanProfilePolicy = deps.getYomitanProfilePolicy(); if (yomitanProfilePolicy.isExternalReadOnlyMode()) { yomitanProfilePolicy.logSkippedWrite( formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle), ); return false; } await deps.ensureYomitanExtensionLoaded(); return await deleteYomitanDictionaryByTitle( dictionaryTitle, deps.getYomitanParserRuntimeDeps(), { error: (message, ...args) => logger.error(message, ...args), info: (message, ...args) => logger.info(message, ...args), }, ); }, upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { const yomitanProfilePolicy = deps.getYomitanProfilePolicy(); if (yomitanProfilePolicy.isExternalReadOnlyMode()) { yomitanProfilePolicy.logSkippedWrite( formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle), ); return false; } await deps.ensureYomitanExtensionLoaded(); return await upsertYomitanDictionarySettings( dictionaryTitle, profileScope, deps.getYomitanParserRuntimeDeps(), { error: (message, ...args) => logger.error(message, ...args), info: (message, ...args) => logger.info(message, ...args), }, ); }, now: () => Date.now(), schedule: (fn, delayMs) => setTimeout(fn, delayMs), clearSchedule: (timer) => clearTimeout(timer), logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), onSyncStatus: (event) => { const notifications = deps.getOverlayNotifications(); notifyCharacterDictionaryAutoSyncStatus(event, { getNotificationType: () => notifications.getConfiguredStatusNotificationType(), showOsd: (message) => deps.showMpvOsd(message), showOverlayNotification: (payload) => notifications.showOverlayNotification(payload), showDesktopNotification: (title, options) => deps.showDesktopNotification(title, options), startupOsdSequencer: deps.startupOsdSequencer, }); }, onSyncComplete: ({ mediaId, mediaTitle, changed }) => { handleCharacterDictionaryAutoSyncComplete( { mediaId, mediaTitle, changed, }, { hasParserWindow: () => Boolean(appState.yomitanParserWindow), invalidateCharacterDictionaryLookups: () => { imageLookup.invalidate(); nameCandidates.invalidate(); }, clearParserCaches: () => { if (appState.yomitanParserWindow) { clearYomitanParserCachesForWindow(appState.yomitanParserWindow); } }, invalidateTokenizationCache: () => { deps.invalidateTokenizationCache(); }, refreshSubtitlePrefetch: () => { deps.refreshSubtitlePrefetch(); }, refreshCurrentSubtitle: () => { deps.refreshCurrentSubtitle(appState.currentSubText); }, logInfo: (message) => logger.info(message), }, ); }, }); const imageLookup = createCharacterDictionaryImageLookup({ userDataPath: deps.userDataPath, getCurrentMediaId: () => autoSync.getCurrentMediaId(), onIndexReady: () => deps.refreshCurrentSubtitleAnnotations(), onIndexReadyError: (error) => logger.warn( 'Failed to refresh subtitle annotations after character portrait index became ready.', error, ), }); // Lets the Yomitan scan runtime skip name lookups at positions where no // character name can start; absent candidates just mean the exhaustive scan. const nameCandidates = createCharacterNameCandidateLookup({ userDataPath: deps.userDataPath, getCurrentMediaId: () => autoSync.getCurrentMediaId(), }); const selectionDeps = { setManualSelection: (request: Parameters[0]) => runtime.setManualSelection(request), resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), runSyncNow: () => autoSync.runSyncNow(), warn: (message: string, error?: unknown) => logger.warn(message, error), }; /** Manager edits that change the merged dictionary rebuild it; a failed rebuild is logged. */ async function rebuildAfterManagerChange( result: T, change: 'override' | 'removal' | 'reorder', ): Promise { if (result.ok && result.rebuildRequired) { try { await autoSync.runSyncNow(); } catch (error) { logger.warn(`Failed to rebuild character dictionary after manager ${change}:`, error); } } return result; } const ipc: IpcSlice< | 'getCharacterDictionarySelection' | 'setCharacterDictionarySelection' | 'getCharacterDictionaryManagerSnapshot' | 'removeCharacterDictionaryManagedEntry' | 'moveCharacterDictionaryManagedEntry' > = { getCharacterDictionarySelection: (searchTitle?: string) => runtime.getManualSelectionSnapshot(undefined, searchTitle), setCharacterDictionarySelection: async ( mediaId: number, replaceManagedMediaId?: number, mediaTitle?: string, ) => { if (replaceManagedMediaId !== undefined && mediaTitle) { return rebuildAfterManagerChange( replaceCharacterDictionaryManagedEntry(deps.userDataPath, replaceManagedMediaId, { mediaId, mediaTitle, }), 'override', ); } return await applyCharacterDictionarySelection({ mediaId }, selectionDeps); }, getCharacterDictionaryManagerSnapshot: async () => getCharacterDictionaryManagerSnapshot(deps.userDataPath, autoSync.getCurrentMediaId()), removeCharacterDictionaryManagedEntry: async (mediaId: number) => rebuildAfterManagerChange( removeCharacterDictionaryManagedEntry( deps.userDataPath, mediaId, autoSync.getCurrentMediaId(), ), 'removal', ), moveCharacterDictionaryManagedEntry: async (mediaId: number, direction: 1 | -1) => rebuildAfterManagerChange( moveCharacterDictionaryManagedEntry( deps.userDataPath, mediaId, direction, autoSync.getCurrentMediaId(), ), 'reorder', ), }; const cli: CliSlice< | 'generateCharacterDictionary' | 'getCharacterDictionarySelection' | 'setCharacterDictionarySelection' > = { generateCharacterDictionary: async (targetPath?: string) => { const disabledReason = deps.getYomitanProfilePolicy().getCharacterDictionaryDisabledReason(); if (disabledReason) { throw new Error(disabledReason); } return await runtime.generateForCurrentMedia(targetPath); }, getCharacterDictionarySelection: async (targetPath?: string) => runtime.getManualSelectionSnapshot(targetPath), setCharacterDictionarySelection: async (request) => applyCharacterDictionarySelection(request, selectionDeps), }; return { runtime, autoSync, imageLookup, nameCandidates, ipc, cli }; }