mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
refactor(main): move domains into per-domain assemblies (#309)
* refactor(main): move the YouTube domain into an assembly createYoutubeAssembly owns the YouTube flow, playback, media cache, whisper subtitles, primary-subtitle notifications, and the in-app browser, plus the browser quit-on-close state. main.ts builds it once where the first YouTube runtime used to be and calls through the result. Siblings built later are passed as getters or callbacks; tests pin that construction calls none of them. * docs(architecture): describe per-domain assemblies * test(e2e): blank the line before mining the sub-delay cue The previous test leaves the same cue on screen, so the overlay check passed before the seek landed and --mine-sentence could read mpv's empty mid-seek sub-text and skip the card. * refactor(main): move the Jellyfin domain into an assembly createJellyfinAssembly owns the Jellyfin composer call, its playback and remote-progress state, the subtitle cache IO, mpv defaults, and the tray discovery deps. main.ts builds it where the composer call used to be and reads state through getActivePlayback/getQuitOnDisconnectArmed. * refactor(main): move the AniList domain into an assembly createAnilistAssembly owns the AniList setup and tracking composer calls, the setup window, the state runtime, and the token, media-guess, in-flight and attempted-update state they share. main.ts builds it where the setup composer call used to be and keeps the rate limiter, which the stats cover-art fetcher also uses. * refactor(main): move config hot-reload, settings, and sync UI into assemblies createSettingsAssembly owns config hot-reload and the settings window, which share the hot-reload-applied handler; main.ts still supplies that handler's cross-domain deps and the Yomitan deck-name helper. createSyncAssembly owns the sync window, sync IPC runtime, and auto-sync scheduler. Both are built where their blocks were; main.ts keeps the registerHandlers()/start() calls at the same points. * refactor(main): move dictionary runtimes into assemblies createDictionariesAssembly owns the JLPT and frequency lookups and their dictionary-root builders; main.ts still resolves the root paths at the same point. createCharacterDictionaryAssembly owns the character dictionary runtime, Yomitan auto-sync, and the portrait and name-candidate lookups. Both are built where their blocks were; later siblings (Yomitan profile policy, notifications, OSD, subtitle refresh) come in as getters or callbacks. * refactor(main): move overlay modal openers into an assembly createOverlayModalsAssembly owns the shared open-with-notification helper and the runtime options, Jimaku, TsukiHime, subtitle generation, session help, changelog, character dictionary manager, controller, and playlist browser openers. main.ts builds it where the openers used to live. * refactor(logger): export the Logger type for assembly deps The build emits declarations, and the Jellyfin assembly returns the logger in its tray discovery deps, so tsc needs a nameable Logger type. Assemblies now Pick from it directly.
This commit is contained in:
1 parent
a9a4c4f083
commit
2924f04316
23 files changed
+3173
-2010
No files matched your search
+1
-1
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user