feat(core): add Electron runtime, services, and app composition

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 448ce03fd4
commit d3fd47f0ec
562 changed files with 69719 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { composeAnilistSetupHandlers } from './anilist-setup-composer';
test('composeAnilistSetupHandlers returns callable setup handlers', () => {
const composed = composeAnilistSetupHandlers({
notifyDeps: {
hasMpvClient: () => false,
showMpvOsd: () => {},
showDesktopNotification: () => {},
logInfo: () => {},
},
consumeTokenDeps: {
consumeAnilistSetupCallbackUrl: () => false,
saveToken: () => {},
setCachedToken: () => {},
setResolvedState: () => {},
setSetupPageOpened: () => {},
onSuccess: () => {},
closeWindow: () => {},
},
handleProtocolDeps: {
consumeAnilistSetupTokenFromUrl: () => false,
logWarn: () => {},
},
registerProtocolClientDeps: {
isDefaultApp: () => false,
getArgv: () => [],
execPath: process.execPath,
resolvePath: (value) => value,
setAsDefaultProtocolClient: () => true,
logWarn: () => {},
},
});
assert.equal(typeof composed.notifyAnilistSetup, 'function');
assert.equal(typeof composed.consumeAnilistSetupTokenFromUrl, 'function');
assert.equal(typeof composed.handleAnilistSetupProtocolUrl, 'function');
assert.equal(typeof composed.registerSubminerProtocolClient, 'function');
});

View File

@@ -0,0 +1,56 @@
import {
createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler,
createBuildHandleAnilistSetupProtocolUrlMainDepsHandler,
createBuildNotifyAnilistSetupMainDepsHandler,
createBuildRegisterSubminerProtocolClientMainDepsHandler,
createConsumeAnilistSetupTokenFromUrlHandler,
createHandleAnilistSetupProtocolUrlHandler,
createNotifyAnilistSetupHandler,
createRegisterSubminerProtocolClientHandler,
} from '../domains/anilist';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type NotifyHandler = ReturnType<typeof createNotifyAnilistSetupHandler>;
type ConsumeHandler = ReturnType<typeof createConsumeAnilistSetupTokenFromUrlHandler>;
type HandleProtocolHandler = ReturnType<typeof createHandleAnilistSetupProtocolUrlHandler>;
type RegisterClientHandler = ReturnType<typeof createRegisterSubminerProtocolClientHandler>;
export type AnilistSetupComposerOptions = ComposerInputs<{
notifyDeps: Parameters<typeof createBuildNotifyAnilistSetupMainDepsHandler>[0];
consumeTokenDeps: Parameters<typeof createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler>[0];
handleProtocolDeps: Parameters<typeof createBuildHandleAnilistSetupProtocolUrlMainDepsHandler>[0];
registerProtocolClientDeps: Parameters<
typeof createBuildRegisterSubminerProtocolClientMainDepsHandler
>[0];
}>;
export type AnilistSetupComposerResult = ComposerOutputs<{
notifyAnilistSetup: NotifyHandler;
consumeAnilistSetupTokenFromUrl: ConsumeHandler;
handleAnilistSetupProtocolUrl: HandleProtocolHandler;
registerSubminerProtocolClient: RegisterClientHandler;
}>;
export function composeAnilistSetupHandlers(
options: AnilistSetupComposerOptions,
): AnilistSetupComposerResult {
const notifyAnilistSetup = createNotifyAnilistSetupHandler(
createBuildNotifyAnilistSetupMainDepsHandler(options.notifyDeps)(),
);
const consumeAnilistSetupTokenFromUrl = createConsumeAnilistSetupTokenFromUrlHandler(
createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler(options.consumeTokenDeps)(),
);
const handleAnilistSetupProtocolUrl = createHandleAnilistSetupProtocolUrlHandler(
createBuildHandleAnilistSetupProtocolUrlMainDepsHandler(options.handleProtocolDeps)(),
);
const registerSubminerProtocolClient = createRegisterSubminerProtocolClientHandler(
createBuildRegisterSubminerProtocolClientMainDepsHandler(options.registerProtocolClientDeps)(),
);
return {
notifyAnilistSetup,
consumeAnilistSetupTokenFromUrl,
handleAnilistSetupProtocolUrl,
registerSubminerProtocolClient,
};
}

View File

@@ -0,0 +1,237 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AnilistMediaGuess } from '../../../core/services/anilist/anilist-updater';
import { composeAnilistTrackingHandlers } from './anilist-tracking-composer';
test('composeAnilistTrackingHandlers returns callable handlers and forwards calls to deps', async () => {
const refreshSavedTokens: string[] = [];
let refreshCachedToken: string | null = null;
let mediaKeyState: string | null = 'media-key';
let mediaDurationSecState: number | null = null;
let mediaGuessState: AnilistMediaGuess | null = null;
let mediaGuessPromiseState: Promise<AnilistMediaGuess | null> | null = null;
let lastDurationProbeAtMsState = 0;
let requestMpvDurationCalls = 0;
let guessAnilistMediaInfoCalls = 0;
let retryUpdateCalls = 0;
let maybeRunUpdateCalls = 0;
const composed = composeAnilistTrackingHandlers({
refreshClientSecretMainDeps: {
getResolvedConfig: () => ({ anilist: { accessToken: 'refresh-token' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => refreshCachedToken,
setCachedAccessToken: (token) => {
refreshCachedToken = token;
},
saveStoredToken: (token) => {
refreshSavedTokens.push(token);
},
loadStoredToken: () => null,
setClientSecretState: () => {},
getAnilistSetupPageOpened: () => false,
setAnilistSetupPageOpened: () => {},
openAnilistSetupWindow: () => {},
now: () => 100,
},
getCurrentMediaKeyMainDeps: {
getCurrentMediaPath: () => ' media-key ',
},
resetMediaTrackingMainDeps: {
setMediaKey: (value) => {
mediaKeyState = value;
},
setMediaDurationSec: (value) => {
mediaDurationSecState = value;
},
setMediaGuess: (value) => {
mediaGuessState = value;
},
setMediaGuessPromise: (value) => {
mediaGuessPromiseState = value;
},
setLastDurationProbeAtMs: (value) => {
lastDurationProbeAtMsState = value;
},
},
getMediaGuessRuntimeStateMainDeps: {
getMediaKey: () => mediaKeyState,
getMediaDurationSec: () => mediaDurationSecState,
getMediaGuess: () => mediaGuessState,
getMediaGuessPromise: () => mediaGuessPromiseState,
getLastDurationProbeAtMs: () => lastDurationProbeAtMsState,
},
setMediaGuessRuntimeStateMainDeps: {
setMediaKey: (value) => {
mediaKeyState = value;
},
setMediaDurationSec: (value) => {
mediaDurationSecState = value;
},
setMediaGuess: (value) => {
mediaGuessState = value;
},
setMediaGuessPromise: (value) => {
mediaGuessPromiseState = value;
},
setLastDurationProbeAtMs: (value) => {
lastDurationProbeAtMsState = value;
},
},
resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => {
mediaGuessState = value;
},
setMediaGuessPromise: (value) => {
mediaGuessPromiseState = value;
},
},
maybeProbeDurationMainDeps: {
getState: () => ({
mediaKey: mediaKeyState,
mediaDurationSec: mediaDurationSecState,
mediaGuess: mediaGuessState,
mediaGuessPromise: mediaGuessPromiseState,
lastDurationProbeAtMs: lastDurationProbeAtMsState,
}),
setState: (state) => {
mediaKeyState = state.mediaKey;
mediaDurationSecState = state.mediaDurationSec;
mediaGuessState = state.mediaGuess;
mediaGuessPromiseState = state.mediaGuessPromise;
lastDurationProbeAtMsState = state.lastDurationProbeAtMs;
},
durationRetryIntervalMs: 0,
now: () => 1000,
requestMpvDuration: async () => {
requestMpvDurationCalls += 1;
return 120;
},
logWarn: () => {},
},
ensureMediaGuessMainDeps: {
getState: () => ({
mediaKey: mediaKeyState,
mediaDurationSec: mediaDurationSecState,
mediaGuess: mediaGuessState,
mediaGuessPromise: mediaGuessPromiseState,
lastDurationProbeAtMs: lastDurationProbeAtMsState,
}),
setState: (state) => {
mediaKeyState = state.mediaKey;
mediaDurationSecState = state.mediaDurationSec;
mediaGuessState = state.mediaGuess;
mediaGuessPromiseState = state.mediaGuessPromise;
lastDurationProbeAtMsState = state.lastDurationProbeAtMs;
},
resolveMediaPathForJimaku: (value) => value,
getCurrentMediaPath: () => '/tmp/media.mkv',
getCurrentMediaTitle: () => 'Episode title',
guessAnilistMediaInfo: async () => {
guessAnilistMediaInfoCalls += 1;
return { title: 'Episode title', episode: 7, source: 'guessit' };
},
},
processNextRetryUpdateMainDeps: {
nextReady: () => ({ key: 'retry-key', title: 'Retry title', episode: 1 }),
refreshRetryQueueState: () => {},
setLastAttemptAt: () => {},
setLastError: () => {},
refreshAnilistClientSecretState: async () => 'retry-token',
updateAnilistPostWatchProgress: async () => {
retryUpdateCalls += 1;
return { status: 'updated', message: 'ok' };
},
markSuccess: () => {},
rememberAttemptedUpdateKey: () => {},
markFailure: () => {},
logInfo: () => {},
now: () => 1,
},
maybeRunPostWatchUpdateMainDeps: {
getInFlight: () => false,
setInFlight: () => {},
getResolvedConfig: () => ({ tracking: true }),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => 'media-key',
hasMpvClient: () => true,
getTrackedMediaKey: () => 'media-key',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 500,
maybeProbeAnilistDuration: async () => 600,
ensureAnilistMediaGuess: async () => ({
title: 'Episode title',
episode: 2,
source: 'guessit',
}),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
refreshAnilistClientSecretState: async () => 'run-token',
enqueueRetry: () => {},
markRetryFailure: () => {},
markRetrySuccess: () => {},
refreshRetryQueueState: () => {},
updateAnilistPostWatchProgress: async () => {
maybeRunUpdateCalls += 1;
return { status: 'updated', message: 'updated from maybeRun' };
},
rememberAttemptedUpdateKey: () => {},
showMpvOsd: () => {},
logInfo: () => {},
logWarn: () => {},
minWatchSeconds: 10,
minWatchRatio: 0.5,
},
});
assert.equal(typeof composed.refreshAnilistClientSecretState, 'function');
assert.equal(typeof composed.getCurrentAnilistMediaKey, 'function');
assert.equal(typeof composed.resetAnilistMediaTracking, 'function');
assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function');
assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function');
assert.equal(typeof composed.resetAnilistMediaGuessState, 'function');
assert.equal(typeof composed.maybeProbeAnilistDuration, 'function');
assert.equal(typeof composed.ensureAnilistMediaGuess, 'function');
assert.equal(typeof composed.processNextAnilistRetryUpdate, 'function');
assert.equal(typeof composed.maybeRunAnilistPostWatchUpdate, 'function');
const refreshed = await composed.refreshAnilistClientSecretState({ force: true });
assert.equal(refreshed, 'refresh-token');
assert.deepEqual(refreshSavedTokens, ['refresh-token']);
assert.equal(composed.getCurrentAnilistMediaKey(), 'media-key');
composed.resetAnilistMediaTracking('next-key');
assert.equal(mediaKeyState, 'next-key');
assert.equal(mediaDurationSecState, null);
composed.setAnilistMediaGuessRuntimeState({
mediaKey: 'media-key',
mediaDurationSec: 90,
mediaGuess: { title: 'Known', episode: 3, source: 'fallback' },
mediaGuessPromise: null,
lastDurationProbeAtMs: 11,
});
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90);
composed.resetAnilistMediaGuessState();
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null);
mediaKeyState = 'media-key';
mediaDurationSecState = null;
const probedDuration = await composed.maybeProbeAnilistDuration('media-key');
assert.equal(probedDuration, 120);
assert.equal(requestMpvDurationCalls, 1);
mediaGuessState = null;
await composed.ensureAnilistMediaGuess('media-key');
assert.equal(guessAnilistMediaInfoCalls, 1);
const retryResult = await composed.processNextAnilistRetryUpdate();
assert.deepEqual(retryResult, { ok: true, message: 'ok' });
assert.equal(retryUpdateCalls, 1);
await composed.maybeRunAnilistPostWatchUpdate();
assert.equal(maybeRunUpdateCalls, 1);
});

View File

@@ -0,0 +1,129 @@
import {
createBuildEnsureAnilistMediaGuessMainDepsHandler,
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
createBuildMaybeProbeAnilistDurationMainDepsHandler,
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
createBuildRefreshAnilistClientSecretStateMainDepsHandler,
createBuildResetAnilistMediaGuessStateMainDepsHandler,
createBuildResetAnilistMediaTrackingMainDepsHandler,
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
createEnsureAnilistMediaGuessHandler,
createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler,
createMaybeProbeAnilistDurationHandler,
createMaybeRunAnilistPostWatchUpdateHandler,
createProcessNextAnilistRetryUpdateHandler,
createRefreshAnilistClientSecretStateHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler,
} from '../domains/anilist';
import type { ComposerInputs, ComposerOutputs } from './contracts';
export type AnilistTrackingComposerOptions = ComposerInputs<{
refreshClientSecretMainDeps: Parameters<
typeof createBuildRefreshAnilistClientSecretStateMainDepsHandler
>[0];
getCurrentMediaKeyMainDeps: Parameters<
typeof createBuildGetCurrentAnilistMediaKeyMainDepsHandler
>[0];
resetMediaTrackingMainDeps: Parameters<
typeof createBuildResetAnilistMediaTrackingMainDepsHandler
>[0];
getMediaGuessRuntimeStateMainDeps: Parameters<
typeof createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler
>[0];
setMediaGuessRuntimeStateMainDeps: Parameters<
typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler
>[0];
resetMediaGuessStateMainDeps: Parameters<
typeof createBuildResetAnilistMediaGuessStateMainDepsHandler
>[0];
maybeProbeDurationMainDeps: Parameters<
typeof createBuildMaybeProbeAnilistDurationMainDepsHandler
>[0];
ensureMediaGuessMainDeps: Parameters<typeof createBuildEnsureAnilistMediaGuessMainDepsHandler>[0];
processNextRetryUpdateMainDeps: Parameters<
typeof createBuildProcessNextAnilistRetryUpdateMainDepsHandler
>[0];
maybeRunPostWatchUpdateMainDeps: Parameters<
typeof createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler
>[0];
}>;
export type AnilistTrackingComposerResult = ComposerOutputs<{
refreshAnilistClientSecretState: ReturnType<typeof createRefreshAnilistClientSecretStateHandler>;
getCurrentAnilistMediaKey: ReturnType<typeof createGetCurrentAnilistMediaKeyHandler>;
resetAnilistMediaTracking: ReturnType<typeof createResetAnilistMediaTrackingHandler>;
getAnilistMediaGuessRuntimeState: ReturnType<
typeof createGetAnilistMediaGuessRuntimeStateHandler
>;
setAnilistMediaGuessRuntimeState: ReturnType<
typeof createSetAnilistMediaGuessRuntimeStateHandler
>;
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
processNextAnilistRetryUpdate: ReturnType<typeof createProcessNextAnilistRetryUpdateHandler>;
maybeRunAnilistPostWatchUpdate: ReturnType<typeof createMaybeRunAnilistPostWatchUpdateHandler>;
}>;
export function composeAnilistTrackingHandlers(
options: AnilistTrackingComposerOptions,
): AnilistTrackingComposerResult {
const refreshAnilistClientSecretState = createRefreshAnilistClientSecretStateHandler(
createBuildRefreshAnilistClientSecretStateMainDepsHandler(
options.refreshClientSecretMainDeps,
)(),
);
const getCurrentAnilistMediaKey = createGetCurrentAnilistMediaKeyHandler(
createBuildGetCurrentAnilistMediaKeyMainDepsHandler(options.getCurrentMediaKeyMainDeps)(),
);
const resetAnilistMediaTracking = createResetAnilistMediaTrackingHandler(
createBuildResetAnilistMediaTrackingMainDepsHandler(options.resetMediaTrackingMainDeps)(),
);
const getAnilistMediaGuessRuntimeState = createGetAnilistMediaGuessRuntimeStateHandler(
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler(
options.getMediaGuessRuntimeStateMainDeps,
)(),
);
const setAnilistMediaGuessRuntimeState = createSetAnilistMediaGuessRuntimeStateHandler(
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler(
options.setMediaGuessRuntimeStateMainDeps,
)(),
);
const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler(
createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(),
);
const maybeProbeAnilistDuration = createMaybeProbeAnilistDurationHandler(
createBuildMaybeProbeAnilistDurationMainDepsHandler(options.maybeProbeDurationMainDeps)(),
);
const ensureAnilistMediaGuess = createEnsureAnilistMediaGuessHandler(
createBuildEnsureAnilistMediaGuessMainDepsHandler(options.ensureMediaGuessMainDeps)(),
);
const processNextAnilistRetryUpdate = createProcessNextAnilistRetryUpdateHandler(
createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
options.processNextRetryUpdateMainDeps,
)(),
);
const maybeRunAnilistPostWatchUpdate = createMaybeRunAnilistPostWatchUpdateHandler(
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
options.maybeRunPostWatchUpdateMainDeps,
)(),
);
return {
refreshAnilistClientSecretState,
getCurrentAnilistMediaKey,
resetAnilistMediaTracking,
getAnilistMediaGuessRuntimeState,
setAnilistMediaGuessRuntimeState,
resetAnilistMediaGuessState,
maybeProbeAnilistDuration,
ensureAnilistMediaGuess,
processNextAnilistRetryUpdate,
maybeRunAnilistPostWatchUpdate,
};
}

View File

@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeAppReadyRuntime } from './app-ready-composer';
test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => {
const composed = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
refreshAnilistClientSecretState: async () => {},
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
criticalConfigErrorMainDeps: {
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
appReadyRuntimeMainDeps: {
loadSubtitlePosition: () => {},
resolveKeybindings: () => {},
createMpvClient: () => {},
getResolvedConfig: () => ({}) as never,
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {},
initRuntimeOptionsManager: () => {},
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {},
loadYomitanExtension: async () => {},
startJellyfinRemoteSession: async () => {},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
initializeOverlayRuntime: () => {},
handleInitialArgs: () => {},
logDebug: () => {},
now: () => Date.now(),
},
immersionTrackerStartupMainDeps: {
getResolvedConfig: () => ({}) as never,
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
createTrackerService: () =>
({
startSession: () => {},
}) as never,
setTracker: () => {},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => {},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
},
});
assert.equal(typeof composed.reloadConfig, 'function');
assert.equal(typeof composed.criticalConfigError, 'function');
assert.equal(typeof composed.appReadyRuntimeRunner, 'function');
});

View File

@@ -0,0 +1,59 @@
import { createAppReadyRuntimeRunner } from '../../app-lifecycle';
import { createBuildAppReadyRuntimeMainDepsHandler } from '../app-ready-main-deps';
import {
createBuildCriticalConfigErrorMainDepsHandler,
createBuildReloadConfigMainDepsHandler,
} from '../startup-config-main-deps';
import { createCriticalConfigErrorHandler, createReloadConfigHandler } from '../startup-config';
import { createBuildImmersionTrackerStartupMainDepsHandler } from '../immersion-startup-main-deps';
import { createImmersionTrackerStartupHandler } from '../immersion-startup';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type ReloadConfigMainDeps = Parameters<typeof createBuildReloadConfigMainDepsHandler>[0];
type CriticalConfigErrorMainDeps = Parameters<
typeof createBuildCriticalConfigErrorMainDepsHandler
>[0];
type AppReadyRuntimeMainDeps = Parameters<typeof createBuildAppReadyRuntimeMainDepsHandler>[0];
export type AppReadyComposerOptions = ComposerInputs<{
reloadConfigMainDeps: ReloadConfigMainDeps;
criticalConfigErrorMainDeps: CriticalConfigErrorMainDeps;
appReadyRuntimeMainDeps: Omit<AppReadyRuntimeMainDeps, 'reloadConfig' | 'onCriticalConfigErrors'>;
immersionTrackerStartupMainDeps: Parameters<
typeof createBuildImmersionTrackerStartupMainDepsHandler
>[0];
}>;
export type AppReadyComposerResult = ComposerOutputs<{
reloadConfig: ReturnType<typeof createReloadConfigHandler>;
criticalConfigError: ReturnType<typeof createCriticalConfigErrorHandler>;
appReadyRuntimeRunner: ReturnType<typeof createAppReadyRuntimeRunner>;
}>;
export function composeAppReadyRuntime(options: AppReadyComposerOptions): AppReadyComposerResult {
const reloadConfig = createReloadConfigHandler(
createBuildReloadConfigMainDepsHandler(options.reloadConfigMainDeps)(),
);
const criticalConfigError = createCriticalConfigErrorHandler(
createBuildCriticalConfigErrorMainDepsHandler(options.criticalConfigErrorMainDeps)(),
);
const appReadyRuntimeRunner = createAppReadyRuntimeRunner(
createBuildAppReadyRuntimeMainDepsHandler({
...options.appReadyRuntimeMainDeps,
reloadConfig,
createImmersionTracker: createImmersionTrackerStartupHandler(
createBuildImmersionTrackerStartupMainDepsHandler(
options.immersionTrackerStartupMainDeps,
)(),
),
onCriticalConfigErrors: criticalConfigError,
})(),
);
return {
reloadConfig,
criticalConfigError,
appReadyRuntimeRunner,
};
}

View File

@@ -0,0 +1,95 @@
import type { ComposerInputs } from './contracts';
import type { IpcRuntimeComposerOptions } from './ipc-runtime-composer';
import type { JellyfinRemoteComposerOptions } from './jellyfin-remote-composer';
import type { MpvRuntimeComposerOptions } from './mpv-runtime-composer';
import type { AnilistSetupComposerOptions } from './anilist-setup-composer';
type Assert<T extends true> = T;
type IsAssignable<From, To> = [From] extends [To] ? true : false;
type FakeMpvClient = {
on: (...args: unknown[]) => unknown;
connect: () => void;
};
type FakeTokenizerDeps = { isKnownWord: (text: string) => boolean };
type FakeTokenizedSubtitle = { text: string };
type RequiredAnilistSetupInputKeys = keyof ComposerInputs<AnilistSetupComposerOptions>;
type RequiredJellyfinInputKeys = keyof ComposerInputs<JellyfinRemoteComposerOptions>;
type RequiredIpcInputKeys = keyof ComposerInputs<IpcRuntimeComposerOptions>;
type RequiredMpvInputKeys = keyof ComposerInputs<
MpvRuntimeComposerOptions<FakeMpvClient, FakeTokenizerDeps, FakeTokenizedSubtitle>
>;
type _anilistHasNotifyDeps = Assert<IsAssignable<'notifyDeps', RequiredAnilistSetupInputKeys>>;
type _jellyfinHasGetMpvClient = Assert<IsAssignable<'getMpvClient', RequiredJellyfinInputKeys>>;
type _ipcHasRegistration = Assert<IsAssignable<'registration', RequiredIpcInputKeys>>;
type _mpvHasTokenizer = Assert<IsAssignable<'tokenizer', RequiredMpvInputKeys>>;
// @ts-expect-error missing required notifyDeps should fail compile-time contract
const anilistMissingRequired: AnilistSetupComposerOptions = {
consumeTokenDeps: {} as AnilistSetupComposerOptions['consumeTokenDeps'],
handleProtocolDeps: {} as AnilistSetupComposerOptions['handleProtocolDeps'],
registerProtocolClientDeps: {} as AnilistSetupComposerOptions['registerProtocolClientDeps'],
};
// @ts-expect-error missing required getMpvClient should fail compile-time contract
const jellyfinMissingRequired: JellyfinRemoteComposerOptions = {
getConfiguredSession: {} as JellyfinRemoteComposerOptions['getConfiguredSession'],
getClientInfo: {} as JellyfinRemoteComposerOptions['getClientInfo'],
getJellyfinConfig: {} as JellyfinRemoteComposerOptions['getJellyfinConfig'],
playJellyfinItem: {} as JellyfinRemoteComposerOptions['playJellyfinItem'],
logWarn: {} as JellyfinRemoteComposerOptions['logWarn'],
sendMpvCommand: {} as JellyfinRemoteComposerOptions['sendMpvCommand'],
jellyfinTicksToSeconds: {} as JellyfinRemoteComposerOptions['jellyfinTicksToSeconds'],
getActivePlayback: {} as JellyfinRemoteComposerOptions['getActivePlayback'],
clearActivePlayback: {} as JellyfinRemoteComposerOptions['clearActivePlayback'],
getSession: {} as JellyfinRemoteComposerOptions['getSession'],
getNow: {} as JellyfinRemoteComposerOptions['getNow'],
getLastProgressAtMs: {} as JellyfinRemoteComposerOptions['getLastProgressAtMs'],
setLastProgressAtMs: {} as JellyfinRemoteComposerOptions['setLastProgressAtMs'],
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: {} as JellyfinRemoteComposerOptions['logDebug'],
};
// @ts-expect-error missing required registration should fail compile-time contract
const ipcMissingRequired: IpcRuntimeComposerOptions = {
mpvCommandMainDeps: {} as IpcRuntimeComposerOptions['mpvCommandMainDeps'],
handleMpvCommandFromIpcRuntime: {} as IpcRuntimeComposerOptions['handleMpvCommandFromIpcRuntime'],
runSubsyncManualFromIpc: {} as IpcRuntimeComposerOptions['runSubsyncManualFromIpc'],
};
// @ts-expect-error missing required tokenizer should fail compile-time contract
const mpvMissingRequired: MpvRuntimeComposerOptions<
FakeMpvClient,
FakeTokenizerDeps,
FakeTokenizedSubtitle
> = {
bindMpvMainEventHandlersMainDeps: {} as MpvRuntimeComposerOptions<
FakeMpvClient,
FakeTokenizerDeps,
FakeTokenizedSubtitle
>['bindMpvMainEventHandlersMainDeps'],
mpvClientRuntimeServiceFactoryMainDeps: {} as MpvRuntimeComposerOptions<
FakeMpvClient,
FakeTokenizerDeps,
FakeTokenizedSubtitle
>['mpvClientRuntimeServiceFactoryMainDeps'],
updateMpvSubtitleRenderMetricsMainDeps: {} as MpvRuntimeComposerOptions<
FakeMpvClient,
FakeTokenizerDeps,
FakeTokenizedSubtitle
>['updateMpvSubtitleRenderMetricsMainDeps'],
warmups: {} as MpvRuntimeComposerOptions<
FakeMpvClient,
FakeTokenizerDeps,
FakeTokenizedSubtitle
>['warmups'],
};
void anilistMissingRequired;
void jellyfinMissingRequired;
void ipcMissingRequired;
void mpvMissingRequired;

View File

@@ -0,0 +1,13 @@
type ComposerShape = Record<string, unknown>;
export type ComposerInputs<T extends ComposerShape> = Readonly<Required<T>>;
export type ComposerOutputs<T extends ComposerShape> = Readonly<T>;
export type BuiltMainDeps<TFactory> = TFactory extends (
...args: infer _TFactoryArgs
) => infer TBuilder
? TBuilder extends (...args: infer _TBuilderArgs) => infer TDeps
? TDeps
: never
: never;

View File

@@ -0,0 +1,10 @@
export * from './anilist-setup-composer';
export * from './anilist-tracking-composer';
export * from './app-ready-composer';
export * from './contracts';
export * from './ipc-runtime-composer';
export * from './jellyfin-remote-composer';
export * from './jellyfin-runtime-composer';
export * from './mpv-runtime-composer';
export * from './shortcuts-runtime-composer';
export * from './startup-lifecycle-composer';

View File

@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeIpcRuntimeHandlers } from './ipc-runtime-composer';
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
let registered = false;
let receivedSourceTrackId: number | null | undefined;
const composed = composeIpcRuntimeHandlers({
mpvCommandMainDeps: {
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
playNextSubtitle: () => {},
sendMpvCommand: () => {},
isMpvConnected: () => false,
hasRuntimeOptionsManager: () => true,
},
handleMpvCommandFromIpcRuntime: () => {},
runSubsyncManualFromIpc: async (request) => {
receivedSourceTrackId = request.sourceTrackId;
return {
ok: true,
message: 'ok',
};
},
registration: {
runtimeOptions: {
getRuntimeOptionsManager: () => null,
showMpvOsd: () => {},
},
mainDeps: {
getInvisibleWindow: () => null,
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
getInvisibleOverlayVisibility: () => false,
focusMainWindow: () => {},
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => ({}) as never,
getSubtitlePosition: () => ({}) as never,
getSubtitleStyle: () => ({}) as never,
saveSubtitlePosition: () => {},
getMecabTokenizer: () => null,
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}) as never,
getSecondarySubMode: () => 'hover' as never,
getMpvClient: () => null,
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
},
ankiJimakuDeps: {
patchAnkiConnectEnabled: () => {},
getResolvedConfig: () => ({}) as never,
getRuntimeOptionsManager: () => null,
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getAnkiIntegration: () => null,
setAnkiIntegration: () => {},
getKnownWordCacheStatePath: () => '',
showDesktopNotification: () => {},
createFieldGroupingCallback: () => (() => {}) as never,
broadcastRuntimeOptionsChanged: () => {},
getFieldGroupingResolver: () => null,
setFieldGroupingResolver: () => {},
parseMediaInfo: () => ({}) as never,
getCurrentMediaPath: () => null,
jimakuFetchJson: async () => ({ data: null }) as never,
getJimakuMaxEntryResults: () => 0,
getJimakuLanguagePreference: () => 'ja' as never,
resolveJimakuApiKey: async () => null,
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/file' }),
},
registerIpcRuntimeServices: () => {
registered = true;
},
},
});
assert.equal(typeof composed.handleMpvCommandFromIpc, 'function');
assert.equal(typeof composed.runSubsyncManualFromIpc, 'function');
assert.equal(typeof composed.registerIpcRuntimeHandlers, 'function');
const result = await composed.runSubsyncManualFromIpc({
engine: 'alass',
sourceTrackId: 7,
});
assert.deepEqual(result, { ok: true, message: 'ok' });
assert.equal(receivedSourceTrackId, 7);
composed.registerIpcRuntimeHandlers();
assert.equal(registered, true);
});

View File

@@ -0,0 +1,73 @@
import type { RegisterIpcRuntimeServicesParams } from '../../ipc-runtime';
import type { SubsyncManualRunRequest, SubsyncResult } from '../../../types';
import {
createBuildMpvCommandFromIpcRuntimeMainDepsHandler,
createIpcRuntimeHandlers,
} from '../domains/ipc';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type MpvCommand = (string | number)[];
type IpcMainDeps = RegisterIpcRuntimeServicesParams['mainDeps'];
type IpcMainDepsWithoutHandlers = Omit<IpcMainDeps, 'handleMpvCommand' | 'runSubsyncManual'>;
type RunSubsyncManual = IpcMainDeps['runSubsyncManual'];
type IpcRuntimeDeps = Parameters<
typeof createIpcRuntimeHandlers<SubsyncManualRunRequest, SubsyncResult>
>[0];
export type IpcRuntimeComposerOptions = ComposerInputs<{
mpvCommandMainDeps: Parameters<typeof createBuildMpvCommandFromIpcRuntimeMainDepsHandler>[0];
handleMpvCommandFromIpcRuntime: IpcRuntimeDeps['handleMpvCommandFromIpcDeps']['handleMpvCommandFromIpcRuntime'];
runSubsyncManualFromIpc: RunSubsyncManual;
registration: {
runtimeOptions: RegisterIpcRuntimeServicesParams['runtimeOptions'];
mainDeps: IpcMainDepsWithoutHandlers;
ankiJimakuDeps: RegisterIpcRuntimeServicesParams['ankiJimakuDeps'];
registerIpcRuntimeServices: (params: RegisterIpcRuntimeServicesParams) => void;
};
}>;
export type IpcRuntimeComposerResult = ComposerOutputs<{
handleMpvCommandFromIpc: (command: MpvCommand) => void;
runSubsyncManualFromIpc: RunSubsyncManual;
registerIpcRuntimeHandlers: () => void;
}>;
export function composeIpcRuntimeHandlers(
options: IpcRuntimeComposerOptions,
): IpcRuntimeComposerResult {
const mpvCommandFromIpcRuntimeMainDeps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
options.mpvCommandMainDeps,
)();
const { handleMpvCommandFromIpc, runSubsyncManualFromIpc } = createIpcRuntimeHandlers<
SubsyncManualRunRequest,
SubsyncResult
>({
handleMpvCommandFromIpcDeps: {
handleMpvCommandFromIpcRuntime: options.handleMpvCommandFromIpcRuntime,
buildMpvCommandDeps: () => mpvCommandFromIpcRuntimeMainDeps,
},
runSubsyncManualFromIpcDeps: {
runManualFromIpc: (request) => options.runSubsyncManualFromIpc(request),
},
});
const registerIpcRuntimeHandlers = (): void => {
options.registration.registerIpcRuntimeServices({
runtimeOptions: options.registration.runtimeOptions,
mainDeps: {
...options.registration.mainDeps,
handleMpvCommand: (command) => handleMpvCommandFromIpc(command),
runSubsyncManual: (request) => runSubsyncManualFromIpc(request),
},
ankiJimakuDeps: options.registration.ankiJimakuDeps,
});
};
return {
handleMpvCommandFromIpc: (command) => handleMpvCommandFromIpc(command),
runSubsyncManualFromIpc: (request) => runSubsyncManualFromIpc(request),
registerIpcRuntimeHandlers,
};
}

View File

@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', () => {
let lastProgressAt = 0;
const composed = composeJellyfinRemoteHandlers({
getConfiguredSession: () => null,
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: 'test', deviceId: 'dev' }) as never,
getJellyfinConfig: () => ({ enabled: false }) as never,
playJellyfinItem: async () => {},
logWarn: () => {},
getMpvClient: () => null,
sendMpvCommand: () => {},
jellyfinTicksToSeconds: () => 0,
getActivePlayback: () => null,
clearActivePlayback: () => {},
getSession: () => null,
getNow: () => 0,
getLastProgressAtMs: () => lastProgressAt,
setLastProgressAtMs: (next) => {
lastProgressAt = next;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function');
assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlay, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
});

View File

@@ -0,0 +1,137 @@
import {
createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler,
createBuildHandleJellyfinRemotePlayMainDepsHandler,
createBuildHandleJellyfinRemotePlaystateMainDepsHandler,
createBuildReportJellyfinRemoteProgressMainDepsHandler,
createBuildReportJellyfinRemoteStoppedMainDepsHandler,
createHandleJellyfinRemoteGeneralCommand,
createHandleJellyfinRemotePlay,
createHandleJellyfinRemotePlaystate,
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
} from '../domains/jellyfin';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type RemotePlayPayload = Parameters<ReturnType<typeof createHandleJellyfinRemotePlay>>[0];
type RemotePlaystatePayload = Parameters<ReturnType<typeof createHandleJellyfinRemotePlaystate>>[0];
type RemoteGeneralPayload = Parameters<
ReturnType<typeof createHandleJellyfinRemoteGeneralCommand>
>[0];
type JellyfinRemotePlayMainDeps = Parameters<
typeof createBuildHandleJellyfinRemotePlayMainDepsHandler
>[0];
type JellyfinRemotePlaystateMainDeps = Parameters<
typeof createBuildHandleJellyfinRemotePlaystateMainDepsHandler
>[0];
type JellyfinRemoteGeneralMainDeps = Parameters<
typeof createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler
>[0];
type JellyfinRemoteProgressMainDeps = Parameters<
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
>[0];
export type JellyfinRemoteComposerOptions = ComposerInputs<{
getConfiguredSession: JellyfinRemotePlayMainDeps['getConfiguredSession'];
getClientInfo: JellyfinRemotePlayMainDeps['getClientInfo'];
getJellyfinConfig: JellyfinRemotePlayMainDeps['getJellyfinConfig'];
playJellyfinItem: JellyfinRemotePlayMainDeps['playJellyfinItem'];
logWarn: JellyfinRemotePlayMainDeps['logWarn'];
getMpvClient: JellyfinRemoteProgressMainDeps['getMpvClient'];
sendMpvCommand: JellyfinRemotePlaystateMainDeps['sendMpvCommand'];
jellyfinTicksToSeconds: Parameters<
typeof createBuildHandleJellyfinRemotePlaystateMainDepsHandler
>[0]['jellyfinTicksToSeconds'];
getActivePlayback: JellyfinRemoteGeneralMainDeps['getActivePlayback'];
clearActivePlayback: JellyfinRemoteProgressMainDeps['clearActivePlayback'];
getSession: JellyfinRemoteProgressMainDeps['getSession'];
getNow: JellyfinRemoteProgressMainDeps['getNow'];
getLastProgressAtMs: Parameters<
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
>[0]['getLastProgressAtMs'];
setLastProgressAtMs: Parameters<
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
>[0]['setLastProgressAtMs'];
progressIntervalMs: number;
ticksPerSecond: number;
logDebug: Parameters<
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
>[0]['logDebug'];
}>;
export type JellyfinRemoteComposerResult = ComposerOutputs<{
reportJellyfinRemoteProgress: ReturnType<typeof createReportJellyfinRemoteProgressHandler>;
reportJellyfinRemoteStopped: ReturnType<typeof createReportJellyfinRemoteStoppedHandler>;
handleJellyfinRemotePlay: (payload: RemotePlayPayload) => Promise<void>;
handleJellyfinRemotePlaystate: (payload: RemotePlaystatePayload) => Promise<void>;
handleJellyfinRemoteGeneralCommand: (payload: RemoteGeneralPayload) => Promise<void>;
}>;
export function composeJellyfinRemoteHandlers(
options: JellyfinRemoteComposerOptions,
): JellyfinRemoteComposerResult {
const buildReportJellyfinRemoteProgressMainDepsHandler =
createBuildReportJellyfinRemoteProgressMainDepsHandler({
getActivePlayback: options.getActivePlayback,
clearActivePlayback: options.clearActivePlayback,
getSession: options.getSession,
getMpvClient: options.getMpvClient,
getNow: options.getNow,
getLastProgressAtMs: options.getLastProgressAtMs,
setLastProgressAtMs: options.setLastProgressAtMs,
progressIntervalMs: options.progressIntervalMs,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug,
});
const buildReportJellyfinRemoteStoppedMainDepsHandler =
createBuildReportJellyfinRemoteStoppedMainDepsHandler({
getActivePlayback: options.getActivePlayback,
clearActivePlayback: options.clearActivePlayback,
getSession: options.getSession,
logDebug: options.logDebug,
});
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
buildReportJellyfinRemoteProgressMainDepsHandler(),
);
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler(
buildReportJellyfinRemoteStoppedMainDepsHandler(),
);
const buildHandleJellyfinRemotePlayMainDepsHandler =
createBuildHandleJellyfinRemotePlayMainDepsHandler({
getConfiguredSession: options.getConfiguredSession,
getClientInfo: options.getClientInfo,
getJellyfinConfig: options.getJellyfinConfig,
playJellyfinItem: options.playJellyfinItem,
logWarn: options.logWarn,
});
const buildHandleJellyfinRemotePlaystateMainDepsHandler =
createBuildHandleJellyfinRemotePlaystateMainDepsHandler({
getMpvClient: options.getMpvClient,
sendMpvCommand: options.sendMpvCommand,
reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force),
reportJellyfinRemoteStopped: () => reportJellyfinRemoteStopped(),
jellyfinTicksToSeconds: options.jellyfinTicksToSeconds,
});
const buildHandleJellyfinRemoteGeneralCommandMainDepsHandler =
createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler({
getMpvClient: options.getMpvClient,
sendMpvCommand: options.sendMpvCommand,
getActivePlayback: options.getActivePlayback,
reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force),
logDebug: (message) => options.logDebug(message, undefined),
});
return {
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
handleJellyfinRemotePlay: createHandleJellyfinRemotePlay(
buildHandleJellyfinRemotePlayMainDepsHandler(),
),
handleJellyfinRemotePlaystate: createHandleJellyfinRemotePlaystate(
buildHandleJellyfinRemotePlaystateMainDepsHandler(),
),
handleJellyfinRemoteGeneralCommand: createHandleJellyfinRemoteGeneralCommand(
buildHandleJellyfinRemoteGeneralCommandMainDepsHandler(),
),
};
}

View File

@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeJellyfinRuntimeHandlers } from './jellyfin-runtime-composer';
test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => {
let activePlayback: unknown = null;
let lastProgressAtMs = 0;
const composed = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => ({ jellyfin: { enabled: false, serverUrl: '' } }) as never,
loadStoredSession: () => null,
getEnv: () => undefined,
},
getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => ({}) as never,
getDefaultJellyfinConfig: () => ({
clientName: 'SubMiner',
clientVersion: 'test',
deviceId: 'dev',
}),
},
waitForMpvConnectedMainDeps: {
getMpvClient: () => null,
now: () => Date.now(),
sleep: async () => {},
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getSocketPath: () => '/tmp/test-mpv.sock',
platform: 'linux',
execPath: process.execPath,
defaultMpvLogPath: '/tmp/test-mpv.log',
defaultMpvArgs: [],
removeSocketPath: () => {},
spawnMpv: () => ({ unref: () => {} }) as never,
logWarn: () => {},
logInfo: () => {},
},
ensureMpvConnectedForJellyfinPlaybackMainDeps: {
getMpvClient: () => null,
setMpvClient: () => {},
createMpvClient: () => ({}) as never,
getAutoLaunchInFlight: () => null,
setAutoLaunchInFlight: () => {},
connectTimeoutMs: 10,
autoLaunchTimeoutMs: 10,
},
preloadJellyfinExternalSubtitlesMainDeps: {
listJellyfinSubtitleTracks: async () => [],
getMpvClient: () => null,
sendMpvCommand: () => {},
wait: async () => {},
logDebug: () => {},
},
playJellyfinItemInMpvMainDeps: {
getMpvClient: () => null,
resolvePlaybackPlan: async () => ({
mode: 'direct',
url: 'https://example.test/video.m3u8',
title: 'Episode 1',
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
sendMpvCommand: () => {},
armQuitOnDisconnect: () => {},
schedule: () => undefined,
convertTicksToSeconds: () => 0,
setActivePlayback: (value) => {
activePlayback = value;
},
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
reportPlaying: () => {},
showMpvOsd: () => {},
},
remoteComposerOptions: {
getConfiguredSession: () => null,
logWarn: () => {},
getMpvClient: () => null,
sendMpvCommand: () => {},
jellyfinTicksToSeconds: () => 0,
getActivePlayback: () => activePlayback as never,
clearActivePlayback: () => {
activePlayback = null;
},
getSession: () => null,
getNow: () => Date.now(),
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
},
handleJellyfinAuthCommandsMainDeps: {
patchRawConfig: () => {},
authenticateWithPassword: async () => ({
serverUrl: 'https://example.test',
username: 'user',
accessToken: 'token',
userId: 'id',
}),
saveStoredSession: () => {},
clearStoredSession: () => {},
logInfo: () => {},
},
handleJellyfinListCommandsMainDeps: {
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
logInfo: () => {},
},
handleJellyfinPlayCommandMainDeps: {
logWarn: () => {},
},
handleJellyfinRemoteAnnounceCommandMainDeps: {
getRemoteSession: () => null,
logInfo: () => {},
logWarn: () => {},
},
startJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: () =>
({
start: async () => {},
}) as never,
defaultDeviceId: 'dev',
defaultClientName: 'SubMiner',
defaultClientVersion: 'test',
logInfo: () => {},
logWarn: () => {},
},
stopJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => null,
setCurrentSession: () => {},
clearActivePlayback: () => {
activePlayback = null;
},
},
runJellyfinCommandMainDeps: {
defaultServerUrl: 'https://example.test',
},
maybeFocusExistingJellyfinSetupWindowMainDeps: {
getSetupWindow: () => null,
},
openJellyfinSetupWindowMainDeps: {
createSetupWindow: () =>
({
focus: () => {},
webContents: { on: () => {} },
loadURL: () => {},
on: () => {},
isDestroyed: () => false,
close: () => {},
}) as never,
buildSetupFormHtml: (defaultServer, defaultUser) =>
`<html>${defaultServer}${defaultUser}</html>`,
parseSubmissionUrl: () => null,
authenticateWithPassword: async () => ({
serverUrl: 'https://example.test',
username: 'user',
accessToken: 'token',
userId: 'id',
}),
saveStoredSession: () => {},
patchJellyfinConfig: () => {},
logInfo: () => {},
logError: () => {},
showMpvOsd: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {},
encodeURIComponent,
},
});
assert.equal(typeof composed.getResolvedJellyfinConfig, 'function');
assert.equal(typeof composed.getJellyfinClientInfo, 'function');
assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function');
assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlay, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
assert.equal(typeof composed.runJellyfinCommand, 'function');
assert.equal(typeof composed.openJellyfinSetupWindow, 'function');
});

View File

@@ -0,0 +1,290 @@
import {
buildJellyfinSetupFormHtml,
createEnsureMpvConnectedForJellyfinPlaybackHandler,
createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler,
createBuildGetJellyfinClientInfoMainDepsHandler,
createBuildGetResolvedJellyfinConfigMainDepsHandler,
createBuildHandleJellyfinAuthCommandsMainDepsHandler,
createBuildHandleJellyfinListCommandsMainDepsHandler,
createBuildHandleJellyfinPlayCommandMainDepsHandler,
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler,
createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler,
createBuildOpenJellyfinSetupWindowMainDepsHandler,
createBuildPlayJellyfinItemInMpvMainDepsHandler,
createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler,
createBuildRunJellyfinCommandMainDepsHandler,
createBuildStartJellyfinRemoteSessionMainDepsHandler,
createBuildStopJellyfinRemoteSessionMainDepsHandler,
createBuildWaitForMpvConnectedMainDepsHandler,
createGetJellyfinClientInfoHandler,
createGetResolvedJellyfinConfigHandler,
createHandleJellyfinAuthCommands,
createHandleJellyfinListCommands,
createHandleJellyfinPlayCommand,
createHandleJellyfinRemoteAnnounceCommand,
createLaunchMpvIdleForJellyfinPlaybackHandler,
createOpenJellyfinSetupWindowHandler,
createPlayJellyfinItemInMpvHandler,
createPreloadJellyfinExternalSubtitlesHandler,
createRunJellyfinCommandHandler,
createStartJellyfinRemoteSessionHandler,
createStopJellyfinRemoteSessionHandler,
createWaitForMpvConnectedHandler,
createMaybeFocusExistingJellyfinSetupWindowHandler,
parseJellyfinSetupSubmissionUrl,
} from '../domains/jellyfin';
import {
composeJellyfinRemoteHandlers,
type JellyfinRemoteComposerOptions,
} from './jellyfin-remote-composer';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type EnsureMpvConnectedMainDeps = Parameters<
typeof createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler
>[0];
type PlayJellyfinItemMainDeps = Parameters<
typeof createBuildPlayJellyfinItemInMpvMainDepsHandler
>[0];
type HandlePlayCommandMainDeps = Parameters<
typeof createBuildHandleJellyfinPlayCommandMainDepsHandler
>[0];
type HandleRemoteAnnounceMainDeps = Parameters<
typeof createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler
>[0];
type StartRemoteSessionMainDeps = Parameters<
typeof createBuildStartJellyfinRemoteSessionMainDepsHandler
>[0];
type RunJellyfinCommandMainDeps = Parameters<
typeof createBuildRunJellyfinCommandMainDepsHandler
>[0];
type OpenJellyfinSetupWindowMainDeps = Parameters<
typeof createBuildOpenJellyfinSetupWindowMainDepsHandler
>[0];
export type JellyfinRuntimeComposerOptions = ComposerInputs<{
getResolvedJellyfinConfigMainDeps: Parameters<
typeof createBuildGetResolvedJellyfinConfigMainDepsHandler
>[0];
getJellyfinClientInfoMainDeps: Parameters<
typeof createBuildGetJellyfinClientInfoMainDepsHandler
>[0];
waitForMpvConnectedMainDeps: Parameters<typeof createBuildWaitForMpvConnectedMainDepsHandler>[0];
launchMpvIdleForJellyfinPlaybackMainDeps: Parameters<
typeof createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler
>[0];
ensureMpvConnectedForJellyfinPlaybackMainDeps: Omit<
EnsureMpvConnectedMainDeps,
'waitForMpvConnected' | 'launchMpvIdleForJellyfinPlayback'
>;
preloadJellyfinExternalSubtitlesMainDeps: Parameters<
typeof createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler
>[0];
playJellyfinItemInMpvMainDeps: Omit<
PlayJellyfinItemMainDeps,
'ensureMpvConnectedForPlayback' | 'preloadExternalSubtitles'
>;
remoteComposerOptions: Omit<
JellyfinRemoteComposerOptions,
'getClientInfo' | 'getJellyfinConfig' | 'playJellyfinItem'
>;
handleJellyfinAuthCommandsMainDeps: Parameters<
typeof createBuildHandleJellyfinAuthCommandsMainDepsHandler
>[0];
handleJellyfinListCommandsMainDeps: Parameters<
typeof createBuildHandleJellyfinListCommandsMainDepsHandler
>[0];
handleJellyfinPlayCommandMainDeps: Omit<HandlePlayCommandMainDeps, 'playJellyfinItemInMpv'>;
handleJellyfinRemoteAnnounceCommandMainDeps: Omit<
HandleRemoteAnnounceMainDeps,
'startJellyfinRemoteSession'
>;
startJellyfinRemoteSessionMainDeps: Omit<
StartRemoteSessionMainDeps,
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand'
>;
stopJellyfinRemoteSessionMainDeps: Parameters<
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
>[0];
runJellyfinCommandMainDeps: Omit<
RunJellyfinCommandMainDeps,
| 'getJellyfinConfig'
| 'getJellyfinClientInfo'
| 'handleAuthCommands'
| 'handleRemoteAnnounceCommand'
| 'handleListCommands'
| 'handlePlayCommand'
>;
maybeFocusExistingJellyfinSetupWindowMainDeps: Parameters<
typeof createMaybeFocusExistingJellyfinSetupWindowHandler
>[0];
openJellyfinSetupWindowMainDeps: Omit<
OpenJellyfinSetupWindowMainDeps,
'maybeFocusExistingSetupWindow' | 'getResolvedJellyfinConfig' | 'getJellyfinClientInfo'
>;
}>;
export type JellyfinRuntimeComposerResult = ComposerOutputs<{
getResolvedJellyfinConfig: ReturnType<typeof createGetResolvedJellyfinConfigHandler>;
getJellyfinClientInfo: ReturnType<typeof createGetJellyfinClientInfoHandler>;
reportJellyfinRemoteProgress: ReturnType<
typeof composeJellyfinRemoteHandlers
>['reportJellyfinRemoteProgress'];
reportJellyfinRemoteStopped: ReturnType<
typeof composeJellyfinRemoteHandlers
>['reportJellyfinRemoteStopped'];
handleJellyfinRemotePlay: ReturnType<
typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemotePlay'];
handleJellyfinRemotePlaystate: ReturnType<
typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemotePlaystate'];
handleJellyfinRemoteGeneralCommand: ReturnType<
typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemoteGeneralCommand'];
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
openJellyfinSetupWindow: ReturnType<typeof createOpenJellyfinSetupWindowHandler>;
}>;
export function composeJellyfinRuntimeHandlers(
options: JellyfinRuntimeComposerOptions,
): JellyfinRuntimeComposerResult {
const getResolvedJellyfinConfig = createGetResolvedJellyfinConfigHandler(
createBuildGetResolvedJellyfinConfigMainDepsHandler(
options.getResolvedJellyfinConfigMainDeps,
)(),
);
const getJellyfinClientInfo = createGetJellyfinClientInfoHandler(
createBuildGetJellyfinClientInfoMainDepsHandler(options.getJellyfinClientInfoMainDeps)(),
);
const waitForMpvConnected = createWaitForMpvConnectedHandler(
createBuildWaitForMpvConnectedMainDepsHandler(options.waitForMpvConnectedMainDeps)(),
);
const launchMpvIdleForJellyfinPlayback = createLaunchMpvIdleForJellyfinPlaybackHandler(
createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
options.launchMpvIdleForJellyfinPlaybackMainDeps,
)(),
);
const ensureMpvConnectedForJellyfinPlayback = createEnsureMpvConnectedForJellyfinPlaybackHandler(
createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler({
...options.ensureMpvConnectedForJellyfinPlaybackMainDeps,
waitForMpvConnected: (timeoutMs) => waitForMpvConnected(timeoutMs),
launchMpvIdleForJellyfinPlayback: () => launchMpvIdleForJellyfinPlayback(),
})(),
);
const preloadJellyfinExternalSubtitles = createPreloadJellyfinExternalSubtitlesHandler(
createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
options.preloadJellyfinExternalSubtitlesMainDeps,
)(),
);
const playJellyfinItemInMpv = createPlayJellyfinItemInMpvHandler(
createBuildPlayJellyfinItemInMpvMainDepsHandler({
...options.playJellyfinItemInMpvMainDeps,
ensureMpvConnectedForPlayback: () => ensureMpvConnectedForJellyfinPlayback(),
preloadExternalSubtitles: (params) => {
void preloadJellyfinExternalSubtitles(params);
},
})(),
);
const {
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
handleJellyfinRemotePlay,
handleJellyfinRemotePlaystate,
handleJellyfinRemoteGeneralCommand,
} = composeJellyfinRemoteHandlers({
...options.remoteComposerOptions,
getClientInfo: () => getJellyfinClientInfo(),
getJellyfinConfig: () => getResolvedJellyfinConfig(),
playJellyfinItem: (params) =>
playJellyfinItemInMpv(params as Parameters<typeof playJellyfinItemInMpv>[0]),
});
const handleJellyfinAuthCommands = createHandleJellyfinAuthCommands(
createBuildHandleJellyfinAuthCommandsMainDepsHandler(
options.handleJellyfinAuthCommandsMainDeps,
)(),
);
const handleJellyfinListCommands = createHandleJellyfinListCommands(
createBuildHandleJellyfinListCommandsMainDepsHandler(
options.handleJellyfinListCommandsMainDeps,
)(),
);
const handleJellyfinPlayCommand = createHandleJellyfinPlayCommand(
createBuildHandleJellyfinPlayCommandMainDepsHandler({
...options.handleJellyfinPlayCommandMainDeps,
playJellyfinItemInMpv: (params) =>
playJellyfinItemInMpv(params as Parameters<typeof playJellyfinItemInMpv>[0]),
})(),
);
let startJellyfinRemoteSession!: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand(
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
...options.handleJellyfinRemoteAnnounceCommandMainDeps,
startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
})(),
);
startJellyfinRemoteSession = createStartJellyfinRemoteSessionHandler(
createBuildStartJellyfinRemoteSessionMainDepsHandler({
...options.startJellyfinRemoteSessionMainDeps,
getJellyfinConfig: () => getResolvedJellyfinConfig(),
handlePlay: (payload) => handleJellyfinRemotePlay(payload),
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
})(),
);
const stopJellyfinRemoteSession = createStopJellyfinRemoteSessionHandler(
createBuildStopJellyfinRemoteSessionMainDepsHandler(
options.stopJellyfinRemoteSessionMainDeps,
)(),
);
const runJellyfinCommand = createRunJellyfinCommandHandler(
createBuildRunJellyfinCommandMainDepsHandler({
...options.runJellyfinCommandMainDeps,
getJellyfinConfig: () => getResolvedJellyfinConfig(),
getJellyfinClientInfo: (jellyfinConfig) => getJellyfinClientInfo(jellyfinConfig),
handleAuthCommands: (params) => handleJellyfinAuthCommands(params),
handleRemoteAnnounceCommand: (args) => handleJellyfinRemoteAnnounceCommand(args),
handleListCommands: (params) => handleJellyfinListCommands(params),
handlePlayCommand: (params) => handleJellyfinPlayCommand(params),
})(),
);
const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler(
options.maybeFocusExistingJellyfinSetupWindowMainDeps,
);
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler(
createBuildOpenJellyfinSetupWindowMainDepsHandler({
...options.openJellyfinSetupWindowMainDeps,
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getJellyfinClientInfo: () => getJellyfinClientInfo(),
})(),
);
return {
getResolvedJellyfinConfig,
getJellyfinClientInfo,
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
handleJellyfinRemotePlay,
handleJellyfinRemotePlaystate,
handleJellyfinRemoteGeneralCommand,
playJellyfinItemInMpv,
startJellyfinRemoteSession,
stopJellyfinRemoteSession,
runJellyfinCommand,
openJellyfinSetupWindow,
};
}
export { buildJellyfinSetupFormHtml, parseJellyfinSetupSubmissionUrl };

View File

@@ -0,0 +1,219 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { MpvSubtitleRenderMetrics } from '../../../types';
import { composeMpvRuntimeHandlers } from './mpv-runtime-composer';
const BASE_METRICS: MpvSubtitleRenderMetrics = {
subPos: 100,
subFontSize: 36,
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
osdDimensions: null,
};
test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => {
const calls: string[] = [];
let started = false;
let metrics = BASE_METRICS;
class FakeMpvClient {
connected = false;
constructor(
public socketPath: string,
public options: unknown,
) {
const autoStartOverlay = (options as { autoStartOverlay: boolean }).autoStartOverlay;
calls.push(`create-client:${socketPath}`);
calls.push(`auto-start:${String(autoStartOverlay)}`);
}
on(): void {}
connect(): void {
this.connected = true;
calls.push('client-connect');
}
}
const composed = composeMpvRuntimeHandlers<
FakeMpvClient,
{ isKnownWord: (text: string) => boolean },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
updateCurrentMediaPath: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: FakeMpvClient,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => metrics,
setCurrentMetrics: (next) => {
metrics = next;
calls.push('set-metrics');
},
applyPatch: (current, patch) => {
calls.push('apply-metrics-patch');
return { next: { ...current, ...patch }, changed: true };
},
broadcastMetrics: () => {
calls.push('broadcast-metrics');
},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: (text) => text === 'known',
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: (deps) => {
calls.push('create-tokenizer-runtime-deps');
return { isKnownWord: (text: string) => deps.isKnownWord(text) };
},
tokenizeSubtitle: async (text, deps) => {
calls.push(`tokenize:${text}`);
deps.isKnownWord('known');
return { text };
},
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => ({ id: 'mecab' }),
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {
calls.push('check-mecab');
},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {
calls.push('prewarm-jlpt');
},
ensureFrequencyDictionaryLookup: async () => {
calls.push('prewarm-frequency');
},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 100,
logDebug: () => {
calls.push('warmup-debug');
},
logWarn: () => {
calls.push('warmup-warn');
},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => started,
setStarted: (next) => {
started = next;
calls.push(`set-started:${String(next)}`);
},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {
calls.push('warmup-yomitan');
},
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {
calls.push('warmup-jellyfin');
},
},
},
});
assert.equal(typeof composed.bindMpvClientEventHandlers, 'function');
assert.equal(typeof composed.createMpvClientRuntimeService, 'function');
assert.equal(typeof composed.updateMpvSubtitleRenderMetrics, 'function');
assert.equal(typeof composed.tokenizeSubtitle, 'function');
assert.equal(typeof composed.createMecabTokenizerAndCheck, 'function');
assert.equal(typeof composed.prewarmSubtitleDictionaries, 'function');
assert.equal(typeof composed.launchBackgroundWarmupTask, 'function');
assert.equal(typeof composed.startBackgroundWarmups, 'function');
const client = composed.createMpvClientRuntimeService();
assert.equal(client.connected, true);
composed.updateMpvSubtitleRenderMetrics({ subPos: 90 });
const tokenized = await composed.tokenizeSubtitle('subtitle text');
await composed.createMecabTokenizerAndCheck();
await composed.prewarmSubtitleDictionaries();
composed.startBackgroundWarmups();
assert.deepEqual(tokenized, { text: 'subtitle text' });
assert.equal(metrics.subPos, 90);
assert.ok(calls.includes('create-client:/tmp/mpv.sock'));
assert.ok(calls.includes('auto-start:true'));
assert.ok(calls.includes('client-connect'));
assert.ok(calls.includes('apply-metrics-patch'));
assert.ok(calls.includes('set-metrics'));
assert.ok(calls.includes('broadcast-metrics'));
assert.ok(calls.includes('create-tokenizer-runtime-deps'));
assert.ok(calls.includes('tokenize:subtitle text'));
assert.ok(calls.includes('check-mecab'));
assert.ok(calls.includes('prewarm-jlpt'));
assert.ok(calls.includes('prewarm-frequency'));
assert.ok(calls.includes('set-started:true'));
assert.ok(calls.includes('warmup-yomitan'));
});

View File

@@ -0,0 +1,167 @@
import { createBindMpvMainEventHandlersHandler } from '../mpv-main-event-bindings';
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from '../mpv-main-event-main-deps';
import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from '../mpv-client-runtime-service-main-deps';
import { createMpvClientRuntimeServiceFactory } from '../mpv-client-runtime-service';
import type { MpvClientRuntimeServiceOptions } from '../mpv-client-runtime-service';
import type { Config } from '../../../types';
import { createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler } from '../mpv-subtitle-render-metrics-main-deps';
import { createUpdateMpvSubtitleRenderMetricsHandler } from '../mpv-subtitle-render-metrics';
import {
createBuildTokenizerDepsMainHandler,
createCreateMecabTokenizerAndCheckMainHandler,
createPrewarmSubtitleDictionariesMainHandler,
} from '../subtitle-tokenization-main-deps';
import {
createBuildLaunchBackgroundWarmupTaskMainDepsHandler,
createBuildStartBackgroundWarmupsMainDepsHandler,
} from '../startup-warmups-main-deps';
import {
createLaunchBackgroundWarmupTaskHandler as createLaunchBackgroundWarmupTaskFromStartup,
createStartBackgroundWarmupsHandler as createStartBackgroundWarmupsFromStartup,
} from '../startup-warmups';
import type { BuiltMainDeps, ComposerInputs, ComposerOutputs } from './contracts';
type BindMpvMainEventHandlersMainDeps = Parameters<
typeof createBuildBindMpvMainEventHandlersMainDepsHandler
>[0];
type BindMpvMainEventHandlers = ReturnType<typeof createBindMpvMainEventHandlersHandler>;
type BoundMpvClient = Parameters<BindMpvMainEventHandlers>[0];
type RuntimeMpvClient = BoundMpvClient & { connect: () => void };
type MpvClientRuntimeServiceFactoryMainDeps<TMpvClient extends RuntimeMpvClient> = Omit<
Parameters<
typeof createBuildMpvClientRuntimeServiceFactoryDepsHandler<
TMpvClient,
Config,
MpvClientRuntimeServiceOptions
>
>[0],
'bindEventHandlers'
>;
type UpdateMpvSubtitleRenderMetricsMainDeps = Parameters<
typeof createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler
>[0];
type BuildTokenizerDepsMainDeps = Parameters<typeof createBuildTokenizerDepsMainHandler>[0];
type TokenizerMainDeps = BuiltMainDeps<typeof createBuildTokenizerDepsMainHandler>;
type CreateMecabTokenizerAndCheckMainDeps = Parameters<
typeof createCreateMecabTokenizerAndCheckMainHandler
>[0];
type PrewarmSubtitleDictionariesMainDeps = Parameters<
typeof createPrewarmSubtitleDictionariesMainHandler
>[0];
type LaunchBackgroundWarmupTaskMainDeps = Parameters<
typeof createBuildLaunchBackgroundWarmupTaskMainDepsHandler
>[0];
type StartBackgroundWarmupsMainDeps = Omit<
Parameters<typeof createBuildStartBackgroundWarmupsMainDepsHandler>[0],
'launchTask' | 'createMecabTokenizerAndCheck' | 'prewarmSubtitleDictionaries'
>;
export type MpvRuntimeComposerOptions<
TMpvClient extends RuntimeMpvClient,
TTokenizerRuntimeDeps,
TTokenizedSubtitle,
> = ComposerInputs<{
bindMpvMainEventHandlersMainDeps: BindMpvMainEventHandlersMainDeps;
mpvClientRuntimeServiceFactoryMainDeps: MpvClientRuntimeServiceFactoryMainDeps<TMpvClient>;
updateMpvSubtitleRenderMetricsMainDeps: UpdateMpvSubtitleRenderMetricsMainDeps;
tokenizer: {
buildTokenizerDepsMainDeps: BuildTokenizerDepsMainDeps;
createTokenizerRuntimeDeps: (deps: TokenizerMainDeps) => TTokenizerRuntimeDeps;
tokenizeSubtitle: (text: string, deps: TTokenizerRuntimeDeps) => Promise<TTokenizedSubtitle>;
createMecabTokenizerAndCheckMainDeps: CreateMecabTokenizerAndCheckMainDeps;
prewarmSubtitleDictionariesMainDeps: PrewarmSubtitleDictionariesMainDeps;
};
warmups: {
launchBackgroundWarmupTaskMainDeps: LaunchBackgroundWarmupTaskMainDeps;
startBackgroundWarmupsMainDeps: StartBackgroundWarmupsMainDeps;
};
}>;
export type MpvRuntimeComposerResult<
TMpvClient extends RuntimeMpvClient,
TTokenizedSubtitle,
> = ComposerOutputs<{
bindMpvClientEventHandlers: BindMpvMainEventHandlers;
createMpvClientRuntimeService: () => TMpvClient;
updateMpvSubtitleRenderMetrics: ReturnType<typeof createUpdateMpvSubtitleRenderMetricsHandler>;
tokenizeSubtitle: (text: string) => Promise<TTokenizedSubtitle>;
createMecabTokenizerAndCheck: () => Promise<void>;
prewarmSubtitleDictionaries: () => Promise<void>;
launchBackgroundWarmupTask: ReturnType<typeof createLaunchBackgroundWarmupTaskFromStartup>;
startBackgroundWarmups: ReturnType<typeof createStartBackgroundWarmupsFromStartup>;
}>;
export function composeMpvRuntimeHandlers<
TMpvClient extends RuntimeMpvClient,
TTokenizerRuntimeDeps,
TTokenizedSubtitle,
>(
options: MpvRuntimeComposerOptions<TMpvClient, TTokenizerRuntimeDeps, TTokenizedSubtitle>,
): MpvRuntimeComposerResult<TMpvClient, TTokenizedSubtitle> {
const bindMpvMainEventHandlersMainDeps = createBuildBindMpvMainEventHandlersMainDepsHandler(
options.bindMpvMainEventHandlersMainDeps,
)();
const bindMpvClientEventHandlers = createBindMpvMainEventHandlersHandler(
bindMpvMainEventHandlersMainDeps,
);
const buildMpvClientRuntimeServiceFactoryMainDepsHandler =
createBuildMpvClientRuntimeServiceFactoryDepsHandler<
TMpvClient,
Config,
MpvClientRuntimeServiceOptions
>({
...options.mpvClientRuntimeServiceFactoryMainDeps,
bindEventHandlers: (client) => bindMpvClientEventHandlers(client),
});
const createMpvClientRuntimeService = (): TMpvClient =>
createMpvClientRuntimeServiceFactory(buildMpvClientRuntimeServiceFactoryMainDepsHandler())();
const updateMpvSubtitleRenderMetrics = createUpdateMpvSubtitleRenderMetricsHandler(
createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler(
options.updateMpvSubtitleRenderMetricsMainDeps,
)(),
);
const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler(
options.tokenizer.buildTokenizerDepsMainDeps,
);
const createMecabTokenizerAndCheck = createCreateMecabTokenizerAndCheckMainHandler(
options.tokenizer.createMecabTokenizerAndCheckMainDeps,
);
const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler(
options.tokenizer.prewarmSubtitleDictionariesMainDeps,
);
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
await prewarmSubtitleDictionaries();
return options.tokenizer.tokenizeSubtitle(
text,
options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()),
);
};
const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskFromStartup(
createBuildLaunchBackgroundWarmupTaskMainDepsHandler(
options.warmups.launchBackgroundWarmupTaskMainDeps,
)(),
);
const startBackgroundWarmups = createStartBackgroundWarmupsFromStartup(
createBuildStartBackgroundWarmupsMainDepsHandler({
...options.warmups.startBackgroundWarmupsMainDeps,
launchTask: (label, task) => launchBackgroundWarmupTask(label, task),
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
})(),
);
return {
bindMpvClientEventHandlers: (client) => bindMpvClientEventHandlers(client),
createMpvClientRuntimeService,
updateMpvSubtitleRenderMetrics: (patch) => updateMpvSubtitleRenderMetrics(patch),
tokenizeSubtitle,
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
launchBackgroundWarmupTask: (label, task) => launchBackgroundWarmupTask(label, task),
startBackgroundWarmups: () => startBackgroundWarmups(),
};
}

View File

@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeShortcutRuntimes } from './shortcuts-runtime-composer';
test('composeShortcutRuntimes returns callable shortcut runtime handlers', () => {
const composed = composeShortcutRuntimes({
globalShortcuts: {
getConfiguredShortcutsMainDeps: {
getResolvedConfig: () => ({}) as never,
defaultConfig: {} as never,
resolveConfiguredShortcuts: () => ({}) as never,
},
buildRegisterGlobalShortcutsMainDeps: () => ({
getConfiguredShortcuts: () => ({}) as never,
registerGlobalShortcutsCore: () => {},
toggleVisibleOverlay: () => {},
toggleInvisibleOverlay: () => {},
openYomitanSettings: () => {},
isDev: false,
getMainWindow: () => null,
}),
buildRefreshGlobalAndOverlayShortcutsMainDeps: () => ({
unregisterAllGlobalShortcuts: () => {},
registerGlobalShortcuts: () => {},
syncOverlayShortcuts: () => {},
}),
},
numericShortcutRuntimeMainDeps: {
globalShortcut: {
register: () => true,
unregister: () => {},
},
showMpvOsd: () => {},
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
clearTimer: (timer) => clearTimeout(timer),
},
numericSessions: {
onMultiCopyDigit: () => {},
onMineSentenceDigit: () => {},
},
overlayShortcutsRuntimeMainDeps: {
overlayShortcutsRuntime: {
registerOverlayShortcuts: () => {},
unregisterOverlayShortcuts: () => {},
syncOverlayShortcuts: () => {},
refreshOverlayShortcuts: () => {},
},
},
});
assert.equal(typeof composed.getConfiguredShortcuts, 'function');
assert.equal(typeof composed.registerGlobalShortcuts, 'function');
assert.equal(typeof composed.refreshGlobalAndOverlayShortcuts, 'function');
assert.equal(typeof composed.cancelPendingMultiCopy, 'function');
assert.equal(typeof composed.startPendingMultiCopy, 'function');
assert.equal(typeof composed.cancelPendingMineSentenceMultiple, 'function');
assert.equal(typeof composed.startPendingMineSentenceMultiple, 'function');
assert.equal(typeof composed.registerOverlayShortcuts, 'function');
assert.equal(typeof composed.unregisterOverlayShortcuts, 'function');
assert.equal(typeof composed.syncOverlayShortcuts, 'function');
assert.equal(typeof composed.refreshOverlayShortcuts, 'function');
});

View File

@@ -0,0 +1,60 @@
import { createNumericShortcutRuntime } from '../../../core/services/numeric-shortcut';
import {
createBuildNumericShortcutRuntimeMainDepsHandler,
createGlobalShortcutsRuntimeHandlers,
createNumericShortcutSessionRuntimeHandlers,
createOverlayShortcutsRuntimeHandlers,
} from '../domains/shortcuts';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type GlobalShortcutsOptions = Parameters<typeof createGlobalShortcutsRuntimeHandlers>[0];
type NumericShortcutRuntimeMainDeps = Parameters<
typeof createBuildNumericShortcutRuntimeMainDepsHandler
>[0];
type NumericSessionOptions = Omit<
Parameters<typeof createNumericShortcutSessionRuntimeHandlers>[0],
'multiCopySession' | 'mineSentenceSession'
>;
type OverlayShortcutsMainDeps = Parameters<
typeof createOverlayShortcutsRuntimeHandlers
>[0]['overlayShortcutsRuntimeMainDeps'];
export type ShortcutsRuntimeComposerOptions = ComposerInputs<{
globalShortcuts: GlobalShortcutsOptions;
numericShortcutRuntimeMainDeps: NumericShortcutRuntimeMainDeps;
numericSessions: NumericSessionOptions;
overlayShortcutsRuntimeMainDeps: OverlayShortcutsMainDeps;
}>;
export type ShortcutsRuntimeComposerResult = ComposerOutputs<
ReturnType<typeof createGlobalShortcutsRuntimeHandlers> &
ReturnType<typeof createNumericShortcutSessionRuntimeHandlers> &
ReturnType<typeof createOverlayShortcutsRuntimeHandlers>
>;
export function composeShortcutRuntimes(
options: ShortcutsRuntimeComposerOptions,
): ShortcutsRuntimeComposerResult {
const globalShortcuts = createGlobalShortcutsRuntimeHandlers(options.globalShortcuts);
const numericShortcutRuntimeMainDeps = createBuildNumericShortcutRuntimeMainDepsHandler(
options.numericShortcutRuntimeMainDeps,
)();
const numericShortcutRuntime = createNumericShortcutRuntime(numericShortcutRuntimeMainDeps);
const numericSessions = createNumericShortcutSessionRuntimeHandlers({
multiCopySession: numericShortcutRuntime.createSession(),
mineSentenceSession: numericShortcutRuntime.createSession(),
onMultiCopyDigit: options.numericSessions.onMultiCopyDigit,
onMineSentenceDigit: options.numericSessions.onMineSentenceDigit,
});
const overlayShortcuts = createOverlayShortcutsRuntimeHandlers({
overlayShortcutsRuntimeMainDeps: options.overlayShortcutsRuntimeMainDeps,
});
return {
...globalShortcuts,
...numericSessions,
...overlayShortcuts,
};
}

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeStartupLifecycleHandlers } from './startup-lifecycle-composer';
test('composeStartupLifecycleHandlers returns callable startup lifecycle handlers', () => {
const composed = composeStartupLifecycleHandlers({
registerProtocolUrlHandlersMainDeps: {
registerOpenUrl: () => {},
registerSecondInstance: () => {},
handleAnilistSetupProtocolUrl: () => false,
findAnilistSetupDeepLinkArgvUrl: () => null,
logUnhandledOpenUrl: () => {},
logUnhandledSecondInstanceUrl: () => {},
},
onWillQuitCleanupMainDeps: {
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
getYomitanParserWindow: () => null,
clearYomitanParserState: () => {},
getWindowTracker: () => null,
flushMpvLog: () => {},
getMpvSocket: () => null,
getReconnectTimer: () => null,
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
stopJellyfinRemoteSession: async () => {},
stopDiscordPresenceService: () => {},
},
shouldRestoreWindowsOnActivateMainDeps: {
isOverlayRuntimeInitialized: () => false,
getAllWindowCount: () => 0,
},
restoreWindowsOnActivateMainDeps: {
createMainWindow: () => {},
createInvisibleWindow: () => {},
updateVisibleOverlayVisibility: () => {},
updateInvisibleOverlayVisibility: () => {},
},
});
assert.equal(typeof composed.registerProtocolUrlHandlers, 'function');
assert.equal(typeof composed.onWillQuitCleanup, 'function');
assert.equal(typeof composed.shouldRestoreWindowsOnActivate, 'function');
assert.equal(typeof composed.restoreWindowsOnActivate, 'function');
});

View File

@@ -0,0 +1,66 @@
import {
createOnWillQuitCleanupHandler,
createRestoreWindowsOnActivateHandler,
createShouldRestoreWindowsOnActivateHandler,
} from '../app-lifecycle-actions';
import { createBuildOnWillQuitCleanupDepsHandler } from '../app-lifecycle-main-cleanup';
import {
createBuildRestoreWindowsOnActivateMainDepsHandler,
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
} from '../app-lifecycle-main-activate';
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from '../protocol-url-handlers-main-deps';
import { registerProtocolUrlHandlers } from '../protocol-url-handlers';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type RegisterProtocolUrlHandlersMainDeps = Parameters<
typeof createBuildRegisterProtocolUrlHandlersMainDepsHandler
>[0];
type OnWillQuitCleanupDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
type ShouldRestoreWindowsOnActivateMainDeps = Parameters<
typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler
>[0];
type RestoreWindowsOnActivateMainDeps = Parameters<
typeof createBuildRestoreWindowsOnActivateMainDepsHandler
>[0];
export type StartupLifecycleComposerOptions = ComposerInputs<{
registerProtocolUrlHandlersMainDeps: RegisterProtocolUrlHandlersMainDeps;
onWillQuitCleanupMainDeps: OnWillQuitCleanupDeps;
shouldRestoreWindowsOnActivateMainDeps: ShouldRestoreWindowsOnActivateMainDeps;
restoreWindowsOnActivateMainDeps: RestoreWindowsOnActivateMainDeps;
}>;
export type StartupLifecycleComposerResult = ComposerOutputs<{
registerProtocolUrlHandlers: () => void;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
}>;
export function composeStartupLifecycleHandlers(
options: StartupLifecycleComposerOptions,
): StartupLifecycleComposerResult {
const registerProtocolUrlHandlersMainDeps = createBuildRegisterProtocolUrlHandlersMainDepsHandler(
options.registerProtocolUrlHandlersMainDeps,
)();
const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler(
createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(),
);
const shouldRestoreWindowsOnActivateHandler = createShouldRestoreWindowsOnActivateHandler(
createBuildShouldRestoreWindowsOnActivateMainDepsHandler(
options.shouldRestoreWindowsOnActivateMainDeps,
)(),
);
const restoreWindowsOnActivateHandler = createRestoreWindowsOnActivateHandler(
createBuildRestoreWindowsOnActivateMainDepsHandler(options.restoreWindowsOnActivateMainDeps)(),
);
return {
registerProtocolUrlHandlers: () =>
registerProtocolUrlHandlers(registerProtocolUrlHandlersMainDeps),
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
};
}