mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
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:
1 parent
a9a4c4f083
commit
2924f04316
23 files changed
+3173
-2010
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user