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

+1 -1
View File
@@ -14,7 +14,7 @@ export type LogLevelSource = 'cli' | 'config';
type LogMethod = (message: string, ...meta: unknown[]) => void;
type Logger = {
export type Logger = {
debug: LogMethod;
info: LogMethod;
warn: LogMethod;
+369 -2005
View File
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
import { createAnilistAssembly, type AnilistAssemblyDeps } from './anilist';
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(): AnilistAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
appState: {
mpvClient: null,
currentMediaPath: null,
currentMediaTitle: null,
anilistClientSecretState: {
status: 'not_checked',
source: 'none',
message: null,
resolvedAt: null,
errorAt: null,
},
anilistRetryQueueState: {
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
},
anilistSetupPageOpened: false,
anilistSetupWindow: null,
},
configService: { getConfig: lazy('getConfig') },
logger: { info: lazy('info'), warn: lazy('warn'), debug: lazy('debug'), error: lazy('error') },
anilistTokenStore: {
saveToken: lazy('saveToken'),
loadToken: lazy('loadToken'),
clearToken: lazy('clearToken'),
},
anilistUpdateQueue: {
enqueue: lazy('enqueue'),
nextReady: lazy('nextReady'),
markSuccess: lazy('markSuccess'),
markFailure: lazy('markFailure'),
getSnapshot: lazy('getSnapshot'),
},
anilistRateLimiter: createAnilistRateLimiter(),
getOverlayNotifications: lazy('getOverlayNotifications'),
showDesktopNotification: lazy('showDesktopNotification'),
resolveMediaPathForJimaku: lazy('resolveMediaPathForJimaku'),
resolvePinnedMediaId: lazy('resolvePinnedMediaId'),
createBrowserWindow: lazy('createBrowserWindow'),
shell: { openExternal: lazy('openExternal') },
app: { setAsDefaultProtocolClient: lazy('setAsDefaultProtocolClient') },
};
}
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createAnilistAssembly does not call its deps while constructing', () => {
const anilist = createAnilistAssembly(makeDeps());
assert.equal(anilist.getMediaDurationSec(), null);
});
+527
View File
@@ -0,0 +1,527 @@
import path from 'node:path';
import type { App, BrowserWindow, BrowserWindowConstructorOptions, Shell } from 'electron';
import type { ConfigService } from '../../config/service';
import type { createAnilistTokenStore } from '../../core/services/anilist/anilist-token-store';
import type { createAnilistUpdateQueue } from '../../core/services/anilist/anilist-update-queue';
import {
guessAnilistMediaInfo,
updateAnilistPostWatchProgress,
} from '../../core/services/anilist/anilist-updater';
import type { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
import type { Logger } from '../../logger';
import { DEFAULT_MIN_WATCH_RATIO } from '../../shared/watch-threshold';
import type { ResolvedConfig } from '../../types';
import {
isAllowedAnilistExternalUrl,
isAllowedAnilistSetupNavigationUrl,
} from '../anilist-url-guard';
import { composeAnilistSetupHandlers, composeAnilistTrackingHandlers } from '../runtime/composers';
import {
buildAnilistSetupUrl,
consumeAnilistSetupCallbackUrl,
createAnilistStateRuntime,
createMaybeFocusExistingAnilistSetupWindowHandler,
createOpenAnilistSetupWindowHandler,
isAnilistTrackingEnabled,
loadAnilistManualTokenEntry,
openAnilistSetupInBrowser,
rememberAnilistAttemptedUpdateKey,
} from '../runtime/domains/anilist';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createCreateAnilistSetupWindowHandler } from '../runtime/setup-window-factory';
import {
type AnilistMediaGuessRuntimeState,
type AppState,
createInitialAnilistMediaGuessRuntimeState,
createInitialAnilistUpdateInFlightState,
transitionAnilistClientSecretState,
transitionAnilistMediaGuessRuntimeState,
transitionAnilistRetryQueueLastAttemptAt,
transitionAnilistRetryQueueLastError,
transitionAnilistRetryQueueState,
transitionAnilistUpdateInFlightState,
} from '../state';
const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize';
const ANILIST_SETUP_RESPONSE_TYPE = 'token';
const ANILIST_DEFAULT_CLIENT_ID = '36084';
const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/';
const ANILIST_DEVELOPER_SETTINGS_URL = 'https://anilist.co/settings/developer';
const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
type TrackingComposerOptions = Parameters<typeof composeAnilistTrackingHandlers>[0];
/**
* What the AniList domain needs from the rest of the app. Siblings built after this assembly in
* `main.ts` are reached through getters or callbacks, which are only called once the app runs.
*/
export interface AnilistAssemblyDeps {
appState: Pick<
AppState,
| 'mpvClient'
| 'currentMediaPath'
| 'currentMediaTitle'
| 'anilistClientSecretState'
| 'anilistRetryQueueState'
| 'anilistSetupPageOpened'
| 'anilistSetupWindow'
>;
configService: Pick<ConfigService, 'getConfig'>;
logger: Pick<Logger, 'info' | 'warn' | 'debug' | 'error'>;
anilistTokenStore: ReturnType<typeof createAnilistTokenStore>;
anilistUpdateQueue: ReturnType<typeof createAnilistUpdateQueue>;
/** Shared with the stats cover-art fetcher, so `main.ts` owns it. */
anilistRateLimiter: ReturnType<typeof createAnilistRateLimiter>;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
| 'getConfiguredStatusNotificationType'
| 'showConfiguredStatusNotification'
| 'showOverlayNotification'
>;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
resolveMediaPathForJimaku: TrackingComposerOptions['ensureMediaGuessMainDeps']['resolveMediaPathForJimaku'];
resolvePinnedMediaId: NonNullable<
TrackingComposerOptions['maybeRunPostWatchUpdateMainDeps']['resolvePinnedAnilistMediaId']
>;
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
shell: Pick<Shell, 'openExternal'>;
app: Pick<App, 'setAsDefaultProtocolClient'>;
}
export type AnilistAssembly = ReturnType<typeof createAnilistAssembly>;
/**
* Owns AniList login/setup, token and client-secret state, media tracking, and post-watch
* progress updates with their retry queue. `main.ts` builds it once and calls through the result.
*/
export function createAnilistAssembly(deps: AnilistAssemblyDeps) {
const {
appState,
configService,
logger,
anilistTokenStore,
anilistUpdateQueue,
anilistRateLimiter,
} = deps;
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
createInitialAnilistMediaGuessRuntimeState();
let anilistUpdateInFlightState = createInitialAnilistUpdateInFlightState();
const anilistAttemptedUpdateKeys = new Set<string>();
let anilistCachedAccessToken: string | null = null;
const anilistStateRuntime = createAnilistStateRuntime({
getClientSecretState: () => appState.anilistClientSecretState,
setClientSecretState: (next) => {
appState.anilistClientSecretState = transitionAnilistClientSecretState(
appState.anilistClientSecretState,
next,
);
},
getRetryQueueState: () => appState.anilistRetryQueueState,
setRetryQueueState: (next) => {
appState.anilistRetryQueueState = transitionAnilistRetryQueueState(
appState.anilistRetryQueueState,
next,
);
},
getUpdateQueueSnapshot: () => anilistUpdateQueue.getSnapshot(),
clearStoredToken: () => anilistTokenStore.clearToken(),
clearCachedAccessToken: () => {
anilistCachedAccessToken = null;
},
});
const {
notifyAnilistSetup,
consumeAnilistSetupTokenFromUrl,
handleAnilistSetupProtocolUrl,
registerSubminerProtocolClient,
} = composeAnilistSetupHandlers({
notifyDeps: {
getNotificationType: () =>
deps.getOverlayNotifications().getConfiguredStatusNotificationType(),
hasMpvClient: () => Boolean(appState.mpvClient),
showMpvOsd: (message) =>
deps
.getOverlayNotifications()
.showConfiguredStatusNotification(message, { title: 'AniList' }),
showOverlayNotification: (payload) =>
deps.getOverlayNotifications().showOverlayNotification(payload),
showDesktopNotification: (title, options) => deps.showDesktopNotification(title, options),
logInfo: (message) => logger.info(message),
},
consumeTokenDeps: {
consumeAnilistSetupCallbackUrl,
saveToken: (token) => anilistTokenStore.saveToken(token),
setCachedToken: (token) => {
anilistCachedAccessToken = token;
},
setResolvedState: (resolvedAt) => {
anilistStateRuntime.setClientSecretState({
status: 'resolved',
source: 'stored',
message: 'saved token from AniList login',
resolvedAt,
errorAt: null,
});
},
setSetupPageOpened: (opened) => {
appState.anilistSetupPageOpened = opened;
},
onSuccess: () => {
notifyAnilistSetup('AniList login success');
},
closeWindow: () => {
if (appState.anilistSetupWindow && !appState.anilistSetupWindow.isDestroyed()) {
appState.anilistSetupWindow.close();
}
},
},
handleProtocolDeps: {
consumeAnilistSetupTokenFromUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl),
logWarn: (message, details) => logger.warn(message, details),
},
registerProtocolClientDeps: {
isDefaultApp: () => Boolean(process.defaultApp),
getArgv: () => process.argv,
execPath: process.execPath,
resolvePath: (value) => path.resolve(value),
setAsDefaultProtocolClient: (scheme, appPath, args) =>
appPath
? deps.app.setAsDefaultProtocolClient(scheme, appPath, args)
: deps.app.setAsDefaultProtocolClient(scheme),
logDebug: (message, details) => logger.debug(message, details),
},
});
const maybeFocusExistingAnilistSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({
getSetupWindow: () => appState.anilistSetupWindow,
});
const openAnilistSetupWindowHandler = createOpenAnilistSetupWindowHandler({
maybeFocusExistingSetupWindow: maybeFocusExistingAnilistSetupWindow,
createSetupWindow: createCreateAnilistSetupWindowHandler({
createBrowserWindow: (options) => deps.createBrowserWindow(options),
}),
buildAuthorizeUrl: () =>
buildAnilistSetupUrl({
authorizeUrl: ANILIST_SETUP_CLIENT_ID_URL,
clientId: ANILIST_DEFAULT_CLIENT_ID,
responseType: ANILIST_SETUP_RESPONSE_TYPE,
}),
consumeCallbackUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl),
openSetupInBrowser: (authorizeUrl) =>
openAnilistSetupInBrowser({
authorizeUrl,
openExternal: (url) => deps.shell.openExternal(url),
logError: (message, error) => logger.error(message, error),
}),
loadManualTokenEntry: (setupWindow, authorizeUrl) =>
loadAnilistManualTokenEntry({
setupWindow: setupWindow as BrowserWindow,
authorizeUrl,
developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL,
logWarn: (message, data) => logger.warn(message, data),
}),
redirectUri: ANILIST_REDIRECT_URI,
developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL,
isAllowedExternalUrl: (url) => isAllowedAnilistExternalUrl(url),
isAllowedNavigationUrl: (url) => isAllowedAnilistSetupNavigationUrl(url),
logWarn: (message, details) => logger.warn(message, details),
logError: (message, details) => logger.error(message, details),
clearSetupWindow: () => {
appState.anilistSetupWindow = null;
},
setSetupPageOpened: (opened) => {
appState.anilistSetupPageOpened = opened;
},
setSetupWindow: (setupWindow) => {
appState.anilistSetupWindow = setupWindow as BrowserWindow;
},
openExternal: (url) => {
void deps.shell.openExternal(url);
},
});
const {
refreshAnilistClientSecretState,
getCurrentAnilistMediaKey,
resetAnilistMediaTracking,
getAnilistMediaGuessRuntimeState,
setAnilistMediaGuessRuntimeState,
recordAnilistMediaDuration,
resetAnilistMediaGuessState,
maybeProbeAnilistDuration,
ensureAnilistMediaGuess,
processNextAnilistRetryUpdate,
maybeRunAnilistPostWatchUpdate,
} = composeAnilistTrackingHandlers({
refreshClientSecretMainDeps: {
getResolvedConfig: () => configService.getConfig(),
isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig),
getCachedAccessToken: () => anilistCachedAccessToken,
setCachedAccessToken: (token) => {
anilistCachedAccessToken = token;
},
saveStoredToken: (token) => {
anilistTokenStore.saveToken(token);
},
loadStoredToken: () => anilistTokenStore.loadToken(),
setClientSecretState: (state) => {
anilistStateRuntime.setClientSecretState(state);
},
getAnilistSetupPageOpened: () => appState.anilistSetupPageOpened,
setAnilistSetupPageOpened: (opened) => {
appState.anilistSetupPageOpened = opened;
},
openAnilistSetupWindow: () => {
openAnilistSetupWindowHandler();
},
now: () => Date.now(),
},
getCurrentMediaKeyMainDeps: {
getCurrentMediaPath: () => appState.currentMediaPath,
},
resetMediaTrackingMainDeps: {
setMediaKey: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaKey: value },
);
},
setMediaDurationSec: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaDurationSec: value },
);
},
setMediaGuess: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuess: value },
);
},
setMediaGuessPromise: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuessPromise: value },
);
},
setLastDurationProbeAtMs: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ lastDurationProbeAtMs: value },
);
},
},
getMediaGuessRuntimeStateMainDeps: {
getMediaKey: () => anilistMediaGuessRuntimeState.mediaKey,
getMediaDurationSec: () => anilistMediaGuessRuntimeState.mediaDurationSec,
getMediaGuess: () => anilistMediaGuessRuntimeState.mediaGuess,
getMediaGuessPromise: () => anilistMediaGuessRuntimeState.mediaGuessPromise,
getLastDurationProbeAtMs: () => anilistMediaGuessRuntimeState.lastDurationProbeAtMs,
},
setMediaGuessRuntimeStateMainDeps: {
setMediaKey: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaKey: value },
);
},
setMediaDurationSec: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaDurationSec: value },
);
},
setMediaGuess: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuess: value },
);
},
setMediaGuessPromise: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuessPromise: value },
);
},
setLastDurationProbeAtMs: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ lastDurationProbeAtMs: value },
);
},
},
recordMediaDurationMainDeps: {
getCurrentMediaKey: () => getCurrentAnilistMediaKey(),
getState: () => getAnilistMediaGuessRuntimeState(),
setState: (state) => {
setAnilistMediaGuessRuntimeState(state);
},
},
resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuess: value },
);
},
setMediaGuessPromise: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuessPromise: value },
);
},
},
maybeProbeDurationMainDeps: {
getState: () => getAnilistMediaGuessRuntimeState(),
setState: (state) => {
setAnilistMediaGuessRuntimeState(state);
},
durationRetryIntervalMs: ANILIST_DURATION_RETRY_INTERVAL_MS,
now: () => Date.now(),
requestMpvDuration: async () => appState.mpvClient?.requestProperty('duration'),
logWarn: (message, error) => logger.warn(message, error),
},
ensureMediaGuessMainDeps: {
getState: () => getAnilistMediaGuessRuntimeState(),
setState: (state) => {
setAnilistMediaGuessRuntimeState(state);
},
resolveMediaPathForJimaku: (currentMediaPath) =>
deps.resolveMediaPathForJimaku(currentMediaPath),
getCurrentMediaPath: () => appState.currentMediaPath,
getCurrentMediaTitle: () => appState.currentMediaTitle,
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
guessAnilistMediaInfo(mediaPath, mediaTitle),
},
processNextRetryUpdateMainDeps: {
nextReady: () => anilistUpdateQueue.nextReady(),
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
setLastAttemptAt: (value) => {
appState.anilistRetryQueueState = transitionAnilistRetryQueueLastAttemptAt(
appState.anilistRetryQueueState,
value,
);
},
setLastError: (value) => {
appState.anilistRetryQueueState = transitionAnilistRetryQueueLastError(
appState.anilistRetryQueueState,
value,
);
},
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
updateAnilistPostWatchProgress: (accessToken, title, episode, season, mediaId) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
season,
mediaId,
logInfo: (message) => logger.info(message),
}),
markSuccess: (key) => {
anilistUpdateQueue.markSuccess(key);
},
rememberAttemptedUpdateKey: (key) => {
rememberAnilistAttemptedUpdate(key);
},
markFailure: (key, message) => {
anilistUpdateQueue.markFailure(key, message);
},
logInfo: (message) => logger.info(message),
now: () => Date.now(),
},
maybeRunPostWatchUpdateMainDeps: {
getInFlight: () => anilistUpdateInFlightState.inFlight,
setInFlight: (value) => {
anilistUpdateInFlightState = transitionAnilistUpdateInFlightState(
anilistUpdateInFlightState,
value,
);
},
getResolvedConfig: () => configService.getConfig(),
isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig),
getCurrentMediaKey: () => getCurrentAnilistMediaKey(),
hasMpvClient: () => Boolean(appState.mpvClient),
getTrackedMediaKey: () => anilistMediaGuessRuntimeState.mediaKey,
resetTrackedMedia: (mediaKey) => {
resetAnilistMediaTracking(mediaKey);
},
getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN,
maybeProbeAnilistDuration: (mediaKey, options) =>
maybeProbeAnilistDuration(mediaKey, options),
ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey),
hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key),
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
enqueueRetry: (key, title, episode, season, mediaId) => {
anilistUpdateQueue.enqueue(key, title, episode, season, mediaId);
},
getCurrentMediaTitle: () => appState.currentMediaTitle,
resolvePinnedAnilistMediaId: ({ mediaPath, mediaTitle, guess }) =>
deps.resolvePinnedMediaId({ mediaPath, mediaTitle, guess }),
markRetryFailure: (key, message) => {
anilistUpdateQueue.markFailure(key, message);
},
markRetrySuccess: (key) => {
anilistUpdateQueue.markSuccess(key);
},
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
updateAnilistPostWatchProgress: (accessToken, title, episode, season, mediaId) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
season,
mediaId,
logInfo: (message) => logger.info(message),
}),
rememberAttemptedUpdateKey: (key) => {
rememberAnilistAttemptedUpdate(key);
},
showMpvOsd: (message) =>
deps
.getOverlayNotifications()
.showConfiguredStatusNotification(message, { title: 'AniList' }),
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS,
minWatchRatio: DEFAULT_MIN_WATCH_RATIO,
},
});
function refreshAnilistClientSecretStateIfEnabled(options?: {
force?: boolean;
allowSetupPrompt?: boolean;
}): Promise<string | null> {
if (!isAnilistTrackingEnabled(configService.getConfig())) {
return Promise.resolve(null);
}
return refreshAnilistClientSecretState(options);
}
const rememberAnilistAttemptedUpdate = (key: string): void => {
rememberAnilistAttemptedUpdateKey(
anilistAttemptedUpdateKeys,
key,
ANILIST_MAX_ATTEMPTED_UPDATE_KEYS,
);
};
return {
stateRuntime: anilistStateRuntime,
openSetupWindow: openAnilistSetupWindowHandler,
handleAnilistSetupProtocolUrl,
registerSubminerProtocolClient,
refreshClientSecretStateIfEnabled: refreshAnilistClientSecretStateIfEnabled,
getCurrentAnilistMediaKey,
resetAnilistMediaTracking,
recordAnilistMediaDuration,
resetAnilistMediaGuessState,
maybeProbeAnilistDuration,
ensureAnilistMediaGuess,
processNextAnilistRetryUpdate,
maybeRunAnilistPostWatchUpdate,
/** Duration of the media AniList is tracking, used as Discord presence's fallback. */
getMediaDurationSec: () => anilistMediaGuessRuntimeState.mediaDurationSec,
};
}
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createCharacterDictionaryAssembly,
type CharacterDictionaryAssemblyDeps,
} from './character-dictionary';
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(): CharacterDictionaryAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
userDataPath: '/tmp/subminer-user-data',
appState: {
currentMediaPath: null,
mpvClient: null,
currentMediaTitle: null,
mecabTokenizer: null,
yomitanParserWindow: null,
currentSubText: '',
},
getConfig: lazy('getConfig'),
logger: { info: lazy('info'), warn: lazy('warn'), error: lazy('error') },
startupOsdSequencer: {
notifyCharacterDictionaryStatus: lazy('notifyCharacterDictionaryStatus'),
},
resolveMediaPathForJimaku: lazy('resolveMediaPathForJimaku'),
getYomitanProfilePolicy: lazy('getYomitanProfilePolicy'),
isYoutubePlaybackActive: lazy('isYoutubePlaybackActive'),
waitForMediaTokenizationReady: lazy('waitForMediaTokenizationReady'),
ensureYomitanExtensionLoaded: lazy('ensureYomitanExtensionLoaded'),
getYomitanParserRuntimeDeps: lazy('getYomitanParserRuntimeDeps'),
getOverlayNotifications: lazy('getOverlayNotifications'),
showMpvOsd: lazy('showMpvOsd'),
showDesktopNotification: lazy('showDesktopNotification'),
invalidateTokenizationCache: lazy('invalidateTokenizationCache'),
refreshCurrentSubtitle: lazy('refreshCurrentSubtitle'),
refreshSubtitlePrefetch: lazy('refreshSubtitlePrefetch'),
refreshCurrentSubtitleAnnotations: lazy('refreshCurrentSubtitleAnnotations'),
};
}
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createCharacterDictionaryAssembly does not call its deps while constructing', () => {
const characterDictionary = createCharacterDictionaryAssembly(makeDeps());
assert.equal(characterDictionary.autoSync.getCurrentMediaId(), null);
});
+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 };
}
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDictionariesAssembly, type DictionariesAssemblyDeps } from './dictionaries';
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(): DictionariesAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
roots: {
platform: 'linux',
dirname: '/tmp/subminer-main',
appPath: '/tmp/subminer-app',
resourcesPath: '/tmp/subminer-resources',
userDataPath: '/tmp/subminer-user-data',
appUserDataPath: '/tmp/subminer-app-user-data',
homeDir: '/tmp/subminer-home',
cwd: '/tmp/subminer-cwd',
joinPath: lazy('joinPath'),
},
appState: { jlptLevelLookup: () => null, frequencyRankLookup: () => null },
getConfig: lazy('getConfig'),
logger: { info: lazy('info') },
};
}
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createDictionariesAssembly does not call its deps while constructing', () => {
const dictionaries = createDictionariesAssembly(makeDeps());
assert.equal(typeof dictionaries.jlpt.ensureJlptDictionaryLookup, 'function');
assert.equal(typeof dictionaries.frequency.ensureFrequencyDictionaryLookup, 'function');
});
+65
View File
@@ -0,0 +1,65 @@
import type { ResolvedConfig } from '../../types';
import type { Logger } from '../../logger';
import {
createFrequencyDictionaryRuntimeService,
getFrequencyDictionarySearchPaths,
} from '../frequency-dictionary-runtime';
import { createJlptDictionaryRuntimeService, getJlptDictionarySearchPaths } from '../jlpt-runtime';
import { createBuildDictionaryRootsHandler } from '../runtime/dictionary-roots';
import type { AppState } from '../state';
/**
* What the bundled annotation dictionaries need. `roots` holds the install and user paths
* searched for each dictionary's vendor folder; `main.ts` resolves them when it builds this.
*/
export interface DictionariesAssemblyDeps {
roots: Omit<Parameters<typeof createBuildDictionaryRootsHandler>[0], 'vendorDirName'>;
appState: Pick<AppState, 'jlptLevelLookup' | 'frequencyRankLookup'>;
getConfig: () => ResolvedConfig;
logger: Pick<Logger, 'info'>;
}
export type DictionariesAssembly = ReturnType<typeof createDictionariesAssembly>;
/**
* Owns the JLPT and frequency dictionary lookups that subtitle annotations read. `main.ts` builds
* it once; tokenization calls `jlpt`/`frequency` to load each lookup on first use.
*/
export function createDictionariesAssembly(deps: DictionariesAssemblyDeps) {
const { appState, logger } = deps;
const buildJlptDictionaryRootsHandler = createBuildDictionaryRootsHandler({
...deps.roots,
vendorDirName: 'yomitan-jlpt-vocab',
});
const buildFrequencyDictionaryRootsHandler = createBuildDictionaryRootsHandler({
...deps.roots,
vendorDirName: 'frequency-dictionary',
});
const jlpt = createJlptDictionaryRuntimeService({
isJlptEnabled: () => deps.getConfig().subtitleStyle.enableJlpt,
getSearchPaths: () =>
getJlptDictionarySearchPaths({ getDictionaryRoots: () => buildJlptDictionaryRootsHandler() }),
setJlptLevelLookup: (lookup) => {
appState.jlptLevelLookup = lookup;
},
log: (message) => logger.info(`[JLPT] ${message}`),
});
const frequency = createFrequencyDictionaryRuntimeService({
isFrequencyDictionaryEnabled: () => deps.getConfig().subtitleStyle.frequencyDictionary.enabled,
getSearchPaths: () =>
getFrequencyDictionarySearchPaths({
getDictionaryRoots: () =>
buildFrequencyDictionaryRootsHandler().filter((dictionaryRoot) => dictionaryRoot),
getSourcePath: () => deps.getConfig().subtitleStyle.frequencyDictionary.sourcePath,
}),
setFrequencyRankLookup: (lookup) => {
appState.frequencyRankLookup = lookup;
},
log: (message) => logger.info(`[Frequency] ${message}`),
});
return { jlpt, frequency };
}
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createJellyfinAssembly, type JellyfinAssemblyDeps } from './jellyfin';
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(): JellyfinAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
appState: {
mpvClient: null,
mpvSocketPath: '/tmp/subminer-test.sock',
immersionTracker: null,
jellyfinRemoteSession: null,
jellyfinSetupWindow: null,
},
configService: { getConfig: lazy('getConfig'), patchRawConfig: lazy('patchRawConfig') },
jellyfinTokenStore: {
loadSession: lazy('loadSession'),
saveSession: lazy('saveSession'),
clearSession: lazy('clearSession'),
},
logger: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} },
defaultMpvLogPath: '/tmp/mpv.log',
getAppDataPath: lazy('getAppDataPath'),
getRuntimePluginEntrypoint: lazy('getRuntimePluginEntrypoint'),
getMpvPluginRuntimeConfig: lazy('getMpvPluginRuntimeConfig'),
createMpvClient: lazy('createMpvClient'),
initSubtitlePrefetch: lazy('initSubtitlePrefetch'),
showVisibleOverlay: lazy('showVisibleOverlay'),
getOverlayNotifications: lazy('getOverlayNotifications'),
updateCurrentMediaTitle: lazy('updateCurrentMediaTitle'),
ensureImmersionTrackerStarted: lazy('ensureImmersionTrackerStarted'),
refreshTrayMenu: lazy('refreshTrayMenu'),
setupPreloadPath: '/tmp/preload-jellyfin-setup.js',
createBrowserWindow: lazy('createBrowserWindow'),
ipcMain: { handle: lazy('ipcMain.handle'), removeHandler: lazy('ipcMain.removeHandler') },
};
}
// main.ts builds the assembly before several siblings exist, so construction must stay lazy.
test('createJellyfinAssembly does not call its deps while constructing', () => {
const jellyfin = createJellyfinAssembly(makeDeps());
assert.equal(jellyfin.getActivePlayback(), null);
assert.equal(jellyfin.getQuitOnDisconnectArmed(), false);
});
+457
View File
@@ -0,0 +1,457 @@
import fs from 'node:fs';
import os from 'node:os';
import type { BrowserWindow, BrowserWindowConstructorOptions, IpcMain } from 'electron';
import { DEFAULT_CONFIG } from '../../config/definitions';
import type { ConfigService } from '../../config/service';
import {
authenticateWithPasswordRuntime,
JellyfinPlaybackReporter,
JellyfinRemoteSessionService,
jellyfinTicksToSecondsRuntime,
listJellyfinItemsRuntime,
listJellyfinLibrariesRuntime,
listJellyfinSubtitleTracksRuntime,
resolveJellyfinPlaybackPlanRuntime,
sendMpvCommandRuntime,
type MpvIpcClient,
} from '../../core/services';
import type { JellyfinTokenStore } from '../../core/services/jellyfin-token-store';
import type { Logger } from '../../logger';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { writeJellyfinCliResponse } from '../../shared/jellyfin-cli-response';
import { isLogFileEnabled } from '../../shared/log-files';
import { composeJellyfinRuntimeHandlers } from '../runtime/composers';
import {
buildJellyfinSetupFormHtml,
createApplyJellyfinMpvDefaultsHandler,
getConfiguredJellyfinSession,
mergeJellyfinRecentServers,
parseJellyfinSetupSubmissionUrl,
persistJellyfinAuthSession,
type ActiveJellyfinRemotePlaybackState,
} from '../runtime/domains/jellyfin';
import { detectInstalledMpvPlugin } from '../runtime/first-run-setup-plugin';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
DEFAULT_JELLYFIN_CLIENT_VERSION,
createHostDerivedJellyfinDeviceId,
resolveJellyfinRemoteDeviceName,
} from '../runtime/jellyfin-device-identity';
import { createJellyfinSubtitleCacheIo } from '../runtime/jellyfin-subtitle-cache-io';
import { clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime } from '../runtime/jellyfin-tray-discovery';
import { resolveMpvExecutablePath, spawnMpvProcess } from '../runtime/mpv-process';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createCreateJellyfinSetupWindowHandler } from '../runtime/setup-window-factory';
import type { AppState } from '../state';
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
const JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS = 10_000;
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
const MPV_JELLYFIN_DEFAULT_ARGS = [
'--sub-auto=no',
'--sub-file-paths=.;subs;subtitles',
'--sid=no',
'--secondary-sid=no',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
] as const;
type JellyfinComposerOptions = Parameters<typeof composeJellyfinRuntimeHandlers>[0];
type LaunchMpvIdleDeps = JellyfinComposerOptions['launchMpvIdleForJellyfinPlaybackMainDeps'];
/**
* What the Jellyfin domain needs from the rest of the app. Siblings built after this assembly in
* `main.ts` are reached through getters or callbacks, which are only called once the app runs.
*/
export interface JellyfinAssemblyDeps {
appState: Pick<
AppState,
| 'mpvClient'
| 'mpvSocketPath'
| 'immersionTracker'
| 'jellyfinRemoteSession'
| 'jellyfinSetupWindow'
>;
configService: Pick<ConfigService, 'getConfig' | 'patchRawConfig'>;
jellyfinTokenStore: JellyfinTokenStore;
logger: Pick<Logger, 'info' | 'warn' | 'debug' | 'error'>;
defaultMpvLogPath: string;
getAppDataPath: () => string;
getRuntimePluginEntrypoint: NonNullable<LaunchMpvIdleDeps['getRuntimePluginEntrypoint']>;
getMpvPluginRuntimeConfig: NonNullable<LaunchMpvIdleDeps['getPluginRuntimeConfig']>;
createMpvClient: () => MpvIpcClient;
initSubtitlePrefetch: NonNullable<
JellyfinComposerOptions['preloadJellyfinExternalSubtitlesMainDeps']['initSubtitlePrefetch']
>;
showVisibleOverlay: () => void;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'showConfiguredStatusNotification'
>;
updateCurrentMediaTitle: (title: string) => void;
ensureImmersionTrackerStarted: () => void;
refreshTrayMenu: () => void;
setupPreloadPath: string;
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
ipcMain: Pick<IpcMain, 'handle' | 'removeHandler'>;
}
export type JellyfinAssembly = ReturnType<typeof createJellyfinAssembly>;
/**
* Owns Jellyfin playback, remote-control session, CLI commands, setup window and tray discovery,
* plus the playback state they share. `main.ts` builds it once and calls through the result.
*/
export function createJellyfinAssembly(deps: JellyfinAssemblyDeps) {
const { appState, configService, jellyfinTokenStore, logger } = deps;
const jellyfinSubtitleCacheIo = createJellyfinSubtitleCacheIo({
tmpDir: () => os.tmpdir(),
makeTempDir: (prefix) => fs.promises.mkdtemp(prefix),
writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes),
removeDir: (dir, options) => {
fs.rmSync(dir, options);
},
fetch: (url) => fetch(url),
});
let jellyfinPlayQuitOnDisconnectArmed = false;
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
const applyJellyfinMpvDefaultsHandler = createApplyJellyfinMpvDefaultsHandler({
sendMpvCommandRuntime: (client, command) => sendMpvCommandRuntime(client, command),
jellyfinLangPref: JELLYFIN_LANG_PREF,
});
const {
getResolvedJellyfinConfig,
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
startJellyfinRemoteSession,
stopJellyfinRemoteSession,
cleanupJellyfinSubtitleCache,
runJellyfinCommand,
openJellyfinSetupWindow,
ensureMpvConnectedForPlayback,
} = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => configService.getConfig(),
loadStoredSession: () => jellyfinTokenStore.loadSession(),
getEnv: (name) => process.env[name],
},
getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getHostName: () => os.hostname(),
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
},
waitForMpvConnectedMainDeps: {
getMpvClient: () => appState.mpvClient,
now: () => Date.now(),
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () =>
resolveMpvExecutablePath(configService.getConfig().mpv.executablePath),
getSocketPath: () => appState.mpvSocketPath,
getLaunchMode: () => configService.getConfig().mpv.launchMode,
platform: process.platform,
execPath: process.execPath,
getRuntimePluginEntrypoint: () => deps.getRuntimePluginEntrypoint(),
getInstalledPluginDetection: (mpvExecutablePath) =>
detectInstalledMpvPlugin({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: deps.getAppDataPath(),
mpvExecutablePath,
}),
getPluginRuntimeConfig: () => deps.getMpvPluginRuntimeConfig(),
getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? deps.defaultMpvLogPath : ''),
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
removeSocketPath: (socketPath) => {
fs.rmSync(socketPath, { force: true });
},
spawnMpv: spawnMpvProcess,
logWarn: (message, error) => logger.warn(message, error),
logInfo: (message) => logger.info(message),
},
ensureMpvConnectedForJellyfinPlaybackMainDeps: {
getMpvClient: () => appState.mpvClient,
setMpvClient: (client) => {
appState.mpvClient = client as MpvIpcClient | null;
},
createMpvClient: () => deps.createMpvClient(),
getAutoLaunchInFlight: () => jellyfinMpvAutoLaunchInFlight,
setAutoLaunchInFlight: (promise) => {
jellyfinMpvAutoLaunchInFlight = promise;
},
connectTimeoutMs: JELLYFIN_MPV_CONNECT_TIMEOUT_MS,
autoLaunchTimeoutMs: JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS,
},
preloadJellyfinExternalSubtitlesMainDeps: {
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId),
getMpvClient: () => appState.mpvClient,
sendMpvCommand: (command) => {
sendMpvCommandRuntime(appState.mpvClient, command);
},
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track),
cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs),
initSubtitlePrefetch: (sourcePath) => deps.initSubtitlePrefetch(sourcePath),
logDebug: (message, error) => {
logger.debug(message, error);
},
},
playJellyfinItemInMpvMainDeps: {
getMpvClient: () => appState.mpvClient,
resolvePlaybackPlan: (params) =>
resolveJellyfinPlaybackPlanRuntime(
params.session,
params.clientInfo,
params.jellyfinConfig as ReturnType<typeof getResolvedJellyfinConfig>,
{
itemId: params.itemId,
audioStreamIndex: params.audioStreamIndex ?? undefined,
subtitleStreamIndex: params.subtitleStreamIndex ?? undefined,
},
),
applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaultsHandler(mpvClient),
showVisibleOverlay: () => deps.showVisibleOverlay(),
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
armQuitOnDisconnect: () => {
jellyfinPlayQuitOnDisconnectArmed = false;
setTimeout(() => {
jellyfinPlayQuitOnDisconnectArmed = true;
}, 3000);
},
schedule: (callback, delayMs) => {
setTimeout(callback, delayMs);
},
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
setActivePlayback: (state) => {
activeJellyfinRemotePlayback = {
...(state as ActiveJellyfinRemotePlaybackState),
stopReportsAfterMs:
state.stopReportsAfterMs ?? Date.now() + JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS,
};
},
setLastProgressAtMs: (value) => {
jellyfinRemoteLastProgressAtMs = value;
},
// Same device identity as the cast session, so Jellyfin attributes reports to one session.
createPlaybackReporter: ({ session, clientInfo }) =>
new JellyfinPlaybackReporter({
serverUrl: session.serverUrl,
accessToken: session.accessToken,
deviceId: clientInfo.deviceId,
clientName: clientInfo.clientName,
clientVersion: clientInfo.clientVersion,
deviceName: resolveJellyfinRemoteDeviceName({ hostName: os.hostname() }),
logWarn: (message, details) => logger.warn(message, details),
}),
showMpvOsd: (text) => {
deps
.getOverlayNotifications()
.showConfiguredStatusNotification(text, { title: 'Jellyfin' });
},
updateCurrentMediaTitle: (title) => {
deps.updateCurrentMediaTitle(title);
},
recordJellyfinPlaybackMetadata: (metadata) => {
deps.ensureImmersionTrackerStarted();
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
},
},
remoteComposerOptions: {
getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()),
logWarn: (message) => logger.warn(message),
getMpvClient: () => appState.mpvClient,
sendMpvCommand: (client, command) => sendMpvCommandRuntime(client as MpvIpcClient, command),
jellyfinTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
getActivePlayback: () => activeJellyfinRemotePlayback,
clearActivePlayback: () => {
activeJellyfinRemotePlayback = null;
},
getNow: () => Date.now(),
getLastProgressAtMs: () => jellyfinRemoteLastProgressAtMs,
setLastProgressAtMs: (value) => {
jellyfinRemoteLastProgressAtMs = value;
},
progressIntervalMs: JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS,
ticksPerSecond: JELLYFIN_TICKS_PER_SECOND,
logDebug: (message, error) => logger.debug(message, error),
},
handleJellyfinAuthCommandsMainDeps: {
patchRawConfig: (patch) => {
configService.patchRawConfig(patch);
deps.refreshTrayMenu();
},
authenticateWithPassword: (serverUrl, username, password, clientInfo) =>
authenticateWithPasswordRuntime(serverUrl, username, password, clientInfo),
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
clearStoredSession: () =>
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
logInfo: (message) => logger.info(message),
},
handleJellyfinListCommandsMainDeps: {
listJellyfinLibraries: (session, clientInfo) =>
listJellyfinLibrariesRuntime(session, clientInfo),
listJellyfinItems: (session, clientInfo, params) =>
listJellyfinItemsRuntime(session, clientInfo, params),
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId),
writeJellyfinResponse: writeJellyfinCliResponse,
logInfo: (message) => logger.info(message),
},
handleJellyfinPlayCommandMainDeps: {
logWarn: (message) => logger.warn(message),
},
handleJellyfinRemoteAnnounceCommandMainDeps: {
getRemoteSession: () => appState.jellyfinRemoteSession,
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
},
startJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => appState.jellyfinRemoteSession,
setCurrentSession: (session) => {
appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession;
},
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options),
getHostName: () => os.hostname(),
defaultDeviceId: createHostDerivedJellyfinDeviceId(os.hostname()),
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
logInfo: (message) => logger.info(message),
logWarn: (message, details) => logger.warn(message, details),
onSessionStateChanged: () => deps.refreshTrayMenu(),
},
stopJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => appState.jellyfinRemoteSession,
setCurrentSession: (session) => {
appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession;
},
clearActivePlayback: () => {
activeJellyfinRemotePlayback = null;
},
onSessionStateChanged: () => deps.refreshTrayMenu(),
},
runJellyfinCommandMainDeps: {
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
writeJellyfinResponse: writeJellyfinCliResponse,
},
maybeFocusExistingJellyfinSetupWindowMainDeps: {
getSetupWindow: () => appState.jellyfinSetupWindow,
},
openJellyfinSetupWindowMainDeps: {
createSetupWindow: createCreateJellyfinSetupWindowHandler({
createBrowserWindow: (options) => deps.createBrowserWindow(options),
preloadPath: deps.setupPreloadPath,
}),
buildSetupFormHtml: (state) => buildJellyfinSetupFormHtml(state),
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: (server, username, password, clientInfo) =>
authenticateWithPasswordRuntime(server, username, password, clientInfo),
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
clearStoredSession: () =>
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
patchJellyfinConfig: (session) => {
const recentServers = mergeJellyfinRecentServers(
session.serverUrl,
configService.getConfig().jellyfin.recentServers || [],
);
configService.patchRawConfig({
jellyfin: {
enabled: true,
serverUrl: session.serverUrl,
username: session.username,
recentServers,
},
});
deps.refreshTrayMenu();
},
persistAuthenticatedSession: (session, clientInfo) =>
persistJellyfinAuthSession({
session,
clientInfo,
existingRecentServers: configService.getConfig().jellyfin.recentServers || [],
saveStoredSession: (storedSession) => jellyfinTokenStore.saveSession(storedSession),
patchRawConfig: (patch) => {
configService.patchRawConfig(patch);
deps.refreshTrayMenu();
},
}),
logInfo: (message) => logger.info(message),
logError: (message, error) => logger.error(message, error),
showMpvOsd: (message) =>
deps
.getOverlayNotifications()
.showConfiguredStatusNotification(message, { title: 'Jellyfin' }),
clearSetupWindow: () => {
appState.jellyfinSetupWindow = null;
},
setSetupWindow: (window) => {
appState.jellyfinSetupWindow = window as BrowserWindow;
},
registerSetupIpcHandler: (handler) => {
const channel = IPC_CHANNELS.request.jellyfinSetupSubmit;
deps.ipcMain.removeHandler(channel);
deps.ipcMain.handle(channel, async (event, payload) => {
const setupWindow = appState.jellyfinSetupWindow;
if (!setupWindow || event.sender !== setupWindow.webContents) {
return {
handled: false,
statusMessage: 'This Jellyfin setup window is no longer active.',
statusKind: 'error',
};
}
return handler(payload);
});
return () => {
deps.ipcMain.removeHandler(channel);
};
},
encodeURIComponent: (value) => encodeURIComponent(value),
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl || 'http://127.0.0.1:8096',
hasStoredSession: () => Boolean(jellyfinTokenStore.loadSession()),
},
});
function getJellyfinTrayDiscoveryDeps() {
return {
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getRemoteSession: () => appState.jellyfinRemoteSession,
clearStoredSession: () => jellyfinTokenStore.clearSession(),
stopRemoteSession: () => stopJellyfinRemoteSession(),
startRemoteSession: (options: { explicit: true }) => startJellyfinRemoteSession(options),
refreshTrayMenu: () => deps.refreshTrayMenu(),
logger,
showMpvOsd: (message: string) =>
deps
.getOverlayNotifications()
.showConfiguredStatusNotification(message, { title: 'Jellyfin' }),
};
}
return {
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
startJellyfinRemoteSession,
stopJellyfinRemoteSession,
cleanupJellyfinSubtitleCache,
runJellyfinCommand,
openJellyfinSetupWindow,
ensureMpvConnectedForPlayback,
getTrayDiscoveryDeps: getJellyfinTrayDiscoveryDeps,
getActivePlayback: () => activeJellyfinRemotePlayback,
getQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed,
};
}
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOverlayModalsAssembly, type OverlayModalsAssemblyDeps } from './overlay-modals';
type StatusCall = { message: string; title?: string; variant?: string };
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(overrides: Partial<OverlayModalsAssemblyDeps> = {}): OverlayModalsAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
appState: { mpvClient: null, currentMediaPath: null },
getConfig: lazy('getConfig'),
logger: { warn: () => {}, error: () => {} },
overlayModalRuntime: { waitForModalOpen: lazy('waitForModalOpen') },
getOverlayNotifications: lazy('getOverlayNotifications'),
ensureOverlayStartupPrereqs: lazy('ensureOverlayStartupPrereqs'),
ensureOverlayWindowsReadyForVisibilityActions: lazy(
'ensureOverlayWindowsReadyForVisibilityActions',
),
sendToActiveOverlayWindow: lazy('sendToActiveOverlayWindow'),
showMpvOsd: lazy('showMpvOsd'),
showDesktopNotification: lazy('showDesktopNotification'),
...overrides,
};
}
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createOverlayModalsAssembly does not call its deps while constructing', () => {
createOverlayModalsAssembly(makeDeps());
});
test('playlist browser warns instead of opening when mpv is not connected', () => {
const calls: StatusCall[] = [];
const overlayModals = createOverlayModalsAssembly(
makeDeps({
getOverlayNotifications: () => ({
getConfiguredStatusNotificationType: () => 'overlay',
showConfiguredStatusNotification: (message, options) => {
calls.push({ message, title: options?.title, variant: options?.variant });
},
showOverlayNotification: () => {},
}),
}),
);
// The lazy overlay deps throw if the opener gets past the playback guard.
overlayModals.openPlaylistBrowser();
assert.deepEqual(calls, [
{
message: 'Playlist browser requires active playback.',
title: 'Playlist browser',
variant: 'warning',
},
]);
});
+210
View File
@@ -0,0 +1,210 @@
import type { Logger } from '../../logger';
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import type { ResolvedConfig } from '../../types';
import type { OverlayModalRuntime } from '../overlay-runtime';
import { openChangelogModal as openChangelogModalRuntime } from '../runtime/changelog-open';
import { openCharacterDictionaryManagerWithConfigGate } from '../runtime/character-dictionary-manager-gate';
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from '../runtime/character-dictionary-open';
import { openControllerDebugModal as openControllerDebugModalRuntime } from '../runtime/controller-debug-open';
import { openControllerSelectModal as openControllerSelectModalRuntime } from '../runtime/controller-select-open';
import { openJimakuModal as openJimakuModalRuntime } from '../runtime/jimaku-open';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from '../runtime/playlist-browser-open';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from '../runtime/runtime-options-open';
import { openSessionHelpModal as openSessionHelpModalRuntime } from '../runtime/session-help-open';
import { openSubtitleGenerationModal } from '../runtime/subtitle-generation-open';
import { openTsukihimeModal as openTsukihimeModalRuntime } from '../runtime/tsukihime-open';
import type { AppState } from '../state';
type OverlayNotifications = Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
| 'getConfiguredStatusNotificationType'
| 'showConfiguredStatusNotification'
| 'showOverlayNotification'
>;
/** What the overlay-hosted modal openers need from the rest of the app. */
export interface OverlayModalsAssemblyDeps {
appState: Pick<AppState, 'mpvClient' | 'currentMediaPath'>;
getConfig: () => ResolvedConfig;
logger: Pick<Logger, 'warn' | 'error'>;
overlayModalRuntime: Pick<OverlayModalRuntime, 'waitForModalOpen'>;
getOverlayNotifications: () => OverlayNotifications;
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: OverlayModalRuntime['sendToActiveOverlayWindow'];
showMpvOsd: (text: string) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
}
export type OverlayModalsAssembly = ReturnType<typeof createOverlayModalsAssembly>;
/**
* Opens the overlay-hosted modals (runtime options, Jimaku, session help, playlist browser, ...)
* and reports through the configured status notification when one cannot open.
*/
export function createOverlayModalsAssembly(deps: OverlayModalsAssemblyDeps) {
const { appState, logger } = deps;
function createOverlayHostedModalOpenDeps(): {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: OverlayModalRuntime['sendToActiveOverlayWindow'];
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
} {
return {
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
ensureOverlayWindowsReadyForVisibilityActions: () =>
deps.ensureOverlayWindowsReadyForVisibilityActions(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
deps.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
waitForModalOpen: (modal, timeoutMs) =>
deps.overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
logWarn: (message) => logger.warn(message),
};
}
function openOverlayHostedModalWithOsd(
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
unavailableMessage: string,
failureLogMessage: string,
): void {
void openModal(createOverlayHostedModalOpenDeps())
.then((opened) => {
if (!opened) {
deps.getOverlayNotifications().showConfiguredStatusNotification(unavailableMessage, {
variant: 'warning',
});
}
})
.catch((error) => {
logger.error(failureLogMessage, error);
deps.getOverlayNotifications().showConfiguredStatusNotification(unavailableMessage, {
variant: 'error',
});
});
}
function openRuntimeOptionsPalette(): void {
openOverlayHostedModalWithOsd(
openRuntimeOptionsModalRuntime,
'Runtime options overlay unavailable.',
'Failed to open runtime options overlay.',
);
}
function openJimakuOverlay(): void {
openOverlayHostedModalWithOsd(
openJimakuModalRuntime,
'Jimaku overlay unavailable.',
'Failed to open Jimaku overlay.',
);
}
function openTsukihimeOverlay(): void {
openOverlayHostedModalWithOsd(
openTsukihimeModalRuntime,
'TsukiHime overlay unavailable.',
'Failed to open TsukiHime overlay.',
);
}
function openSubtitleGenerationOverlay(): void {
openOverlayHostedModalWithOsd(
openSubtitleGenerationModal,
'Subtitle generation overlay unavailable.',
'Failed to open subtitle generation overlay.',
);
}
// currentMediaPath follows every mpv path change and is cleared on disconnect; the
// client's currentVideoPath outlives the connection, so a reconnect could reuse it.
function isMediaPlaybackActive(): boolean {
return appState.mpvClient?.connected === true && Boolean(appState.currentMediaPath?.trim());
}
function openSessionHelpOverlay(): void {
openOverlayHostedModalWithOsd(
(modalDeps) => openSessionHelpModalRuntime({ ...modalDeps, isMediaPlaybackActive }),
'Session help overlay unavailable.',
'Failed to open session help overlay.',
);
}
function openChangelogOverlay(): void {
openOverlayHostedModalWithOsd(
openChangelogModalRuntime,
'Changelog overlay unavailable.',
'Failed to open changelog overlay.',
);
}
function openCharacterDictionaryManagerOverlay(): void {
openCharacterDictionaryManagerWithConfigGate({
isCharacterDictionaryEnabled: () => deps.getConfig().subtitleStyle.nameMatchEnabled,
getNotificationType: () =>
deps.getOverlayNotifications().getConfiguredStatusNotificationType(),
openManager: () => {
openOverlayHostedModalWithOsd(
openCharacterDictionaryManagerModalRuntime,
'Character dictionary manager unavailable.',
'Failed to open character dictionary manager.',
);
},
showOsd: (message) => deps.showMpvOsd(message),
showOverlayNotification: (payload) =>
deps.getOverlayNotifications().showOverlayNotification(payload),
showDesktopNotification: (title, options) => deps.showDesktopNotification(title, options),
logWarn: (message, error) => logger.warn(message, error),
});
}
function openControllerSelectOverlay(): void {
openOverlayHostedModalWithOsd(
openControllerSelectModalRuntime,
'Controller select overlay unavailable.',
'Failed to open controller select overlay.',
);
}
function openControllerDebugOverlay(): void {
openOverlayHostedModalWithOsd(
openControllerDebugModalRuntime,
'Controller debug overlay unavailable.',
'Failed to open controller debug overlay.',
);
}
function openPlaylistBrowser(): void {
if (!appState.mpvClient?.connected) {
deps
.getOverlayNotifications()
.showConfiguredStatusNotification('Playlist browser requires active playback.', {
title: 'Playlist browser',
variant: 'warning',
});
return;
}
openOverlayHostedModalWithOsd(
openPlaylistBrowserRuntime,
'Playlist browser overlay unavailable.',
'Failed to open playlist browser overlay.',
);
}
return {
createOverlayHostedModalOpenDeps,
openOverlayHostedModalWithOsd,
openRuntimeOptionsPalette,
openJimakuOverlay,
openTsukihimeOverlay,
openSubtitleGenerationOverlay,
openSessionHelpOverlay,
openChangelogOverlay,
openCharacterDictionaryManagerOverlay,
openControllerSelectOverlay,
openControllerDebugOverlay,
openPlaylistBrowser,
};
}
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSettingsAssembly, type SettingsAssemblyDeps } from './settings';
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(): SettingsAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
appState: { configSettingsWindow: null },
configService: {
getConfig: lazy('getConfig'),
getConfigPath: lazy('getConfigPath'),
getRawConfig: lazy('getRawConfig'),
getWarnings: lazy('getWarnings'),
reloadConfigStrict: lazy('reloadConfigStrict'),
},
logger: { error: lazy('error') },
getOverlayNotifications: lazy('getOverlayNotifications'),
showMpvOsd: lazy('showMpvOsd'),
showDesktopNotification: lazy('showDesktopNotification'),
hotReloadApplied: {
setKeybindings: lazy('setKeybindings'),
setSessionBindings: lazy('setSessionBindings'),
refreshGlobalAndOverlayShortcuts: lazy('refreshGlobalAndOverlayShortcuts'),
setSecondarySubMode: lazy('setSecondarySubMode'),
broadcastToOverlayWindows: lazy('broadcastToOverlayWindows'),
applyAnkiRuntimeConfigPatch: lazy('applyAnkiRuntimeConfigPatch'),
},
getYomitanAnkiDeckName: lazy('getYomitanAnkiDeckName'),
showErrorBox: lazy('showErrorBox'),
createBrowserWindow: lazy('createBrowserWindow'),
mainDirname: '/tmp/subminer-main',
openPath: lazy('openPath'),
ipcMain: { handle: lazy('ipcMain.handle') },
};
}
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createSettingsAssembly does not call its deps while constructing', () => {
const settings = createSettingsAssembly(makeDeps());
assert.equal(typeof settings.hotReload.start, 'function');
assert.equal(typeof settings.window.registerHandlers, 'function');
});
+141
View File
@@ -0,0 +1,141 @@
import fs from 'node:fs';
import path from 'node:path';
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
import { AnkiConnectClient } from '../../anki-connect';
import { DEFAULT_CONFIG } from '../../config/definitions';
import type { ConfigService } from '../../config/service';
import { buildConfigSettingsRegistry } from '../../config/settings/registry';
import {
createConfigHotReloadRuntime,
promoteSettingsWindowAboveOverlay,
} from '../../core/services';
import type { Logger } from '../../logger';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
buildConfigWarningDialogDetails,
buildConfigWarningNotificationBody,
} from '../config-validation';
import {
buildRestartRequiredConfigMessage,
createConfigHotReloadAppliedHandler,
createConfigHotReloadMessageHandler,
createWatchConfigPathHandler,
} from '../runtime/config-hot-reload-handlers';
import {
createConfigSettingsRuntime,
type ConfigSettingsIpcMainLike,
} from '../runtime/config-settings-runtime';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createCreateConfigSettingsWindowHandler } from '../runtime/setup-window-factory';
import type { AppState } from '../state';
type ConfigHotReloadMessageDeps = Parameters<typeof createConfigHotReloadMessageHandler>[0];
/**
* What config hot-reload and the settings window need from the rest of the app. Siblings built
* after this assembly in `main.ts` are reached through getters or callbacks.
*/
export interface SettingsAssemblyDeps {
appState: Pick<AppState, 'configSettingsWindow'>;
configService: Pick<
ConfigService,
'getConfig' | 'getConfigPath' | 'getRawConfig' | 'getWarnings' | 'reloadConfigStrict'
>;
logger: Pick<Logger, 'error'>;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'getConfiguredStatusNotificationType' | 'showOverlayNotification'
>;
showMpvOsd: (message: string) => void;
showDesktopNotification: ConfigHotReloadMessageDeps['showDesktopNotification'];
/** Applies a reloaded config to the running app; shared by file hot-reload and the settings window. */
hotReloadApplied: Parameters<typeof createConfigHotReloadAppliedHandler>[0];
getYomitanAnkiDeckName: () => Promise<string>;
showErrorBox: (title: string, content: string) => void;
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
/** Directory of the main bundle; the settings preload and page live next to it. */
mainDirname: string;
openPath: (targetPath: string) => Promise<string>;
ipcMain: ConfigSettingsIpcMainLike;
}
export type SettingsAssembly = ReturnType<typeof createSettingsAssembly>;
/**
* Owns config file hot-reload and the settings window, which both apply reloaded config through
* the same handler. `main.ts` starts/stops hot-reload and registers the settings IPC handlers.
*/
export function createSettingsAssembly(deps: SettingsAssemblyDeps) {
const { appState, configService, logger } = deps;
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler({
getNotificationType: () => deps.getOverlayNotifications().getConfiguredStatusNotificationType(),
showMpvOsd: (message) => deps.showMpvOsd(message),
showOverlayNotification: (payload) =>
deps.getOverlayNotifications().showOverlayNotification(payload),
showDesktopNotification: (title, options) => deps.showDesktopNotification(title, options),
});
const watchConfigPathHandler = createWatchConfigPathHandler({
fileExists: (targetPath) => fs.existsSync(targetPath),
dirname: (targetPath) => path.dirname(targetPath),
watchPath: (targetPath, listener) => fs.watch(targetPath, listener),
});
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(deps.hotReloadApplied);
const configHotReloadRuntime = createConfigHotReloadRuntime({
getCurrentConfig: () => configService.getConfig(),
reloadConfigStrict: () => configService.reloadConfigStrict(),
watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange),
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
clearTimeout: (timeout) => clearTimeout(timeout),
debounceMs: 250,
onHotReloadApplied: applyConfigHotReloadDiff,
onRestartRequired: (fields) =>
notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)),
onInvalidConfig: notifyConfigHotReloadMessage,
onValidationWarnings: (configPath, warnings) => {
deps.showDesktopNotification('SubMiner', {
body: buildConfigWarningNotificationBody(configPath, warnings),
});
if (process.platform === 'darwin') {
deps.showErrorBox(
'SubMiner config validation warning',
buildConfigWarningDialogDetails(configPath, warnings),
);
}
},
});
const configSettingsRuntime = createConfigSettingsRuntime({
fields: configSettingsFields,
getConfigPath: () => configService.getConfigPath(),
getRawConfig: () => configService.getRawConfig(),
getConfig: () => configService.getConfig(),
getWarnings: () => configService.getWarnings(),
reloadConfigStrict: () => configService.reloadConfigStrict(),
onHotReloadApplied: applyConfigHotReloadDiff,
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: (url) => new AnkiConnectClient(url),
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
getSettingsWindow: () => appState.configSettingsWindow,
setSettingsWindow: (window) => {
appState.configSettingsWindow = window as BrowserWindow | null;
},
createSettingsWindow: createCreateConfigSettingsWindowHandler({
createBrowserWindow: (options) => deps.createBrowserWindow(options),
preloadPath: path.join(deps.mainDirname, 'preload-settings.js'),
}),
settingsHtmlPath: path.join(deps.mainDirname, 'settings', 'index.html'),
promoteSettingsWindowAboveOverlay: (window) =>
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
openPath: (targetPath) => deps.openPath(targetPath),
ipcMain: deps.ipcMain,
ipcChannels: IPC_CHANNELS.request,
log: (message) => logger.error(message),
});
return {
hotReload: configHotReloadRuntime,
window: configSettingsRuntime,
};
}
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSyncAssembly, type SyncAssemblyDeps } from './sync';
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(): SyncAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
appState: { syncUiWindow: null, initialArgs: null },
configService: { getConfig: lazy('getConfig') },
logger: { info: lazy('info'), error: lazy('error') },
userDataPath: '/tmp/subminer-user-data',
defaultImmersionDbPath: '/tmp/subminer-user-data/immersion.sqlite',
getOverlayNotifications: lazy('getOverlayNotifications'),
requestAppQuit: lazy('requestAppQuit'),
createBrowserWindow: lazy('createBrowserWindow'),
mainDirname: '/tmp/subminer-main',
ipcMain: { handle: lazy('ipcMain.handle') },
isPackaged: lazy('isPackaged'),
getAppPath: lazy('getAppPath'),
pickSnapshotFile: lazy('pickSnapshotFile'),
revealPath: lazy('revealPath'),
};
}
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createSyncAssembly does not call its deps while constructing', () => {
const sync = createSyncAssembly(makeDeps());
assert.equal(sync.runtime.isRunning(), false);
});
+115
View File
@@ -0,0 +1,115 @@
import os from 'node:os';
import path from 'node:path';
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
import type { ConfigService } from '../../config/service';
import { promoteSettingsWindowAboveOverlay } from '../../core/services';
import type { Logger } from '../../logger';
import { getSyncHostsPath } from '../../shared/sync/sync-hosts-store';
import { createOpenConfigSettingsWindowHandler } from '../runtime/config-settings-window';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createCreateSyncUiWindowHandler } from '../runtime/setup-window-factory';
import { createSyncAutoScheduler } from '../runtime/sync-auto-scheduler';
import { resolveSyncLauncherCommand, runSyncLauncher } from '../runtime/sync-launcher-client';
import { createSyncUiRuntime, type SyncUiRuntimeDeps } from '../runtime/sync-ui-runtime';
import type { AppState } from '../state';
/**
* What the sync window and auto-sync scheduler need from the rest of the app. Siblings built
* after this assembly in `main.ts` are reached through getters or callbacks.
*/
export interface SyncAssemblyDeps {
appState: Pick<AppState, 'syncUiWindow' | 'initialArgs'>;
configService: Pick<ConfigService, 'getConfig'>;
logger: Pick<Logger, 'info' | 'error'>;
userDataPath: string;
defaultImmersionDbPath: string;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'showOverlayNotification'
>;
requestAppQuit: () => void;
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
/** Directory of the main bundle; the sync preload and page live next to it. */
mainDirname: string;
ipcMain: SyncUiRuntimeDeps['ipcMain'];
/** Electron app details the launcher command depends on. */
isPackaged: () => boolean;
getAppPath: () => string;
pickSnapshotFile: SyncUiRuntimeDeps['pickSnapshotFile'];
revealPath: SyncUiRuntimeDeps['revealPath'];
}
export type SyncAssembly = ReturnType<typeof createSyncAssembly>;
/**
* Owns the stats sync window, the sync IPC runtime, and the auto-sync scheduler. `main.ts`
* registers the IPC handlers, starts the scheduler, and stops both on quit.
*/
export function createSyncAssembly(deps: SyncAssemblyDeps) {
const { appState, configService, logger } = deps;
const openSyncUiWindowHandler = createOpenConfigSettingsWindowHandler({
getSettingsWindow: () => appState.syncUiWindow,
setSettingsWindow: (window) => {
appState.syncUiWindow = window as BrowserWindow | null;
},
createSettingsWindow: createCreateSyncUiWindowHandler({
createBrowserWindow: (options) => deps.createBrowserWindow(options),
preloadPath: path.join(deps.mainDirname, 'preload-syncui.js'),
}),
settingsHtmlPath: path.join(deps.mainDirname, 'syncui', 'index.html'),
promoteSettingsWindowAboveOverlay: (window) =>
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
onClosed: () => {
// requestAppQuit (not app.quit) so the forced-exit fallback covers a
// stalled quit; a standalone sync window is the app's only reason to live.
if (appState.initialArgs?.syncWindow) deps.requestAppQuit();
},
log: (message) => logger.error(message),
});
const syncUiRuntime = createSyncUiRuntime({
ipcMain: deps.ipcMain,
hostsFilePath: getSyncHostsPath(deps.userDataPath),
snapshotsDir: path.join(os.tmpdir(), 'subminer-db-snapshots'),
getDbPath: () => {
const configured = configService.getConfig().immersionTracking?.dbPath?.trim();
return configured || deps.defaultImmersionDbPath;
},
resolveLauncherCommand: () =>
resolveSyncLauncherCommand({
execPath: process.execPath,
appPath: deps.isPackaged() ? null : deps.getAppPath(),
}),
runLauncher: runSyncLauncher,
getWindow: () => appState.syncUiWindow,
pickSnapshotFile: deps.pickSnapshotFile,
revealPath: deps.revealPath,
nowMs: () => Date.now(),
log: (message) => logger.info(message),
notify: (payload) =>
deps.getOverlayNotifications().showOverlayNotification({
title: payload.title,
body: payload.body,
variant: payload.variant,
}),
});
// Live sync uses a consistent WAL snapshot plus a transactional merge, and it
// skips unfinished sessions, so resident stats writers do not block scheduling.
const syncAutoScheduler = createSyncAutoScheduler({
readState: () => syncUiRuntime.readState(),
isRunning: () => syncUiRuntime.isRunning(),
triggerHostSync: (host, direction) => {
void syncUiRuntime.runHostSync({ host, direction }, { notify: true });
},
nowMs: () => Date.now(),
log: (message) => logger.info(message),
});
return {
openWindow: openSyncUiWindowHandler,
runtime: syncUiRuntime,
autoScheduler: syncAutoScheduler,
};
}
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_CONFIG } from '../../config/definitions';
import type { ResolvedConfig } from '../../types';
import { createYoutubeAssembly, type YoutubeAssemblyDeps } from './youtube';
type StatusCall = { message: string; title?: string };
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(overrides: Partial<YoutubeAssemblyDeps> = {}): YoutubeAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
appState: {
mpvClient: null,
currentMediaPath: null,
ankiIntegration: null,
windowTracker: null,
mpvSocketPath: '/tmp/subminer-test.sock',
initialArgs: null,
},
getConfig: lazy('getConfig'),
logger: { info: () => {}, warn: () => {}, debug: () => {} },
getWhisperModelDirectory: lazy('getWhisperModelDirectory'),
getMainOverlayWindow: lazy('getMainOverlayWindow'),
overlayModalRuntime: {
sendToActiveOverlayWindow: lazy('sendToActiveOverlayWindow'),
waitForModalOpen: lazy('waitForModalOpen'),
},
getOverlayNotifications: lazy('getOverlayNotifications'),
getOverlayGeometry: lazy('getOverlayGeometry'),
showMpvOsd: lazy('showMpvOsd'),
showDesktopNotification: lazy('showDesktopNotification'),
refreshCurrentSubtitle: lazy('refreshCurrentSubtitle'),
refreshSubtitleSidebarSource: lazy('refreshSubtitleSidebarSource'),
startTokenizationWarmups: lazy('startTokenizationWarmups'),
waitForMediaTokenizationReady: lazy('waitForMediaTokenizationReady'),
generateYoutubeSubtitles: lazy('generateYoutubeSubtitles'),
openSubtitleGenerationModal: lazy('openSubtitleGenerationModal'),
invalidatePendingAutoplayReadyFallbacks: lazy('invalidatePendingAutoplayReadyFallbacks'),
ensurePlaybackRuntimeReady: lazy('ensurePlaybackRuntimeReady'),
launchWindowsMpv: lazy('launchWindowsMpv'),
ensureMpvConnectedForPlayback: lazy('ensureMpvConnectedForPlayback'),
requestAppQuit: lazy('requestAppQuit'),
browserWindow: {
createBrowserWindow: lazy('createBrowserWindow'),
preloadPath: '/tmp/preload-youtube-browser.js',
ipcMain: { on: lazy('ipcMain.on') },
showContextMenu: lazy('showContextMenu'),
openExternal: lazy('openExternal'),
},
...overrides,
};
}
function makeNotifications(calls: StatusCall[]) {
return {
getConfiguredStatusNotificationType: () => 'overlay' as const,
showConfiguredStatusNotification: (message: string, options?: { title?: string }) => {
calls.push({ message, title: options?.title });
},
showOverlayNotification: () => {},
showYoutubeFlowStatusNotification: () => {},
};
}
const backgroundCacheConfig: ResolvedConfig = {
...DEFAULT_CONFIG,
youtube: {
...DEFAULT_CONFIG.youtube,
mediaCache: { ...DEFAULT_CONFIG.youtube.mediaCache, mode: 'background' },
},
};
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createYoutubeAssembly does not call its deps while constructing', () => {
const youtube = createYoutubeAssembly(makeDeps());
assert.equal(youtube.shouldQuitWhenMpvCloses(), false);
});
test('track picker refuses to open outside YouTube playback', async () => {
const calls: StatusCall[] = [];
const youtube = createYoutubeAssembly(
makeDeps({
appState: {
mpvClient: null,
currentMediaPath: '/media/episode-01.mkv',
ankiIntegration: null,
windowTracker: null,
mpvSocketPath: '/tmp/subminer-test.sock',
initialArgs: null,
},
getConfig: () => backgroundCacheConfig,
getOverlayNotifications: () => makeNotifications(calls),
}),
);
assert.equal(youtube.isPlaybackActiveNow(), false);
assert.equal(youtube.shouldRequireMediaCacheForCurrentPlayback(), false);
await youtube.openTrackPickerFromPlayback();
assert.deepEqual(calls, [
{
message: 'YouTube subtitle picker is only available during YouTube playback.',
title: 'YouTube subtitles',
},
]);
});
+586
View File
@@ -0,0 +1,586 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { sendMpvCommandRuntime } from '../../core/services';
import {
acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks,
} from '../../core/services/youtube/generate';
import { createYoutubeMediaCacheService } from '../../core/services/youtube/media-cache';
import { resolveYoutubePlaybackUrl } from '../../core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from '../../core/services/youtube/track-probe';
import { createYoutubeWhisperSubtitleService } from '../../core/services/youtube/whisper-subtitles';
import type { BrowserWindow } from 'electron';
import type { Logger } from '../../logger';
import type { ResolvedConfig, WindowGeometry } from '../../types';
import type { OverlayModalRuntime } from '../overlay-runtime';
import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import type { createSubtitleGenerationRuntime } from '../runtime/subtitle-generation-runtime';
import { createWaitForMpvConnectedHandler } from '../runtime/jellyfin-remote-connection';
import { createYoutubeBrowserPlaybackRuntime } from '../runtime/youtube-browser-playback';
import {
createYoutubeBrowserWindowRuntime,
type YoutubeBrowserWindowDeps,
} from '../runtime/youtube-browser-window';
import { createYoutubeFlowRuntime } from '../runtime/youtube-flow';
import { createYoutubeMediaCachePlaybackRuntime } from '../runtime/youtube-media-cache-playback';
import { openYoutubeTrackPicker } from '../runtime/youtube-picker-open';
import { isYoutubeMediaPath } from '../runtime/youtube-playback';
import { createPrepareYoutubePlaybackInMpvHandler } from '../runtime/youtube-playback-launch';
import {
createYoutubePlaybackRuntime,
type YoutubePlaybackRuntimeDeps,
} from '../runtime/youtube-playback-runtime';
import {
clearYoutubePrimarySubtitleNotificationTimer,
createYoutubePrimarySubtitleNotificationRuntime,
} from '../runtime/youtube-primary-subtitle-notification';
import type { AppState } from '../state';
const YOUTUBE_MPV_CONNECT_TIMEOUT_MS = 3000;
const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000;
// mpv's ytdl hook resolves the stream before tracks appear; a cold yt-dlp run takes well over 5s.
const YOUTUBE_MPV_LOAD_TIMEOUT_MS = 20000;
const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b';
type OverlayNotifications = Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
| 'getConfiguredStatusNotificationType'
| 'showConfiguredStatusNotification'
| 'showOverlayNotification'
| 'showYoutubeFlowStatusNotification'
>;
type OverlayGeometry = Pick<
ReturnType<typeof createOverlayGeometryRuntime>,
'geometryMatches' | 'getLastOverlayWindowGeometry'
>;
type YoutubeFlowDeps = Parameters<typeof createYoutubeFlowRuntime>[0];
/**
* What the YouTube domain needs from the rest of the app. Siblings built after this assembly in
* `main.ts` are reached through getters or callbacks, which are only called once the app runs.
*/
export interface YoutubeAssemblyDeps {
appState: Pick<
AppState,
| 'mpvClient'
| 'currentMediaPath'
| 'ankiIntegration'
| 'windowTracker'
| 'mpvSocketPath'
| 'initialArgs'
>;
getConfig: () => ResolvedConfig;
logger: Pick<Logger, 'info' | 'warn' | 'debug'>;
getWhisperModelDirectory: () => string;
getMainOverlayWindow: () => BrowserWindow | null;
overlayModalRuntime: Pick<OverlayModalRuntime, 'sendToActiveOverlayWindow' | 'waitForModalOpen'>;
getOverlayNotifications: () => OverlayNotifications;
getOverlayGeometry: () => OverlayGeometry;
showMpvOsd: (text: string) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
refreshCurrentSubtitle: YoutubeFlowDeps['refreshCurrentSubtitle'];
refreshSubtitleSidebarSource: YoutubeFlowDeps['refreshSubtitleSidebarSource'];
startTokenizationWarmups: () => Promise<void>;
waitForMediaTokenizationReady: (mediaPath: string | null) => Promise<void>;
generateYoutubeSubtitles: ReturnType<
typeof createSubtitleGenerationRuntime
>['generateYoutubeSubtitles'];
openSubtitleGenerationModal: YoutubeFlowDeps['openSubtitleGenerationModal'];
invalidatePendingAutoplayReadyFallbacks: () => void;
ensurePlaybackRuntimeReady: () => Promise<void>;
launchWindowsMpv: YoutubePlaybackRuntimeDeps['launchWindowsMpv'];
ensureMpvConnectedForPlayback: () => Promise<boolean>;
requestAppQuit: () => void;
browserWindow: Pick<
YoutubeBrowserWindowDeps,
'createBrowserWindow' | 'preloadPath' | 'ipcMain' | 'showContextMenu' | 'openExternal'
>;
}
export type YoutubeAssembly = ReturnType<typeof createYoutubeAssembly>;
/**
* Owns YouTube playback, subtitle flow, media cache, primary-subtitle notifications and the
* in-app YouTube browser. `main.ts` builds it once and reaches the domain through the result.
*/
export function createYoutubeAssembly(deps: YoutubeAssemblyDeps) {
const { appState, logger } = deps;
const whisperSubtitles = createYoutubeWhisperSubtitleService({
getConfig: () => deps.getConfig().subtitleGeneration,
getModelDirectory: deps.getWhisperModelDirectory,
});
const flow = createYoutubeFlowRuntime({
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
acquireYoutubeSubtitleTracks: (input) => acquireYoutubeSubtitleTracks(input),
openPicker: async (payload) => {
return await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) =>
deps.overlayModalRuntime.sendToActiveOverlayWindow(
channel,
nextPayload,
runtimeOptions,
),
waitForModalOpen: (modal, timeoutMs) =>
deps.overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
logWarn: (message) => logger.warn(message),
},
payload,
);
},
pauseMpv: () => {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']);
},
resumeMpv: () => {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'no']);
},
sendMpvCommand: (command) => {
sendMpvCommandRuntime(appState.mpvClient, command);
},
requestMpvProperty: async (name: string) => {
const client = appState.mpvClient;
if (!client) return null;
return await client.requestProperty(name);
},
refreshCurrentSubtitle: deps.refreshCurrentSubtitle,
refreshSubtitleSidebarSource: deps.refreshSubtitleSidebarSource,
startTokenizationWarmups: async () => {
await deps.startTokenizationWarmups();
},
waitForTokenizationReady: async () => {
await deps.waitForMediaTokenizationReady(
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
);
},
waitForAnkiReady: async () => {
const integration = appState.ankiIntegration;
if (!integration) {
return;
}
try {
await Promise.race([
integration.waitUntilReady(),
new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error('Timed out waiting for AnkiConnect integration')),
2500,
);
}),
]);
} catch (error) {
logger.warn(
'Continuing YouTube playback before AnkiConnect integration reported ready:',
error instanceof Error ? error.message : String(error),
);
}
},
wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
waitForPlaybackWindowReady: async () => {
const deadline = Date.now() + 4000;
let stableGeometry: WindowGeometry | null = null;
let stableSinceMs = 0;
while (Date.now() < deadline) {
const tracker = appState.windowTracker;
const trackerGeometry = tracker?.getGeometry() ?? null;
const mediaPath =
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || '';
const trackerFocused = tracker?.isTargetWindowFocused() ?? false;
if (tracker && tracker.isTracking() && trackerGeometry && trackerFocused && mediaPath) {
if (!deps.getOverlayGeometry().geometryMatches(stableGeometry, trackerGeometry)) {
stableGeometry = trackerGeometry;
stableSinceMs = Date.now();
} else if (Date.now() - stableSinceMs >= 200) {
return;
}
} else {
stableGeometry = null;
stableSinceMs = 0;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
logger.warn(
'Timed out waiting for tracked playback window focus/media readiness before opening YouTube subtitle picker.',
);
},
waitForOverlayGeometryReady: async () => {
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
const tracker = appState.windowTracker;
const trackerGeometry = tracker?.getGeometry() ?? null;
const overlayGeometry = deps.getOverlayGeometry();
if (
trackerGeometry &&
overlayGeometry.geometryMatches(
overlayGeometry.getLastOverlayWindowGeometry(),
trackerGeometry,
)
) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
logger.warn('Timed out waiting for overlay geometry to match tracked playback window.');
},
focusOverlayWindow: () => {
const mainWindow = deps.getMainOverlayWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.setIgnoreMouseEvents(false);
if (!mainWindow.isFocused()) {
mainWindow.focus();
}
if (!mainWindow.webContents.isFocused()) {
mainWindow.webContents.focus();
}
},
showMpvOsd: (text: string) =>
deps.getOverlayNotifications().showYoutubeFlowStatusNotification(text),
reportSubtitleFailure: (message: string) => reportSubtitleFailure(message),
notifyPrimarySubtitleLoaded: () =>
primarySubtitleNotification.markCurrentMediaPrimarySubtitleLoaded(),
warn: (message: string) => logger.warn(message),
log: (message: string) => logger.info(message),
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
createSubtitleTempDir: () =>
fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-youtube-subtitles-')),
cleanupSubtitleTempDirs: (dirs) => {
for (const dir of dirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
},
getSubtitleSource: () => deps.getConfig().youtube.subtitleSource,
// Runs as the subtitle generation modal's active job, so the modal shows it and can cancel it.
generateWhisperSubtitles: ({ url, outputPath, signal, onProgress }) =>
deps.generateYoutubeSubtitles({
url,
signal,
generate: (jobSignal, reportProgress) =>
whisperSubtitles.generate({
url,
outputPath,
signal: jobSignal,
onProgress: (progress) => {
reportProgress(progress);
onProgress(progress);
},
}),
}),
openSubtitleGenerationModal: deps.openSubtitleGenerationModal,
});
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
const client = appState.mpvClient;
if (!client) return null;
const value = await client.requestProperty('path').catch(() => null);
return typeof value === 'string' ? value : null;
},
requestProperty: async (name) => {
const client = appState.mpvClient;
if (!client) return null;
return await client.requestProperty(name);
},
sendMpvCommand: (command) => {
sendMpvCommandRuntime(appState.mpvClient, command);
},
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
});
const mediaCache = createYoutubeMediaCacheService({
onDownloadStarted: (event) => {
deps
.getOverlayNotifications()
.showConfiguredStatusNotification('YouTube media cache is downloading.', {
id: 'youtube-media-cache-status',
title: 'YouTube media cache',
variant: 'progress',
persistent: true,
});
logger.info(`YouTube media cache download notification shown for ${event.url}`);
},
onReady: (event) => {
deps
.getOverlayNotifications()
.showConfiguredStatusNotification('YouTube media cache ready.', {
id: 'youtube-media-cache-status',
title: 'YouTube media cache',
variant: 'success',
persistent: false,
});
void appState.ankiIntegration
?.handleYoutubeMediaCacheReady(event.url, event.path, { notifyNoQueued: false })
.catch((error) => {
logger.warn(
`Failed to apply queued YouTube media updates: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
},
onFailed: (event) => {
deps
.getOverlayNotifications()
.showConfiguredStatusNotification('YouTube media cache failed.', {
id: 'youtube-media-cache-status',
title: 'YouTube media cache',
variant: 'error',
persistent: false,
});
void appState.ankiIntegration
?.handleYoutubeMediaCacheFailed(event.url, { notifyStatus: false })
.catch((error) => {
logger.warn(
`Failed to drain queued YouTube media updates after cache failure: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
},
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
});
const mediaCachePlayback = createYoutubeMediaCachePlaybackRuntime({
getMediaCacheConfig: () => deps.getConfig().youtube.mediaCache,
requestMpvProperty: async (name) => {
const client = appState.mpvClient;
if (!client) return null;
return await client.requestProperty(name);
},
startYoutubeMediaCache: (url, options) => {
mediaCache.start(url, options);
},
logWarn: (message) => logger.warn(message),
});
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
getMpvClient: () => appState.mpvClient,
now: () => Date.now(),
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
});
const playback = createYoutubePlaybackRuntime({
platform: process.platform,
directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT,
mpvYtdlFormat: YOUTUBE_MPV_YTDL_FORMAT,
autoLaunchTimeoutMs: YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS,
connectTimeoutMs: YOUTUBE_MPV_CONNECT_TIMEOUT_MS,
getSocketPath: () => appState.mpvSocketPath,
getMpvConnected: () => Boolean(appState.mpvClient?.connected),
invalidatePendingAutoplayReadyFallbacks: () => deps.invalidatePendingAutoplayReadyFallbacks(),
setAppOwnedFlowInFlight: (next) => {
primarySubtitleNotification.setAppOwnedFlowInFlight(next);
},
ensureYoutubePlaybackRuntimeReady: async () => {
await deps.ensurePlaybackRuntimeReady();
},
resolveYoutubePlaybackUrl: (url, format) => resolveYoutubePlaybackUrl(url, format),
launchWindowsMpv: deps.launchWindowsMpv,
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) =>
prepareYoutubePlaybackInMpv({ ...request, timeoutMs: YOUTUBE_MPV_LOAD_TIMEOUT_MS }),
startYoutubeMediaCache: (url) => {
const mediaCacheConfig = deps.getConfig().youtube.mediaCache;
mediaCache.start(url, {
mode: mediaCacheConfig.mode,
maxHeight: mediaCacheConfig.maxHeight,
});
},
runYoutubePlaybackFlow: (request) => flow.runYoutubePlaybackFlow(request),
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
clearScheduled: (timer) => clearTimeout(timer),
});
const primarySubtitleNotification = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => deps.getConfig().youtube.primarySubLanguages,
notifyFailure: (message) => reportSubtitleFailure(message),
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
clearSchedule: clearYoutubePrimarySubtitleNotificationTimer,
getCurrentSubtitleState: async () => {
const client = appState.mpvClient;
if (!client?.connected) {
return null;
}
const [sid, trackList] = await Promise.all([
client.requestProperty('sid').catch(() => null),
client.requestProperty('track-list').catch(() => null),
]);
return {
sid,
trackList: Array.isArray(trackList) ? trackList : null,
};
},
});
function isPlaybackActiveNow(): boolean {
return Boolean(getCurrentMediaCacheSourceUrlSnapshot());
}
function getCurrentMediaCacheSourceUrlSnapshot(): string | null {
const currentMediaPath = appState.currentMediaPath?.trim() || null;
if (isYoutubeMediaPath(currentMediaPath)) {
return currentMediaPath;
}
const currentVideoPath = appState.mpvClient?.currentVideoPath?.trim() || null;
if (isYoutubeMediaPath(currentVideoPath)) {
return currentVideoPath;
}
return mediaCachePlayback.getActiveYoutubeSourceUrlSnapshot();
}
async function getCurrentMediaCacheSourceUrl(): Promise<string | null> {
const currentMediaPath = appState.currentMediaPath?.trim() || null;
if (isYoutubeMediaPath(currentMediaPath)) {
return currentMediaPath;
}
const currentVideoPath = appState.mpvClient?.currentVideoPath?.trim() || null;
if (isYoutubeMediaPath(currentVideoPath)) {
return currentVideoPath;
}
return await mediaCachePlayback.getActiveYoutubeSourceUrl();
}
function shouldRequireMediaCacheForCurrentPlayback(): boolean {
return deps.getConfig().youtube.mediaCache.mode === 'background' && isPlaybackActiveNow();
}
async function getCachedMediaPathForCurrentPlayback(
currentVideoPath: string,
_kind: 'audio' | 'video',
): Promise<string | null> {
if (deps.getConfig().youtube.mediaCache.mode !== 'background') {
return null;
}
const cacheSourceUrl = isYoutubeMediaPath(currentVideoPath)
? currentVideoPath
: await getCurrentMediaCacheSourceUrl();
if (!cacheSourceUrl) {
return null;
}
// mpv can expose the resolved stream URL here while the cache key uses the original page URL.
// Keep the active-cache fallback so current playback can still resolve the ready cached file.
return (
(await mediaCache.getCachedMediaPath(cacheSourceUrl)) ??
(await mediaCache.getActiveCachedMediaPath())
);
}
function reportSubtitleFailure(message: string): void {
const notifications = deps.getOverlayNotifications();
const type = notifications.getConfiguredStatusNotificationType();
if (type === 'none') {
return;
}
if (type === 'overlay' || type === 'both') {
notifications.showOverlayNotification({
title: 'SubMiner',
body: message,
variant: 'warning',
});
}
if (type === 'osd' || type === 'osd-system') {
deps.showMpvOsd(message);
}
if (type === 'system' || type === 'both' || type === 'osd-system') {
try {
deps.showDesktopNotification('SubMiner', { body: message });
} catch {
logger.warn(`Unable to show desktop notification: ${message}`);
}
}
}
async function openTrackPickerFromPlayback(): Promise<void> {
if (flow.hasActiveSession()) {
deps
.getOverlayNotifications()
.showConfiguredStatusNotification('YouTube subtitle flow already in progress.', {
title: 'YouTube subtitles',
variant: 'warning',
});
return;
}
const currentMediaPath =
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || '';
if (!isPlaybackActiveNow() || !currentMediaPath) {
deps
.getOverlayNotifications()
.showConfiguredStatusNotification(
'YouTube subtitle picker is only available during YouTube playback.',
{
title: 'YouTube subtitles',
variant: 'warning',
},
);
return;
}
await flow.openManualPicker({
url: currentMediaPath,
});
}
let quitWhenMpvClosesAfterBrowser = false;
const isMpvPlayingMedia = (): boolean =>
Boolean(appState.mpvClient?.connected && appState.currentMediaPath?.trim());
const browserPlayback = createYoutubeBrowserPlaybackRuntime({
isMpvPlaying: isMpvPlayingMedia,
// Windows playback bootstraps its own mpv inside the playback flow.
ensureMpvReady: async () =>
process.platform === 'win32' ? true : await deps.ensureMpvConnectedForPlayback(),
runPlaybackFlow: (url) => playback.runYoutubePlaybackFlow({ url, source: 'youtube-browser' }),
appendToMpvPlaylist: (url) => {
sendMpvCommandRuntime(appState.mpvClient, ['loadfile', url, 'append']);
},
notifyFailure: (message) =>
deps
.getOverlayNotifications()
.showConfiguredStatusNotification(message, { title: 'YouTube' }),
logWarn: (message) => logger.warn(message),
onStartupFailed: () => quitAfterBrowserIfIdle(),
});
const browserWindow = createYoutubeBrowserWindowRuntime({
...deps.browserWindow,
openVideo: (request) => browserPlayback.openVideo(request),
logWarn: (message, error) => logger.warn(message, error),
logDebug: (message) => logger.debug(message),
onClosed: () => {
if (!appState.initialArgs?.youtubeBrowser) return;
quitWhenMpvClosesAfterBrowser = true;
quitAfterBrowserIfIdle();
},
});
// A process started just for the browser (`--youtube-browser`) exits once the window is closed,
// but keeps the overlay alive until mpv closes if a video is playing or still starting. Rechecked
// when a start fails, so a failed start after the window closed does not leave the app running.
const quitAfterBrowserIfIdle = (): void => {
if (!quitWhenMpvClosesAfterBrowser || browserWindow.isOpen()) return;
if (isMpvPlayingMedia() || browserPlayback.isStartingPlayback()) return;
deps.requestAppQuit();
};
return {
flow,
playback,
mediaCache,
mediaCachePlayback,
whisperSubtitles,
primarySubtitleNotification,
browserPlayback,
browserWindow,
isPlaybackActiveNow,
getCurrentMediaCacheSourceUrl,
shouldRequireMediaCacheForCurrentPlayback,
getCachedMediaPathForCurrentPlayback,
openTrackPickerFromPlayback,
/** True once a `--youtube-browser` process's window has closed, so mpv closing ends the app. */
shouldQuitWhenMpvCloses: () => quitWhenMpvClosesAfterBrowser && !browserWindow.isOpen(),
};
}