refactor(main): move domains into per-domain assemblies (#309)

* refactor(main): move the YouTube domain into an assembly

createYoutubeAssembly owns the YouTube flow, playback, media cache,
whisper subtitles, primary-subtitle notifications, and the in-app
browser, plus the browser quit-on-close state. main.ts builds it once
where the first YouTube runtime used to be and calls through the result.
Siblings built later are passed as getters or callbacks; tests pin that
construction calls none of them.

* docs(architecture): describe per-domain assemblies

* test(e2e): blank the line before mining the sub-delay cue

The previous test leaves the same cue on screen, so the overlay check
passed before the seek landed and --mine-sentence could read mpv's empty
mid-seek sub-text and skip the card.

* refactor(main): move the Jellyfin domain into an assembly

createJellyfinAssembly owns the Jellyfin composer call, its playback and
remote-progress state, the subtitle cache IO, mpv defaults, and the tray
discovery deps. main.ts builds it where the composer call used to be and
reads state through getActivePlayback/getQuitOnDisconnectArmed.

* refactor(main): move the AniList domain into an assembly

createAnilistAssembly owns the AniList setup and tracking composer calls,
the setup window, the state runtime, and the token, media-guess, in-flight
and attempted-update state they share. main.ts builds it where the setup
composer call used to be and keeps the rate limiter, which the stats
cover-art fetcher also uses.

* refactor(main): move config hot-reload, settings, and sync UI into assemblies

createSettingsAssembly owns config hot-reload and the settings window,
which share the hot-reload-applied handler; main.ts still supplies that
handler's cross-domain deps and the Yomitan deck-name helper.
createSyncAssembly owns the sync window, sync IPC runtime, and auto-sync
scheduler. Both are built where their blocks were; main.ts keeps the
registerHandlers()/start() calls at the same points.

* refactor(main): move dictionary runtimes into assemblies

createDictionariesAssembly owns the JLPT and frequency lookups and their
dictionary-root builders; main.ts still resolves the root paths at the
same point. createCharacterDictionaryAssembly owns the character
dictionary runtime, Yomitan auto-sync, and the portrait and name-candidate
lookups. Both are built where their blocks were; later siblings (Yomitan
profile policy, notifications, OSD, subtitle refresh) come in as getters
or callbacks.

* refactor(main): move overlay modal openers into an assembly

createOverlayModalsAssembly owns the shared open-with-notification helper
and the runtime options, Jimaku, TsukiHime, subtitle generation, session
help, changelog, character dictionary manager, controller, and playlist
browser openers. main.ts builds it where the openers used to live.

* refactor(logger): export the Logger type for assembly deps

The build emits declarations, and the Jellyfin assembly returns the logger
in its tray discovery deps, so tsc needs a nameable Logger type. Assemblies
now Pick from it directly.
This commit is contained in:
sudacode authored and GitHub committed 2026-10-31 19:39:27 -07:00
1 parent a9a4c4f083
commit 2924f04316
23 files changed
+3173 -2010

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
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 { createCharacterDictionaryAutoSyncRuntimeService } 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';
type YomitanParserRuntimeDeps = Parameters<typeof getYomitanDictionaryInfo>[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<Logger, 'info' | 'warn' | 'error'>;
startupOsdSequencer: CharacterDictionaryAutoSyncNotificationDeps['startupOsdSequencer'];
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
getYomitanProfilePolicy: () => Pick<
ReturnType<typeof createYomitanProfilePolicy>,
'isCharacterDictionaryEnabled' | 'isExternalReadOnlyMode' | 'logSkippedWrite'
>;
isYoutubePlaybackActive: () => boolean;
waitForMediaTokenizationReady: (mediaPath: string | null) => Promise<void>;
ensureYomitanExtensionLoaded: () => Promise<unknown>;
getYomitanParserRuntimeDeps: () => YomitanParserRuntimeDeps;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'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;
}
export type CharacterDictionaryAssembly = ReturnType<typeof createCharacterDictionaryAssembly>;
/**
* 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(),
});
return { runtime, autoSync, imageLookup, nameCandidates };
}