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,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
isAllowedAnilistExternalUrl,
isAllowedAnilistSetupNavigationUrl,
} from './anilist-url-guard';
test('allows only AniList https URLs for external opens', () => {
assert.equal(isAllowedAnilistExternalUrl('https://anilist.co'), true);
assert.equal(isAllowedAnilistExternalUrl('https://www.anilist.co/settings/developer'), true);
assert.equal(isAllowedAnilistExternalUrl('http://anilist.co'), false);
assert.equal(isAllowedAnilistExternalUrl('https://example.com'), false);
assert.equal(isAllowedAnilistExternalUrl('file:///tmp/test'), false);
assert.equal(isAllowedAnilistExternalUrl('not a url'), false);
});
test('allows only AniList https or data URLs for setup navigation', () => {
assert.equal(
isAllowedAnilistSetupNavigationUrl('https://anilist.co/api/v2/oauth/authorize'),
true,
);
assert.equal(
isAllowedAnilistSetupNavigationUrl('data:text/html;charset=utf-8,%3Chtml%3E%3C%2Fhtml%3E'),
true,
);
assert.equal(isAllowedAnilistSetupNavigationUrl('https://example.com/redirect'), false);
assert.equal(isAllowedAnilistSetupNavigationUrl('javascript:alert(1)'), false);
});

View File

@@ -0,0 +1,24 @@
const ANILIST_ALLOWED_HOSTS = new Set(['anilist.co', 'www.anilist.co']);
export function isAllowedAnilistExternalUrl(rawUrl: string): boolean {
try {
const parsedUrl = new URL(rawUrl);
return (
parsedUrl.protocol === 'https:' && ANILIST_ALLOWED_HOSTS.has(parsedUrl.hostname.toLowerCase())
);
} catch {
return false;
}
}
export function isAllowedAnilistSetupNavigationUrl(rawUrl: string): boolean {
if (isAllowedAnilistExternalUrl(rawUrl)) {
return true;
}
try {
const parsedUrl = new URL(rawUrl);
return parsedUrl.protocol === 'data:';
} catch {
return false;
}
}

115
src/main/app-lifecycle.ts Normal file
View File

@@ -0,0 +1,115 @@
import type { CliArgs, CliCommandSource } from '../cli/args';
import { runAppReadyRuntime } from '../core/services/startup';
import type { AppReadyRuntimeDeps } from '../core/services/startup';
import type { AppLifecycleDepsRuntimeOptions } from '../core/services/app-lifecycle';
export interface AppLifecycleRuntimeDepsFactoryInput {
app: AppLifecycleDepsRuntimeOptions['app'];
platform: NodeJS.Platform;
shouldStartApp: (args: CliArgs) => boolean;
parseArgs: (argv: string[]) => CliArgs;
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
}
export interface AppReadyRuntimeDepsFactoryInput {
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
reloadConfig: AppReadyRuntimeDeps['reloadConfig'];
getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig'];
getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings'];
logConfigWarning: AppReadyRuntimeDeps['logConfigWarning'];
initRuntimeOptionsManager: AppReadyRuntimeDeps['initRuntimeOptionsManager'];
setSecondarySubMode: AppReadyRuntimeDeps['setSecondarySubMode'];
defaultSecondarySubMode: AppReadyRuntimeDeps['defaultSecondarySubMode'];
defaultWebsocketPort: AppReadyRuntimeDeps['defaultWebsocketPort'];
hasMpvWebsocketPlugin: AppReadyRuntimeDeps['hasMpvWebsocketPlugin'];
startSubtitleWebsocket: AppReadyRuntimeDeps['startSubtitleWebsocket'];
log: AppReadyRuntimeDeps['log'];
setLogLevel: AppReadyRuntimeDeps['setLogLevel'];
createMecabTokenizerAndCheck: AppReadyRuntimeDeps['createMecabTokenizerAndCheck'];
createSubtitleTimingTracker: AppReadyRuntimeDeps['createSubtitleTimingTracker'];
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode'];
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
logDebug?: AppReadyRuntimeDeps['logDebug'];
now?: AppReadyRuntimeDeps['now'];
}
export function createAppLifecycleRuntimeDeps(
params: AppLifecycleRuntimeDepsFactoryInput,
): AppLifecycleDepsRuntimeOptions {
return {
app: params.app,
platform: params.platform,
shouldStartApp: params.shouldStartApp,
parseArgs: params.parseArgs,
handleCliCommand: params.handleCliCommand,
printHelp: params.printHelp,
logNoRunningInstance: params.logNoRunningInstance,
onReady: params.onReady,
onWillQuitCleanup: params.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
restoreWindowsOnActivate: params.restoreWindowsOnActivate,
shouldQuitOnWindowAllClosed: params.shouldQuitOnWindowAllClosed,
};
}
export function createAppReadyRuntimeDeps(
params: AppReadyRuntimeDepsFactoryInput,
): AppReadyRuntimeDeps {
return {
loadSubtitlePosition: params.loadSubtitlePosition,
resolveKeybindings: params.resolveKeybindings,
createMpvClient: params.createMpvClient,
reloadConfig: params.reloadConfig,
getResolvedConfig: params.getResolvedConfig,
getConfigWarnings: params.getConfigWarnings,
logConfigWarning: params.logConfigWarning,
initRuntimeOptionsManager: params.initRuntimeOptionsManager,
setSecondarySubMode: params.setSecondarySubMode,
defaultSecondarySubMode: params.defaultSecondarySubMode,
defaultWebsocketPort: params.defaultWebsocketPort,
hasMpvWebsocketPlugin: params.hasMpvWebsocketPlugin,
startSubtitleWebsocket: params.startSubtitleWebsocket,
log: params.log,
setLogLevel: params.setLogLevel,
createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck,
createSubtitleTimingTracker: params.createSubtitleTimingTracker,
createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension,
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
startBackgroundWarmups: params.startBackgroundWarmups,
texthookerOnlyMode: params.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig:
params.shouldAutoInitializeOverlayRuntimeFromConfig,
initializeOverlayRuntime: params.initializeOverlayRuntime,
handleInitialArgs: params.handleInitialArgs,
onCriticalConfigErrors: params.onCriticalConfigErrors,
logDebug: params.logDebug,
now: params.now,
};
}
export function createAppReadyRuntimeRunner(
params: AppReadyRuntimeDepsFactoryInput,
): () => Promise<void> {
return async () => {
await runAppReadyRuntime(createAppReadyRuntimeDeps(params));
};
}

136
src/main/cli-runtime.ts Normal file
View File

@@ -0,0 +1,136 @@
import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services';
import type { CliArgs, CliCommandSource } from '../cli/args';
import {
createCliCommandRuntimeServiceDeps,
CliCommandRuntimeServiceDepsParams,
} from './dependencies';
export interface CliCommandRuntimeServiceContext {
getSocketPath: () => string;
setSocketPath: (socketPath: string) => void;
getClient: CliCommandRuntimeServiceDepsParams['mpv']['getClient'];
showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
shouldOpenBrowser: () => boolean;
openInBrowser: (url: string) => void;
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus'];
clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken'];
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus'];
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
log: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
export interface CliCommandRuntimeServiceContextHandlers {
texthookerService: CliCommandRuntimeServiceDepsParams['texthooker']['service'];
}
function createCliCommandDepsFromContext(
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
): CliCommandRuntimeServiceDepsParams {
return {
mpv: {
getSocketPath: context.getSocketPath,
setSocketPath: context.setSocketPath,
getClient: context.getClient,
showOsd: context.showOsd,
},
texthooker: {
service: context.texthookerService,
getPort: context.getTexthookerPort,
setPort: context.setTexthookerPort,
shouldOpenBrowser: context.shouldOpenBrowser,
openInBrowser: context.openInBrowser,
},
overlay: {
isInitialized: context.isOverlayInitialized,
initialize: context.initializeOverlay,
toggleVisible: context.toggleVisibleOverlay,
toggleInvisible: context.toggleInvisibleOverlay,
setVisible: context.setVisibleOverlay,
setInvisible: context.setInvisibleOverlay,
},
mining: {
copyCurrentSubtitle: context.copyCurrentSubtitle,
startPendingMultiCopy: context.startPendingMultiCopy,
mineSentenceCard: context.mineSentenceCard,
startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: context.updateLastCardFromClipboard,
refreshKnownWords: context.refreshKnownWordCache,
triggerFieldGrouping: context.triggerFieldGrouping,
triggerSubsyncFromConfig: context.triggerSubsyncFromConfig,
markLastCardAsAudioCard: context.markLastCardAsAudioCard,
},
anilist: {
getStatus: context.getAnilistStatus,
clearToken: context.clearAnilistToken,
openSetup: context.openAnilistSetup,
getQueueStatus: context.getAnilistQueueStatus,
retryQueueNow: context.retryAnilistQueueNow,
},
jellyfin: {
openSetup: context.openJellyfinSetup,
runCommand: context.runJellyfinCommand,
},
ui: {
openYomitanSettings: context.openYomitanSettings,
cycleSecondarySubMode: context.cycleSecondarySubMode,
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
printHelp: context.printHelp,
},
app: {
stop: context.stopApp,
hasMainWindow: context.hasMainWindow,
},
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
schedule: context.schedule,
log: context.log,
warn: context.warn,
error: context.error,
};
}
export function handleCliCommandRuntimeService(
args: CliArgs,
source: CliCommandSource,
params: CliCommandRuntimeServiceDepsParams,
): void {
const deps = createCliCommandDepsRuntime(createCliCommandRuntimeServiceDeps(params));
handleCliCommand(args, source, deps);
}
export function handleCliCommandRuntimeServiceWithContext(
args: CliArgs,
source: CliCommandSource,
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
): void {
handleCliCommandRuntimeService(args, source, createCliCommandDepsFromContext(context));
}

View File

@@ -0,0 +1,90 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildConfigParseErrorDetails,
buildConfigWarningNotificationBody,
buildConfigWarningSummary,
failStartupFromConfig,
formatConfigValue,
} from './config-validation';
test('formatConfigValue handles undefined and JSON values', () => {
assert.equal(formatConfigValue(undefined), 'undefined');
assert.equal(formatConfigValue({ x: 1 }), '{"x":1}');
assert.equal(formatConfigValue(['a', 2]), '["a",2]');
});
test('buildConfigWarningSummary includes warnings with formatted values', () => {
const summary = buildConfigWarningSummary('/tmp/config.jsonc', [
{
path: 'ankiConnect.pollingRate',
message: 'must be >= 50',
value: 20,
fallback: 250,
},
]);
assert.match(summary, /Validation found 1 issue\(s\)\. File: \/tmp\/config\.jsonc/);
assert.match(summary, /ankiConnect\.pollingRate: must be >= 50 actual=20 fallback=250/);
});
test('buildConfigWarningNotificationBody includes concise warning details', () => {
const body = buildConfigWarningNotificationBody('/tmp/config.jsonc', [
{
path: 'ankiConnect.openRouter',
message: 'Deprecated key; use ankiConnect.ai instead.',
value: { enabled: true },
fallback: {},
},
{
path: 'ankiConnect.isLapis.sentenceCardSentenceField',
message: 'Deprecated key; sentence-card sentence field is fixed to Sentence.',
value: 'Sentence',
fallback: 'Sentence',
},
]);
assert.match(body, /2 config validation issue\(s\) detected\./);
assert.match(body, /File: \/tmp\/config\.jsonc/);
assert.match(body, /1\. ankiConnect\.openRouter: Deprecated key; use ankiConnect\.ai instead\./);
assert.match(
body,
/2\. ankiConnect\.isLapis\.sentenceCardSentenceField: Deprecated key; sentence-card sentence field is fixed to Sentence\./,
);
});
test('buildConfigParseErrorDetails includes path error and restart guidance', () => {
const details = buildConfigParseErrorDetails('/tmp/config.jsonc', 'unexpected token at line 1');
assert.match(details, /Failed to parse config file at:/);
assert.match(details, /\/tmp\/config\.jsonc/);
assert.match(details, /Error: unexpected token at line 1/);
assert.match(details, /Fix the config file and restart SubMiner\./);
});
test('failStartupFromConfig invokes handlers and throws', () => {
const calls: string[] = [];
const previousExitCode = process.exitCode;
process.exitCode = 0;
assert.throws(
() =>
failStartupFromConfig('Config Error', 'bad value', {
logError: (details) => {
calls.push(`log:${details}`);
},
showErrorBox: (title, details) => {
calls.push(`dialog:${title}:${details}`);
},
quit: () => {
calls.push('quit');
},
}),
/bad value/,
);
assert.equal(process.exitCode, 1);
assert.deepEqual(calls, ['log:bad value', 'dialog:Config Error:bad value', 'quit']);
process.exitCode = previousExitCode;
});

View File

@@ -0,0 +1,85 @@
import type { ConfigValidationWarning } from '../types';
export type StartupFailureHandlers = {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
quit: () => void;
};
export function formatConfigValue(value: unknown): string {
if (value === undefined) {
return 'undefined';
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export function buildConfigWarningSummary(
configPath: string,
warnings: ConfigValidationWarning[],
): string {
const lines = [
`[config] Validation found ${warnings.length} issue(s). File: ${configPath}`,
...warnings.map(
(warning, index) =>
`[config] ${index + 1}. ${warning.path}: ${warning.message} actual=${formatConfigValue(warning.value)} fallback=${formatConfigValue(warning.fallback)}`,
),
];
return lines.join('\n');
}
export function buildConfigWarningNotificationBody(
configPath: string,
warnings: ConfigValidationWarning[],
): string {
const maxLines = 3;
const maxPathLength = 48;
const trimPath = (value: string): string =>
value.length > maxPathLength ? `...${value.slice(-(maxPathLength - 3))}` : value;
const clippedPath = trimPath(configPath);
const lines = warnings.slice(0, maxLines).map((warning, index) => {
const message = `${warning.path}: ${warning.message}`;
return `${index + 1}. ${message}`;
});
const overflow = warnings.length - lines.length;
if (overflow > 0) {
lines.push(`+${overflow} more issue(s)`);
}
return [
`${warnings.length} config validation issue(s) detected.`,
'Defaults were applied where possible.',
`File: ${clippedPath}`,
...lines,
].join('\n');
}
export function buildConfigParseErrorDetails(configPath: string, parseError: string): string {
return [
'Failed to parse config file at:',
configPath,
'',
`Error: ${parseError}`,
'',
'Fix the config file and restart SubMiner.',
].join('\n');
}
export function failStartupFromConfig(
title: string,
details: string,
handlers: StartupFailureHandlers,
): never {
handlers.logError(details);
handlers.showErrorBox(title, details);
process.exitCode = 1;
handlers.quit();
throw new Error(details);
}

341
src/main/dependencies.ts Normal file
View File

@@ -0,0 +1,341 @@
import { RuntimeOptionId, RuntimeOptionValue, SubsyncManualPayload } from '../types';
import { SubsyncResolvedConfig } from '../subsync/utils';
import type { SubsyncRuntimeDeps } from '../core/services/subsync-runner';
import type { IpcDepsRuntimeOptions } from '../core/services/ipc';
import type { AnkiJimakuIpcRuntimeOptions } from '../core/services/anki-jimaku';
import type { CliCommandDepsRuntimeOptions } from '../core/services/cli-command';
import type { HandleMpvCommandFromIpcOptions } from '../core/services/ipc-command';
import {
cycleRuntimeOptionFromIpcRuntime,
setRuntimeOptionFromIpcRuntime,
} from '../core/services/runtime-options-ipc';
import { RuntimeOptionsManager } from '../runtime-options';
export interface RuntimeOptionsIpcDepsParams {
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
showMpvOsd: (text: string) => void;
}
export interface SubsyncRuntimeDepsParams {
getMpvClient: () => ReturnType<SubsyncRuntimeDeps['getMpvClient']>;
getResolvedSubsyncConfig: () => SubsyncResolvedConfig;
isSubsyncInProgress: () => boolean;
setSubsyncInProgress: (inProgress: boolean) => void;
showMpvOsd: (text: string) => void;
openManualPicker: (payload: SubsyncManualPayload) => void;
}
export function createRuntimeOptionsIpcDeps(params: RuntimeOptionsIpcDepsParams): {
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
} {
return {
setRuntimeOption: (id, value) =>
setRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, value, (text) =>
params.showMpvOsd(text),
),
cycleRuntimeOption: (id, direction) =>
cycleRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, direction, (text) =>
params.showMpvOsd(text),
),
};
}
export function createSubsyncRuntimeDeps(params: SubsyncRuntimeDepsParams): SubsyncRuntimeDeps {
return {
getMpvClient: params.getMpvClient,
getResolvedSubsyncConfig: params.getResolvedSubsyncConfig,
isSubsyncInProgress: params.isSubsyncInProgress,
setSubsyncInProgress: params.setSubsyncInProgress,
showMpvOsd: params.showMpvOsd,
openManualPicker: params.openManualPicker,
};
}
export interface MainIpcRuntimeServiceDepsParams {
getInvisibleWindow: IpcDepsRuntimeOptions['getInvisibleWindow'];
getMainWindow: IpcDepsRuntimeOptions['getMainWindow'];
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
getInvisibleOverlayVisibility: IpcDepsRuntimeOptions['getInvisibleOverlayVisibility'];
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp'];
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions['getMpvSubtitleRenderMetrics'];
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle'];
saveSubtitlePosition: IpcDepsRuntimeOptions['saveSubtitlePosition'];
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
getAnkiConnectStatus: IpcDepsRuntimeOptions['getAnkiConnectStatus'];
getRuntimeOptions: IpcDepsRuntimeOptions['getRuntimeOptions'];
setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption'];
cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption'];
reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds'];
reportHoveredSubtitleToken: IpcDepsRuntimeOptions['reportHoveredSubtitleToken'];
getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus'];
clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken'];
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
}
export interface AnkiJimakuIpcRuntimeServiceDepsParams {
patchAnkiConnectEnabled: AnkiJimakuIpcRuntimeOptions['patchAnkiConnectEnabled'];
getResolvedConfig: AnkiJimakuIpcRuntimeOptions['getResolvedConfig'];
getRuntimeOptionsManager: AnkiJimakuIpcRuntimeOptions['getRuntimeOptionsManager'];
getSubtitleTimingTracker: AnkiJimakuIpcRuntimeOptions['getSubtitleTimingTracker'];
getMpvClient: AnkiJimakuIpcRuntimeOptions['getMpvClient'];
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration'];
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged'];
getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver'];
setFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['setFieldGroupingResolver'];
parseMediaInfo: AnkiJimakuIpcRuntimeOptions['parseMediaInfo'];
getCurrentMediaPath: AnkiJimakuIpcRuntimeOptions['getCurrentMediaPath'];
jimakuFetchJson: AnkiJimakuIpcRuntimeOptions['jimakuFetchJson'];
getJimakuMaxEntryResults: AnkiJimakuIpcRuntimeOptions['getJimakuMaxEntryResults'];
getJimakuLanguagePreference: AnkiJimakuIpcRuntimeOptions['getJimakuLanguagePreference'];
resolveJimakuApiKey: AnkiJimakuIpcRuntimeOptions['resolveJimakuApiKey'];
isRemoteMediaPath: AnkiJimakuIpcRuntimeOptions['isRemoteMediaPath'];
downloadToFile: AnkiJimakuIpcRuntimeOptions['downloadToFile'];
}
export interface CliCommandRuntimeServiceDepsParams {
mpv: {
getSocketPath: CliCommandDepsRuntimeOptions['mpv']['getSocketPath'];
setSocketPath: CliCommandDepsRuntimeOptions['mpv']['setSocketPath'];
getClient: CliCommandDepsRuntimeOptions['mpv']['getClient'];
showOsd: CliCommandDepsRuntimeOptions['mpv']['showOsd'];
};
texthooker: {
service: CliCommandDepsRuntimeOptions['texthooker']['service'];
getPort: CliCommandDepsRuntimeOptions['texthooker']['getPort'];
setPort: CliCommandDepsRuntimeOptions['texthooker']['setPort'];
shouldOpenBrowser: CliCommandDepsRuntimeOptions['texthooker']['shouldOpenBrowser'];
openInBrowser: CliCommandDepsRuntimeOptions['texthooker']['openInBrowser'];
};
overlay: {
isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized'];
initialize: CliCommandDepsRuntimeOptions['overlay']['initialize'];
toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible'];
toggleInvisible: CliCommandDepsRuntimeOptions['overlay']['toggleInvisible'];
setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible'];
setInvisible: CliCommandDepsRuntimeOptions['overlay']['setInvisible'];
};
mining: {
copyCurrentSubtitle: CliCommandDepsRuntimeOptions['mining']['copyCurrentSubtitle'];
startPendingMultiCopy: CliCommandDepsRuntimeOptions['mining']['startPendingMultiCopy'];
mineSentenceCard: CliCommandDepsRuntimeOptions['mining']['mineSentenceCard'];
startPendingMineSentenceMultiple: CliCommandDepsRuntimeOptions['mining']['startPendingMineSentenceMultiple'];
updateLastCardFromClipboard: CliCommandDepsRuntimeOptions['mining']['updateLastCardFromClipboard'];
refreshKnownWords: CliCommandDepsRuntimeOptions['mining']['refreshKnownWords'];
triggerFieldGrouping: CliCommandDepsRuntimeOptions['mining']['triggerFieldGrouping'];
triggerSubsyncFromConfig: CliCommandDepsRuntimeOptions['mining']['triggerSubsyncFromConfig'];
markLastCardAsAudioCard: CliCommandDepsRuntimeOptions['mining']['markLastCardAsAudioCard'];
};
anilist: {
getStatus: CliCommandDepsRuntimeOptions['anilist']['getStatus'];
clearToken: CliCommandDepsRuntimeOptions['anilist']['clearToken'];
openSetup: CliCommandDepsRuntimeOptions['anilist']['openSetup'];
getQueueStatus: CliCommandDepsRuntimeOptions['anilist']['getQueueStatus'];
retryQueueNow: CliCommandDepsRuntimeOptions['anilist']['retryQueueNow'];
};
jellyfin: {
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
};
ui: {
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
};
app: {
stop: CliCommandDepsRuntimeOptions['app']['stop'];
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
};
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
schedule: CliCommandDepsRuntimeOptions['schedule'];
log: CliCommandDepsRuntimeOptions['log'];
warn: CliCommandDepsRuntimeOptions['warn'];
error: CliCommandDepsRuntimeOptions['error'];
}
export interface MpvCommandRuntimeServiceDepsParams {
specialCommands: HandleMpvCommandFromIpcOptions['specialCommands'];
runtimeOptionsCycle: HandleMpvCommandFromIpcOptions['runtimeOptionsCycle'];
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions['hasRuntimeOptionsManager'];
}
export function createMainIpcRuntimeServiceDeps(
params: MainIpcRuntimeServiceDepsParams,
): IpcDepsRuntimeOptions {
return {
getInvisibleWindow: params.getInvisibleWindow,
getMainWindow: params.getMainWindow,
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
getInvisibleOverlayVisibility: params.getInvisibleOverlayVisibility,
onOverlayModalClosed: params.onOverlayModalClosed,
openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp,
toggleVisibleOverlay: params.toggleVisibleOverlay,
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics,
getSubtitlePosition: params.getSubtitlePosition,
getSubtitleStyle: params.getSubtitleStyle,
saveSubtitlePosition: params.saveSubtitlePosition,
getMecabTokenizer: params.getMecabTokenizer,
handleMpvCommand: params.handleMpvCommand,
getKeybindings: params.getKeybindings,
getConfiguredShortcuts: params.getConfiguredShortcuts,
focusMainWindow: params.focusMainWindow ?? (() => {}),
getSecondarySubMode: params.getSecondarySubMode,
getMpvClient: params.getMpvClient,
runSubsyncManual: params.runSubsyncManual,
getAnkiConnectStatus: params.getAnkiConnectStatus,
getRuntimeOptions: params.getRuntimeOptions,
setRuntimeOption: params.setRuntimeOption,
cycleRuntimeOption: params.cycleRuntimeOption,
reportOverlayContentBounds: params.reportOverlayContentBounds,
reportHoveredSubtitleToken: params.reportHoveredSubtitleToken,
getAnilistStatus: params.getAnilistStatus,
clearAnilistToken: params.clearAnilistToken,
openAnilistSetup: params.openAnilistSetup,
getAnilistQueueStatus: params.getAnilistQueueStatus,
retryAnilistQueueNow: params.retryAnilistQueueNow,
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
};
}
export function createAnkiJimakuIpcRuntimeServiceDeps(
params: AnkiJimakuIpcRuntimeServiceDepsParams,
): AnkiJimakuIpcRuntimeOptions {
return {
patchAnkiConnectEnabled: params.patchAnkiConnectEnabled,
getResolvedConfig: params.getResolvedConfig,
getRuntimeOptionsManager: params.getRuntimeOptionsManager,
getSubtitleTimingTracker: params.getSubtitleTimingTracker,
getMpvClient: params.getMpvClient,
getAnkiIntegration: params.getAnkiIntegration,
setAnkiIntegration: params.setAnkiIntegration,
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
showDesktopNotification: params.showDesktopNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback,
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
getFieldGroupingResolver: params.getFieldGroupingResolver,
setFieldGroupingResolver: params.setFieldGroupingResolver,
parseMediaInfo: params.parseMediaInfo,
getCurrentMediaPath: params.getCurrentMediaPath,
jimakuFetchJson: params.jimakuFetchJson,
getJimakuMaxEntryResults: params.getJimakuMaxEntryResults,
getJimakuLanguagePreference: params.getJimakuLanguagePreference,
resolveJimakuApiKey: params.resolveJimakuApiKey,
isRemoteMediaPath: params.isRemoteMediaPath,
downloadToFile: params.downloadToFile,
};
}
export function createCliCommandRuntimeServiceDeps(
params: CliCommandRuntimeServiceDepsParams,
): CliCommandDepsRuntimeOptions {
return {
mpv: {
getSocketPath: params.mpv.getSocketPath,
setSocketPath: params.mpv.setSocketPath,
getClient: params.mpv.getClient,
showOsd: params.mpv.showOsd,
},
texthooker: {
service: params.texthooker.service,
getPort: params.texthooker.getPort,
setPort: params.texthooker.setPort,
shouldOpenBrowser: params.texthooker.shouldOpenBrowser,
openInBrowser: params.texthooker.openInBrowser,
},
overlay: {
isInitialized: params.overlay.isInitialized,
initialize: params.overlay.initialize,
toggleVisible: params.overlay.toggleVisible,
toggleInvisible: params.overlay.toggleInvisible,
setVisible: params.overlay.setVisible,
setInvisible: params.overlay.setInvisible,
},
mining: {
copyCurrentSubtitle: params.mining.copyCurrentSubtitle,
startPendingMultiCopy: params.mining.startPendingMultiCopy,
mineSentenceCard: params.mining.mineSentenceCard,
startPendingMineSentenceMultiple: params.mining.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: params.mining.updateLastCardFromClipboard,
refreshKnownWords: params.mining.refreshKnownWords,
triggerFieldGrouping: params.mining.triggerFieldGrouping,
triggerSubsyncFromConfig: params.mining.triggerSubsyncFromConfig,
markLastCardAsAudioCard: params.mining.markLastCardAsAudioCard,
},
anilist: {
getStatus: params.anilist.getStatus,
clearToken: params.anilist.clearToken,
openSetup: params.anilist.openSetup,
getQueueStatus: params.anilist.getQueueStatus,
retryQueueNow: params.anilist.retryQueueNow,
},
jellyfin: {
openSetup: params.jellyfin.openSetup,
runCommand: params.jellyfin.runCommand,
},
ui: {
openYomitanSettings: params.ui.openYomitanSettings,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
printHelp: params.ui.printHelp,
},
app: {
stop: params.app.stop,
hasMainWindow: params.app.hasMainWindow,
},
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
schedule: params.schedule,
log: params.log,
warn: params.warn,
error: params.error,
};
}
export function createMpvCommandRuntimeServiceDeps(
params: MpvCommandRuntimeServiceDepsParams,
): HandleMpvCommandFromIpcOptions {
return {
specialCommands: params.specialCommands,
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd,
mpvReplaySubtitle: params.mpvReplaySubtitle,
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
mpvSendCommand: params.mpvSendCommand,
isMpvConnected: params.isMpvConnected,
hasRuntimeOptionsManager: params.hasRuntimeOptionsManager,
};
}

View File

@@ -0,0 +1,86 @@
import * as path from 'path';
import type { FrequencyDictionaryLookup } from '../types';
import { createFrequencyDictionaryLookup } from '../core/services';
export interface FrequencyDictionarySearchPathDeps {
getDictionaryRoots: () => string[];
getSourcePath?: () => string | undefined;
}
export interface FrequencyDictionaryRuntimeDeps {
isFrequencyDictionaryEnabled: () => boolean;
getSearchPaths: () => string[];
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
log: (message: string) => void;
}
let frequencyDictionaryLookupInitialized = false;
let frequencyDictionaryLookupInitialization: Promise<void> | null = null;
// Frequency dictionary services are initialized lazily as a process-wide singleton.
// Initialization is idempotent and intentionally shared across callers.
export function getFrequencyDictionarySearchPaths(
deps: FrequencyDictionarySearchPathDeps,
): string[] {
const dictionaryRoots = deps.getDictionaryRoots();
const sourcePath = deps.getSourcePath?.();
const rawSearchPaths: string[] = [];
// User-provided path takes precedence over bundled/default roots.
// Root list should include `vendor/jiten_freq_global` in callers.
if (sourcePath && sourcePath.trim()) {
rawSearchPaths.push(sourcePath.trim());
rawSearchPaths.push(path.join(sourcePath.trim(), 'frequency-dictionary'));
rawSearchPaths.push(path.join(sourcePath.trim(), 'vendor', 'frequency-dictionary'));
}
for (const dictionaryRoot of dictionaryRoots) {
rawSearchPaths.push(dictionaryRoot);
rawSearchPaths.push(path.join(dictionaryRoot, 'frequency-dictionary'));
rawSearchPaths.push(path.join(dictionaryRoot, 'vendor', 'frequency-dictionary'));
}
return [...new Set(rawSearchPaths)];
}
export async function initializeFrequencyDictionaryLookup(
deps: FrequencyDictionaryRuntimeDeps,
): Promise<void> {
const lookup = await createFrequencyDictionaryLookup({
searchPaths: deps.getSearchPaths(),
log: deps.log,
});
deps.setFrequencyRankLookup(lookup);
}
export async function ensureFrequencyDictionaryLookup(
deps: FrequencyDictionaryRuntimeDeps,
): Promise<void> {
if (!deps.isFrequencyDictionaryEnabled()) {
return;
}
if (frequencyDictionaryLookupInitialized) {
return;
}
if (!frequencyDictionaryLookupInitialization) {
frequencyDictionaryLookupInitialization = initializeFrequencyDictionaryLookup(deps)
.then(() => {
frequencyDictionaryLookupInitialized = true;
})
.catch((error) => {
frequencyDictionaryLookupInitialized = true;
deps.log(`Failed to initialize frequency dictionary: ${String(error)}`);
deps.setFrequencyRankLookup(() => null);
});
}
await frequencyDictionaryLookupInitialization;
}
export function createFrequencyDictionaryRuntimeService(deps: FrequencyDictionaryRuntimeDeps): {
ensureFrequencyDictionaryLookup: () => Promise<void>;
} {
return {
ensureFrequencyDictionaryLookup: () => ensureFrequencyDictionaryLookup(deps),
};
}

View File

@@ -0,0 +1,37 @@
import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../types';
import { handleMpvCommandFromIpc } from '../core/services';
import { createMpvCommandRuntimeServiceDeps } from './dependencies';
import { SPECIAL_COMMANDS } from '../config';
export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
replayCurrentSubtitle: () => void;
playNextSubtitle: () => void;
sendMpvCommand: (command: (string | number)[]) => void;
isMpvConnected: () => boolean;
hasRuntimeOptionsManager: () => boolean;
}
export function handleMpvCommandFromIpcRuntime(
command: (string | number)[],
deps: MpvCommandFromIpcRuntimeDeps,
): void {
handleMpvCommandFromIpc(
command,
createMpvCommandRuntimeServiceDeps({
specialCommands: SPECIAL_COMMANDS,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd,
mpvReplaySubtitle: deps.replayCurrentSubtitle,
mpvPlayNextSubtitle: deps.playNextSubtitle,
mpvSendCommand: deps.sendMpvCommand,
isMpvConnected: deps.isMpvConnected,
hasRuntimeOptionsManager: deps.hasRuntimeOptionsManager,
}),
);
}

46
src/main/ipc-runtime.ts Normal file
View File

@@ -0,0 +1,46 @@
import {
createIpcDepsRuntime,
registerAnkiJimakuIpcRuntime,
registerIpcHandlers,
} from '../core/services';
import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc';
import {
createAnkiJimakuIpcRuntimeServiceDeps,
AnkiJimakuIpcRuntimeServiceDepsParams,
createMainIpcRuntimeServiceDeps,
MainIpcRuntimeServiceDepsParams,
createRuntimeOptionsIpcDeps,
RuntimeOptionsIpcDepsParams,
} from './dependencies';
export interface RegisterIpcRuntimeServicesParams {
runtimeOptions: RuntimeOptionsIpcDepsParams;
mainDeps: Omit<MainIpcRuntimeServiceDepsParams, 'setRuntimeOption' | 'cycleRuntimeOption'>;
ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams;
}
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
}
export function registerAnkiJimakuIpcRuntimeServices(
params: AnkiJimakuIpcRuntimeServiceDepsParams,
): void {
registerAnkiJimakuIpcRuntime(
createAnkiJimakuIpcRuntimeServiceDeps(params),
registerAnkiJimakuIpcHandlers,
);
}
export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void {
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
showMpvOsd: params.runtimeOptions.showMpvOsd,
});
registerMainIpcRuntimeServices({
...params.mainDeps,
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
});
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
}

73
src/main/jlpt-runtime.ts Normal file
View File

@@ -0,0 +1,73 @@
import * as path from 'path';
import type { JlptLevel } from '../types';
import { createJlptVocabularyLookup } from '../core/services';
export interface JlptDictionarySearchPathDeps {
getDictionaryRoots: () => string[];
}
export type JlptLookup = (term: string) => JlptLevel | null;
export interface JlptDictionaryRuntimeDeps {
isJlptEnabled: () => boolean;
getSearchPaths: () => string[];
setJlptLevelLookup: (lookup: JlptLookup) => void;
log: (message: string) => void;
}
let jlptDictionaryLookupInitialized = false;
let jlptDictionaryLookupInitialization: Promise<void> | null = null;
export function getJlptDictionarySearchPaths(deps: JlptDictionarySearchPathDeps): string[] {
const dictionaryRoots = deps.getDictionaryRoots();
const searchPaths: string[] = [];
for (const dictionaryRoot of dictionaryRoots) {
searchPaths.push(dictionaryRoot);
searchPaths.push(path.join(dictionaryRoot, 'vendor', 'yomitan-jlpt-vocab'));
searchPaths.push(path.join(dictionaryRoot, 'yomitan-jlpt-vocab'));
}
const uniquePaths = new Set<string>(searchPaths);
return [...uniquePaths];
}
export async function initializeJlptDictionaryLookup(
deps: JlptDictionaryRuntimeDeps,
): Promise<void> {
deps.setJlptLevelLookup(
await createJlptVocabularyLookup({
searchPaths: deps.getSearchPaths(),
log: deps.log,
}),
);
}
export async function ensureJlptDictionaryLookup(deps: JlptDictionaryRuntimeDeps): Promise<void> {
if (!deps.isJlptEnabled()) {
return;
}
if (jlptDictionaryLookupInitialized) {
return;
}
if (!jlptDictionaryLookupInitialization) {
jlptDictionaryLookupInitialization = initializeJlptDictionaryLookup(deps)
.then(() => {
jlptDictionaryLookupInitialized = true;
})
.catch((error) => {
jlptDictionaryLookupInitialization = null;
throw error;
});
}
await jlptDictionaryLookupInitialization;
}
export function createJlptDictionaryRuntimeService(deps: JlptDictionaryRuntimeDeps): {
ensureJlptDictionaryLookup: () => Promise<void>;
} {
return {
ensureJlptDictionaryLookup: () => ensureJlptDictionaryLookup(deps),
};
}

68
src/main/media-runtime.ts Normal file
View File

@@ -0,0 +1,68 @@
import { updateCurrentMediaPath } from '../core/services';
import type { SubtitlePosition } from '../types';
export interface MediaRuntimeDeps {
isRemoteMediaPath: (mediaPath: string) => boolean;
loadSubtitlePosition: () => SubtitlePosition | null;
getCurrentMediaPath: () => string | null;
getPendingSubtitlePosition: () => SubtitlePosition | null;
getSubtitlePositionsDir: () => string;
setCurrentMediaPath: (mediaPath: string | null) => void;
clearPendingSubtitlePosition: () => void;
setSubtitlePosition: (position: SubtitlePosition | null) => void;
broadcastSubtitlePosition: (position: SubtitlePosition | null) => void;
getCurrentMediaTitle: () => string | null;
setCurrentMediaTitle: (title: string | null) => void;
}
export interface MediaRuntimeService {
updateCurrentMediaPath: (mediaPath: unknown) => void;
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
}
export function createMediaRuntimeService(deps: MediaRuntimeDeps): MediaRuntimeService {
return {
updateCurrentMediaPath(mediaPath: unknown): void {
if (typeof mediaPath !== 'string' || !deps.isRemoteMediaPath(mediaPath)) {
deps.setCurrentMediaTitle(null);
}
updateCurrentMediaPath({
mediaPath,
currentMediaPath: deps.getCurrentMediaPath(),
pendingSubtitlePosition: deps.getPendingSubtitlePosition(),
subtitlePositionsDir: deps.getSubtitlePositionsDir(),
loadSubtitlePosition: () => deps.loadSubtitlePosition(),
setCurrentMediaPath: (nextPath: string | null) => {
deps.setCurrentMediaPath(nextPath);
},
clearPendingSubtitlePosition: () => {
deps.clearPendingSubtitlePosition();
},
setSubtitlePosition: (position: SubtitlePosition | null) => {
deps.setSubtitlePosition(position);
},
broadcastSubtitlePosition: (position: SubtitlePosition | null) => {
deps.broadcastSubtitlePosition(position);
},
});
},
updateCurrentMediaTitle(mediaTitle: unknown): void {
if (typeof mediaTitle === 'string') {
const sanitized = mediaTitle.trim();
deps.setCurrentMediaTitle(sanitized.length > 0 ? sanitized : null);
return;
}
deps.setCurrentMediaTitle(null);
},
resolveMediaPathForJimaku(mediaPath: string | null): string | null {
return mediaPath && deps.isRemoteMediaPath(mediaPath) && deps.getCurrentMediaTitle()
? deps.getCurrentMediaTitle()
: mediaPath;
},
};
}

134
src/main/overlay-runtime.ts Normal file
View File

@@ -0,0 +1,134 @@
import type { BrowserWindow } from 'electron';
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku';
type OverlayHostLayer = 'visible' | 'invisible';
export interface OverlayWindowResolver {
getMainWindow: () => BrowserWindow | null;
getInvisibleWindow: () => BrowserWindow | null;
}
export interface OverlayModalRuntime {
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
) => boolean;
openRuntimeOptionsPalette: () => void;
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
}
export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): OverlayModalRuntime {
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
const overlayModalAutoShownLayer = new Map<OverlayHostedModal, OverlayHostLayer>();
const getTargetOverlayWindow = (): {
window: BrowserWindow;
layer: OverlayHostLayer;
} | null => {
const visibleMainWindow = deps.getMainWindow();
const invisibleWindow = deps.getInvisibleWindow();
if (visibleMainWindow && !visibleMainWindow.isDestroyed()) {
return { window: visibleMainWindow, layer: 'visible' };
}
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
return { window: invisibleWindow, layer: 'invisible' };
}
return null;
};
const showOverlayWindowForModal = (window: BrowserWindow, layer: OverlayHostLayer): void => {
if (layer === 'invisible' && typeof window.showInactive === 'function') {
window.showInactive();
} else {
window.show();
}
if (!window.isFocused()) {
window.focus();
}
};
const sendToActiveOverlayWindow = (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
): boolean => {
const target = getTargetOverlayWindow();
if (!target) return false;
const { window: targetWindow, layer } = target;
const wasVisible = targetWindow.isVisible();
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
const sendNow = (): void => {
if (payload === undefined) {
targetWindow.webContents.send(channel);
} else {
targetWindow.webContents.send(channel, payload);
}
};
if (!wasVisible) {
showOverlayWindowForModal(targetWindow, layer);
}
if (!wasVisible && restoreOnModalClose) {
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
overlayModalAutoShownLayer.set(restoreOnModalClose, layer);
}
if (targetWindow.webContents.isLoading()) {
targetWindow.webContents.once('did-finish-load', () => {
if (targetWindow && !targetWindow.isDestroyed() && !targetWindow.webContents.isLoading()) {
sendNow();
}
});
return true;
}
sendNow();
return true;
};
const openRuntimeOptionsPalette = (): void => {
sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
};
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
restoreVisibleOverlayOnModalClose.delete(modal);
const layer = overlayModalAutoShownLayer.get(modal);
overlayModalAutoShownLayer.delete(modal);
if (!layer) return;
const shouldKeepLayerVisible = [...restoreVisibleOverlayOnModalClose].some(
(pendingModal) => overlayModalAutoShownLayer.get(pendingModal) === layer,
);
if (shouldKeepLayerVisible) return;
if (layer === 'visible') {
const mainWindow = deps.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.hide();
}
return;
}
const invisibleWindow = deps.getInvisibleWindow();
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
invisibleWindow.hide();
}
};
return {
sendToActiveOverlayWindow,
openRuntimeOptionsPalette,
handleOverlayModalClosed,
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
};
}
export type { OverlayHostedModal };

View File

@@ -0,0 +1,134 @@
import type { ConfiguredShortcuts } from '../core/utils/shortcut-config';
import {
createOverlayShortcutRuntimeHandlers,
shortcutMatchesInputForLocalFallback,
} from '../core/services';
import {
refreshOverlayShortcutsRuntime,
registerOverlayShortcuts,
syncOverlayShortcutsRuntime,
unregisterOverlayShortcutsRuntime,
} from '../core/services';
import { runOverlayShortcutLocalFallback } from '../core/services/overlay-shortcut-handler';
export interface OverlayShortcutRuntimeServiceInput {
getConfiguredShortcuts: () => ConfiguredShortcuts;
getShortcutsRegistered: () => boolean;
setShortcutsRegistered: (registered: boolean) => void;
isOverlayRuntimeInitialized: () => boolean;
showMpvOsd: (text: string) => void;
openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void;
toggleSecondarySubMode: () => void;
updateLastCardFromClipboard: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
mineSentenceCard: () => Promise<void>;
mineSentenceMultiple: (timeoutMs: number) => void;
cancelPendingMultiCopy: () => void;
cancelPendingMineSentenceMultiple: () => void;
}
export interface OverlayShortcutsRuntimeService {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
registerOverlayShortcuts: () => void;
unregisterOverlayShortcuts: () => void;
syncOverlayShortcuts: () => void;
refreshOverlayShortcuts: () => void;
}
export function createOverlayShortcutsRuntimeService(
input: OverlayShortcutRuntimeServiceInput,
): OverlayShortcutsRuntimeService {
const handlers = createOverlayShortcutRuntimeHandlers({
showMpvOsd: (text: string) => input.showMpvOsd(text),
openRuntimeOptions: () => {
input.openRuntimeOptionsPalette();
},
openJimaku: () => {
input.openJimaku();
},
markAudioCard: () => {
return input.markAudioCard();
},
copySubtitleMultiple: (timeoutMs: number) => {
input.copySubtitleMultiple(timeoutMs);
},
copySubtitle: () => {
input.copySubtitle();
},
toggleSecondarySub: () => {
input.toggleSecondarySubMode();
},
updateLastCardFromClipboard: () => {
return input.updateLastCardFromClipboard();
},
triggerFieldGrouping: () => {
return input.triggerFieldGrouping();
},
triggerSubsync: () => {
return input.triggerSubsyncFromConfig();
},
mineSentence: () => {
return input.mineSentenceCard();
},
mineSentenceMultiple: (timeoutMs: number) => {
input.mineSentenceMultiple(timeoutMs);
},
});
const getShortcutLifecycleDeps = () => {
return {
getConfiguredShortcuts: () => input.getConfiguredShortcuts(),
getOverlayHandlers: () => handlers.overlayHandlers,
cancelPendingMultiCopy: () => input.cancelPendingMultiCopy(),
cancelPendingMineSentenceMultiple: () => input.cancelPendingMineSentenceMultiple(),
};
};
const shouldOverlayShortcutsBeActive = () => input.isOverlayRuntimeInitialized();
return {
tryHandleOverlayShortcutLocalFallback: (inputEvent) =>
runOverlayShortcutLocalFallback(
inputEvent,
input.getConfiguredShortcuts(),
shortcutMatchesInputForLocalFallback,
handlers.fallbackHandlers,
),
registerOverlayShortcuts: () => {
input.setShortcutsRegistered(
registerOverlayShortcuts(input.getConfiguredShortcuts(), handlers.overlayHandlers),
);
},
unregisterOverlayShortcuts: () => {
input.setShortcutsRegistered(
unregisterOverlayShortcutsRuntime(
input.getShortcutsRegistered(),
getShortcutLifecycleDeps(),
),
);
},
syncOverlayShortcuts: () => {
input.setShortcutsRegistered(
syncOverlayShortcutsRuntime(
shouldOverlayShortcutsBeActive(),
input.getShortcutsRegistered(),
getShortcutLifecycleDeps(),
),
);
},
refreshOverlayShortcuts: () => {
input.setShortcutsRegistered(
refreshOverlayShortcutsRuntime(
shouldOverlayShortcutsBeActive(),
input.getShortcutsRegistered(),
getShortcutLifecycleDeps(),
),
);
},
};
}

View File

@@ -0,0 +1,90 @@
import type { BrowserWindow } from 'electron';
import type { BaseWindowTracker } from '../window-trackers';
import type { WindowGeometry } from '../types';
import {
syncInvisibleOverlayMousePassthrough,
updateInvisibleOverlayVisibility,
updateVisibleOverlayVisibility,
} from '../core/services';
export interface OverlayVisibilityRuntimeDeps {
getMainWindow: () => BrowserWindow | null;
getInvisibleWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
getWindowTracker: () => BaseWindowTracker | null;
getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
}
export interface OverlayVisibilityRuntimeService {
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
}
export function createOverlayVisibilityRuntimeService(
deps: OverlayVisibilityRuntimeDeps,
): OverlayVisibilityRuntimeService {
const hasInvisibleWindow = (): boolean => {
const invisibleWindow = deps.getInvisibleWindow();
return Boolean(invisibleWindow && !invisibleWindow.isDestroyed());
};
const setIgnoreMouseEvents = (
ignore: boolean,
options?: Parameters<BrowserWindow['setIgnoreMouseEvents']>[1],
): void => {
const invisibleWindow = deps.getInvisibleWindow();
if (!invisibleWindow || invisibleWindow.isDestroyed()) return;
invisibleWindow.setIgnoreMouseEvents(ignore, options);
};
return {
updateVisibleOverlayVisibility(): void {
updateVisibleOverlayVisibility({
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
mainWindow: deps.getMainWindow(),
windowTracker: deps.getWindowTracker(),
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => {
deps.setTrackerNotReadyWarningShown(shown);
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
});
},
updateInvisibleOverlayVisibility(): void {
updateInvisibleOverlayVisibility({
invisibleWindow: deps.getInvisibleWindow(),
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
windowTracker: deps.getWindowTracker(),
updateInvisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateInvisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
});
},
syncInvisibleOverlayMousePassthrough(): void {
syncInvisibleOverlayMousePassthrough({
hasInvisibleWindow,
setIgnoreMouseEvents,
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
});
},
};
}

View File

@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildEnsureAnilistMediaGuessMainDepsHandler,
createBuildMaybeProbeAnilistDurationMainDepsHandler,
} from './anilist-media-guess-main-deps';
test('maybe probe anilist duration main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildMaybeProbeAnilistDurationMainDepsHandler({
getState: () => ({
mediaKey: 'm',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
}),
setState: () => calls.push('set-state'),
durationRetryIntervalMs: 1000,
now: () => 42,
requestMpvDuration: async () => 3600,
logWarn: (message) => calls.push(`warn:${message}`),
})();
assert.equal(deps.durationRetryIntervalMs, 1000);
assert.equal(deps.now(), 42);
assert.equal(await deps.requestMpvDuration(), 3600);
deps.setState({
mediaKey: 'm',
mediaDurationSec: 100,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
});
deps.logWarn('oops', null);
assert.deepEqual(calls, ['set-state', 'warn:oops']);
});
test('ensure anilist media guess main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildEnsureAnilistMediaGuessMainDepsHandler({
getState: () => ({
mediaKey: 'm',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
}),
setState: () => calls.push('set-state'),
resolveMediaPathForJimaku: (path) => {
calls.push('resolve');
return path;
},
getCurrentMediaPath: () => '/tmp/video.mkv',
getCurrentMediaTitle: () => 'title',
guessAnilistMediaInfo: async () => {
calls.push('guess');
return { title: 'title', episode: 1, source: 'fallback' };
},
})();
assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv');
assert.equal(deps.getCurrentMediaTitle(), 'title');
assert.equal(deps.resolveMediaPathForJimaku('/tmp/video.mkv'), '/tmp/video.mkv');
assert.deepEqual(await deps.guessAnilistMediaInfo('/tmp/video.mkv', 'title'), {
title: 'title',
episode: 1,
source: 'fallback',
});
deps.setState({
mediaKey: 'm',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
});
assert.deepEqual(calls, ['resolve', 'guess', 'set-state']);
});

View File

@@ -0,0 +1,31 @@
import type {
createEnsureAnilistMediaGuessHandler,
createMaybeProbeAnilistDurationHandler,
} from './anilist-media-guess';
type MaybeProbeAnilistDurationMainDeps = Parameters<typeof createMaybeProbeAnilistDurationHandler>[0];
type EnsureAnilistMediaGuessMainDeps = Parameters<typeof createEnsureAnilistMediaGuessHandler>[0];
export function createBuildMaybeProbeAnilistDurationMainDepsHandler(
deps: MaybeProbeAnilistDurationMainDeps,
) {
return (): MaybeProbeAnilistDurationMainDeps => ({
getState: () => deps.getState(),
setState: (state) => deps.setState(state),
durationRetryIntervalMs: deps.durationRetryIntervalMs,
now: () => deps.now(),
requestMpvDuration: () => deps.requestMpvDuration(),
logWarn: (message: string, error: unknown) => deps.logWarn(message, error),
});
}
export function createBuildEnsureAnilistMediaGuessMainDepsHandler(deps: EnsureAnilistMediaGuessMainDeps) {
return (): EnsureAnilistMediaGuessMainDeps => ({
getState: () => deps.getState(),
setState: (state) => deps.setState(state),
resolveMediaPathForJimaku: (currentMediaPath) => deps.resolveMediaPathForJimaku(currentMediaPath),
getCurrentMediaPath: () => deps.getCurrentMediaPath(),
getCurrentMediaTitle: () => deps.getCurrentMediaTitle(),
guessAnilistMediaInfo: (mediaPath, mediaTitle) => deps.guessAnilistMediaInfo(mediaPath, mediaTitle),
});
}

View File

@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createEnsureAnilistMediaGuessHandler,
createMaybeProbeAnilistDurationHandler,
type AnilistMediaGuessRuntimeState,
} from './anilist-media-guess';
test('maybeProbeAnilistDuration updates state with probed duration', async () => {
let state: AnilistMediaGuessRuntimeState = {
mediaKey: '/tmp/video.mkv',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
};
const probe = createMaybeProbeAnilistDurationHandler({
getState: () => state,
setState: (next) => {
state = next;
},
durationRetryIntervalMs: 1000,
now: () => 2000,
requestMpvDuration: async () => 321,
logWarn: () => {},
});
const duration = await probe('/tmp/video.mkv');
assert.equal(duration, 321);
assert.equal(state.mediaDurationSec, 321);
});
test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
let state: AnilistMediaGuessRuntimeState = {
mediaKey: '/tmp/video.mkv',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
};
let calls = 0;
const ensureGuess = createEnsureAnilistMediaGuessHandler({
getState: () => state,
setState: (next) => {
state = next;
},
resolveMediaPathForJimaku: (value) => value,
getCurrentMediaPath: () => '/tmp/video.mkv',
getCurrentMediaTitle: () => 'Episode 1',
guessAnilistMediaInfo: async () => {
calls += 1;
return { title: 'Show', episode: 1, source: 'guessit' };
},
});
const [first, second] = await Promise.all([
ensureGuess('/tmp/video.mkv'),
ensureGuess('/tmp/video.mkv'),
]);
assert.deepEqual(first, { title: 'Show', episode: 1, source: 'guessit' });
assert.deepEqual(second, { title: 'Show', episode: 1, source: 'guessit' });
assert.equal(calls, 1);
assert.deepEqual(state.mediaGuess, { title: 'Show', episode: 1, source: 'guessit' });
assert.equal(state.mediaGuessPromise, null);
});

View File

@@ -0,0 +1,112 @@
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
export type AnilistMediaGuessRuntimeState = {
mediaKey: string | null;
mediaDurationSec: number | null;
mediaGuess: AnilistMediaGuess | null;
mediaGuessPromise: Promise<AnilistMediaGuess | null> | null;
lastDurationProbeAtMs: number;
};
type GuessAnilistMediaInfo = (
mediaPath: string | null,
mediaTitle: string | null,
) => Promise<AnilistMediaGuess | null>;
export function createMaybeProbeAnilistDurationHandler(deps: {
getState: () => AnilistMediaGuessRuntimeState;
setState: (state: AnilistMediaGuessRuntimeState) => void;
durationRetryIntervalMs: number;
now: () => number;
requestMpvDuration: () => Promise<unknown>;
logWarn: (message: string, error: unknown) => void;
}) {
return async (mediaKey: string): Promise<number | null> => {
const state = deps.getState();
if (state.mediaKey !== mediaKey) {
return null;
}
if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) {
return state.mediaDurationSec;
}
const now = deps.now();
if (now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
return null;
}
deps.setState({
...state,
lastDurationProbeAtMs: now,
});
try {
const durationCandidate = await deps.requestMpvDuration();
const duration =
typeof durationCandidate === 'number' && Number.isFinite(durationCandidate)
? durationCandidate
: null;
const latestState = deps.getState();
if (duration && duration > 0 && latestState.mediaKey === mediaKey) {
deps.setState({
...latestState,
mediaDurationSec: duration,
});
return duration;
}
} catch (error) {
deps.logWarn('AniList duration probe failed:', error);
}
return null;
};
}
export function createEnsureAnilistMediaGuessHandler(deps: {
getState: () => AnilistMediaGuessRuntimeState;
setState: (state: AnilistMediaGuessRuntimeState) => void;
resolveMediaPathForJimaku: (currentMediaPath: string | null) => string | null;
getCurrentMediaPath: () => string | null;
getCurrentMediaTitle: () => string | null;
guessAnilistMediaInfo: GuessAnilistMediaInfo;
}) {
return async (mediaKey: string): Promise<AnilistMediaGuess | null> => {
const state = deps.getState();
if (state.mediaKey !== mediaKey) {
return null;
}
if (state.mediaGuess) {
return state.mediaGuess;
}
if (state.mediaGuessPromise) {
return state.mediaGuessPromise;
}
const mediaPathForGuess = deps.resolveMediaPathForJimaku(deps.getCurrentMediaPath());
const promise = deps
.guessAnilistMediaInfo(mediaPathForGuess, deps.getCurrentMediaTitle())
.then((guess) => {
const latestState = deps.getState();
if (latestState.mediaKey === mediaKey) {
deps.setState({
...latestState,
mediaGuess: guess,
});
}
return guess;
})
.finally(() => {
const latestState = deps.getState();
if (latestState.mediaKey === mediaKey) {
deps.setState({
...latestState,
mediaGuessPromise: null,
});
}
});
deps.setState({
...state,
mediaGuessPromise: promise,
});
return promise;
};
}

View File

@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
createBuildResetAnilistMediaGuessStateMainDepsHandler,
createBuildResetAnilistMediaTrackingMainDepsHandler,
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
} from './anilist-media-state-main-deps';
test('get current anilist media key main deps builder maps callbacks', () => {
const deps = createBuildGetCurrentAnilistMediaKeyMainDepsHandler({
getCurrentMediaPath: () => '/tmp/video.mkv',
})();
assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv');
});
test('reset anilist media tracking main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildResetAnilistMediaTrackingMainDepsHandler({
setMediaKey: () => calls.push('key'),
setMediaDurationSec: () => calls.push('duration'),
setMediaGuess: () => calls.push('guess'),
setMediaGuessPromise: () => calls.push('promise'),
setLastDurationProbeAtMs: () => calls.push('probe'),
})();
deps.setMediaKey(null);
deps.setMediaDurationSec(null);
deps.setMediaGuess(null);
deps.setMediaGuessPromise(null);
deps.setLastDurationProbeAtMs(0);
assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']);
});
test('get/set anilist media guess runtime state main deps builders map callbacks', () => {
const getter = createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler({
getMediaKey: () => '/tmp/video.mkv',
getMediaDurationSec: () => 24,
getMediaGuess: () => ({ title: 'X' }) as never,
getMediaGuessPromise: () => Promise.resolve(null) as never,
getLastDurationProbeAtMs: () => 123,
})();
assert.equal(getter.getMediaKey(), '/tmp/video.mkv');
assert.equal(getter.getMediaDurationSec(), 24);
assert.equal(getter.getLastDurationProbeAtMs(), 123);
const calls: string[] = [];
const setter = createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler({
setMediaKey: () => calls.push('key'),
setMediaDurationSec: () => calls.push('duration'),
setMediaGuess: () => calls.push('guess'),
setMediaGuessPromise: () => calls.push('promise'),
setLastDurationProbeAtMs: () => calls.push('probe'),
})();
setter.setMediaKey(null);
setter.setMediaDurationSec(null);
setter.setMediaGuess(null);
setter.setMediaGuessPromise(null);
setter.setLastDurationProbeAtMs(0);
assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']);
});
test('reset anilist media guess state main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildResetAnilistMediaGuessStateMainDepsHandler({
setMediaGuess: () => calls.push('guess'),
setMediaGuessPromise: () => calls.push('promise'),
})();
deps.setMediaGuess(null);
deps.setMediaGuessPromise(null);
assert.deepEqual(calls, ['guess', 'promise']);
});

View File

@@ -0,0 +1,72 @@
import type {
createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler,
} from './anilist-media-state';
type GetCurrentAnilistMediaKeyMainDeps = Parameters<typeof createGetCurrentAnilistMediaKeyHandler>[0];
type ResetAnilistMediaTrackingMainDeps = Parameters<typeof createResetAnilistMediaTrackingHandler>[0];
type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
typeof createGetAnilistMediaGuessRuntimeStateHandler
>[0];
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
typeof createSetAnilistMediaGuessRuntimeStateHandler
>[0];
type ResetAnilistMediaGuessStateMainDeps = Parameters<
typeof createResetAnilistMediaGuessStateHandler
>[0];
export function createBuildGetCurrentAnilistMediaKeyMainDepsHandler(
deps: GetCurrentAnilistMediaKeyMainDeps,
) {
return (): GetCurrentAnilistMediaKeyMainDeps => ({
getCurrentMediaPath: () => deps.getCurrentMediaPath(),
});
}
export function createBuildResetAnilistMediaTrackingMainDepsHandler(
deps: ResetAnilistMediaTrackingMainDeps,
) {
return (): ResetAnilistMediaTrackingMainDeps => ({
setMediaKey: (value) => deps.setMediaKey(value),
setMediaDurationSec: (value) => deps.setMediaDurationSec(value),
setMediaGuess: (value) => deps.setMediaGuess(value),
setMediaGuessPromise: (value) => deps.setMediaGuessPromise(value),
setLastDurationProbeAtMs: (value: number) => deps.setLastDurationProbeAtMs(value),
});
}
export function createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler(
deps: GetAnilistMediaGuessRuntimeStateMainDeps,
) {
return (): GetAnilistMediaGuessRuntimeStateMainDeps => ({
getMediaKey: () => deps.getMediaKey(),
getMediaDurationSec: () => deps.getMediaDurationSec(),
getMediaGuess: () => deps.getMediaGuess(),
getMediaGuessPromise: () => deps.getMediaGuessPromise(),
getLastDurationProbeAtMs: () => deps.getLastDurationProbeAtMs(),
});
}
export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler(
deps: SetAnilistMediaGuessRuntimeStateMainDeps,
) {
return (): SetAnilistMediaGuessRuntimeStateMainDeps => ({
setMediaKey: (value) => deps.setMediaKey(value),
setMediaDurationSec: (value) => deps.setMediaDurationSec(value),
setMediaGuess: (value) => deps.setMediaGuess(value),
setMediaGuessPromise: (value) => deps.setMediaGuessPromise(value),
setLastDurationProbeAtMs: (value: number) => deps.setLastDurationProbeAtMs(value),
});
}
export function createBuildResetAnilistMediaGuessStateMainDepsHandler(
deps: ResetAnilistMediaGuessStateMainDeps,
) {
return (): ResetAnilistMediaGuessStateMainDeps => ({
setMediaGuess: (value) => deps.setMediaGuess(value),
setMediaGuessPromise: (value) => deps.setMediaGuessPromise(value),
});
}

View File

@@ -0,0 +1,166 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler,
} from './anilist-media-state';
test('get current anilist media key trims and normalizes empty path', () => {
const getKey = createGetCurrentAnilistMediaKeyHandler({
getCurrentMediaPath: () => ' /tmp/video.mkv ',
});
const getEmptyKey = createGetCurrentAnilistMediaKeyHandler({
getCurrentMediaPath: () => ' ',
});
assert.equal(getKey(), '/tmp/video.mkv');
assert.equal(getEmptyKey(), null);
});
test('reset anilist media tracking clears duration/guess/probe state', () => {
let mediaKey: string | null = 'old';
let mediaDurationSec: number | null = 123;
let mediaGuess: { title: string } | null = { title: 'guess' };
let mediaGuessPromise: Promise<unknown> | null = Promise.resolve(null);
let lastDurationProbeAtMs = 999;
const reset = createResetAnilistMediaTrackingHandler({
setMediaKey: (value) => {
mediaKey = value;
},
setMediaDurationSec: (value) => {
mediaDurationSec = value;
},
setMediaGuess: (value) => {
mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
mediaGuessPromise = value;
},
setLastDurationProbeAtMs: (value) => {
lastDurationProbeAtMs = value;
},
});
reset('/new/media');
assert.equal(mediaKey, '/new/media');
assert.equal(mediaDurationSec, null);
assert.equal(mediaGuess, null);
assert.equal(mediaGuessPromise, null);
assert.equal(lastDurationProbeAtMs, 0);
});
test('reset anilist media tracking is idempotent', () => {
const state = {
mediaKey: 'old' as string | null,
mediaDurationSec: 123 as number | null,
mediaGuess: { title: 'guess' } as { title: string } | null,
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
lastDurationProbeAtMs: 999,
};
const reset = createResetAnilistMediaTrackingHandler({
setMediaKey: (value) => {
state.mediaKey = value;
},
setMediaDurationSec: (value) => {
state.mediaDurationSec = value;
},
setMediaGuess: (value) => {
state.mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
state.mediaGuessPromise = value;
},
setLastDurationProbeAtMs: (value) => {
state.lastDurationProbeAtMs = value;
},
});
reset('/new/media');
const afterFirstReset = { ...state };
reset('/new/media');
assert.deepEqual(state, afterFirstReset);
});
test('get/set anilist media guess runtime state round-trips fields', () => {
let state = {
mediaKey: null as string | null,
mediaDurationSec: null as number | null,
mediaGuess: null as { title: string } | null,
mediaGuessPromise: null as Promise<unknown> | null,
lastDurationProbeAtMs: 0,
};
const setState = createSetAnilistMediaGuessRuntimeStateHandler({
setMediaKey: (value) => {
state.mediaKey = value;
},
setMediaDurationSec: (value) => {
state.mediaDurationSec = value;
},
setMediaGuess: (value) => {
state.mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
state.mediaGuessPromise = value;
},
setLastDurationProbeAtMs: (value) => {
state.lastDurationProbeAtMs = value;
},
});
const getState = createGetAnilistMediaGuessRuntimeStateHandler({
getMediaKey: () => state.mediaKey,
getMediaDurationSec: () => state.mediaDurationSec,
getMediaGuess: () => state.mediaGuess as never,
getMediaGuessPromise: () => state.mediaGuessPromise as never,
getLastDurationProbeAtMs: () => state.lastDurationProbeAtMs,
});
const nextPromise = Promise.resolve(null);
setState({
mediaKey: '/tmp/video.mkv',
mediaDurationSec: 24,
mediaGuess: { title: 'Title' } as never,
mediaGuessPromise: nextPromise as never,
lastDurationProbeAtMs: 321,
});
const roundTrip = getState();
assert.equal(roundTrip.mediaKey, '/tmp/video.mkv');
assert.equal(roundTrip.mediaDurationSec, 24);
assert.deepEqual(roundTrip.mediaGuess, { title: 'Title' });
assert.equal(roundTrip.mediaGuessPromise, nextPromise);
assert.equal(roundTrip.lastDurationProbeAtMs, 321);
});
test('reset anilist media guess state clears guess and in-flight promise', () => {
const state = {
mediaKey: '/tmp/video.mkv' as string | null,
mediaDurationSec: 240 as number | null,
mediaGuess: { title: 'guess' } as { title: string } | null,
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
lastDurationProbeAtMs: 321,
};
const resetGuessState = createResetAnilistMediaGuessStateHandler({
setMediaGuess: (value) => {
state.mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
state.mediaGuessPromise = value;
},
});
resetGuessState();
assert.equal(state.mediaGuess, null);
assert.equal(state.mediaGuessPromise, null);
assert.equal(state.mediaKey, '/tmp/video.mkv');
assert.equal(state.mediaDurationSec, 240);
assert.equal(state.lastDurationProbeAtMs, 321);
});

View File

@@ -0,0 +1,68 @@
import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess';
export function createGetCurrentAnilistMediaKeyHandler(deps: {
getCurrentMediaPath: () => string | null;
}) {
return (): string | null => {
const mediaPath = deps.getCurrentMediaPath()?.trim();
return mediaPath && mediaPath.length > 0 ? mediaPath : null;
};
}
export function createResetAnilistMediaTrackingHandler(deps: {
setMediaKey: (value: string | null) => void;
setMediaDurationSec: (value: number | null) => void;
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
setLastDurationProbeAtMs: (value: number) => void;
}) {
return (mediaKey: string | null): void => {
deps.setMediaKey(mediaKey);
deps.setMediaDurationSec(null);
deps.setMediaGuess(null);
deps.setMediaGuessPromise(null);
deps.setLastDurationProbeAtMs(0);
};
}
export function createGetAnilistMediaGuessRuntimeStateHandler(deps: {
getMediaKey: () => string | null;
getMediaDurationSec: () => number | null;
getMediaGuess: () => AnilistMediaGuessRuntimeState['mediaGuess'];
getMediaGuessPromise: () => AnilistMediaGuessRuntimeState['mediaGuessPromise'];
getLastDurationProbeAtMs: () => number;
}) {
return (): AnilistMediaGuessRuntimeState => ({
mediaKey: deps.getMediaKey(),
mediaDurationSec: deps.getMediaDurationSec(),
mediaGuess: deps.getMediaGuess(),
mediaGuessPromise: deps.getMediaGuessPromise(),
lastDurationProbeAtMs: deps.getLastDurationProbeAtMs(),
});
}
export function createSetAnilistMediaGuessRuntimeStateHandler(deps: {
setMediaKey: (value: string | null) => void;
setMediaDurationSec: (value: number | null) => void;
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
setLastDurationProbeAtMs: (value: number) => void;
}) {
return (state: AnilistMediaGuessRuntimeState): void => {
deps.setMediaKey(state.mediaKey);
deps.setMediaDurationSec(state.mediaDurationSec);
deps.setMediaGuess(state.mediaGuess);
deps.setMediaGuessPromise(state.mediaGuessPromise);
deps.setLastDurationProbeAtMs(state.lastDurationProbeAtMs);
};
}
export function createResetAnilistMediaGuessStateHandler(deps: {
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
}) {
return (): void => {
deps.setMediaGuess(null);
deps.setMediaGuessPromise(null);
};
}

View File

@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
} from './anilist-post-watch-main-deps';
test('process next anilist retry update main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildProcessNextAnilistRetryUpdateMainDepsHandler({
nextReady: () => ({ key: 'k', title: 't', episode: 1 }),
refreshRetryQueueState: () => calls.push('refresh'),
setLastAttemptAt: () => calls.push('attempt'),
setLastError: () => calls.push('error'),
refreshAnilistClientSecretState: async () => 'token',
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
markSuccess: () => calls.push('success'),
rememberAttemptedUpdateKey: () => calls.push('remember'),
markFailure: () => calls.push('failure'),
logInfo: (message) => calls.push(`info:${message}`),
now: () => 7,
})();
assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', episode: 1 });
deps.refreshRetryQueueState();
deps.setLastAttemptAt(1);
deps.setLastError('x');
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
status: 'updated',
message: 'ok',
});
deps.markSuccess('k');
deps.rememberAttemptedUpdateKey('k');
deps.markFailure('k', 'bad');
deps.logInfo('hello');
assert.equal(deps.now(), 7);
assert.deepEqual(calls, [
'refresh',
'attempt',
'error',
'success',
'remember',
'failure',
'info:hello',
]);
});
test('maybe run anilist post watch update main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler({
getInFlight: () => false,
setInFlight: () => calls.push('in-flight'),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => 'media',
hasMpvClient: () => true,
getTrackedMediaKey: () => 'media',
resetTrackedMedia: () => calls.push('reset'),
getWatchedSeconds: () => 100,
maybeProbeAnilistDuration: async () => 120,
ensureAnilistMediaGuess: async () => ({ title: 'x', episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('retry-fail'),
markRetrySuccess: () => calls.push('retry-ok'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'done' }),
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: () => calls.push('osd'),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 5,
minWatchRatio: 0.5,
})();
assert.equal(deps.getInFlight(), false);
deps.setInFlight(true);
assert.equal(deps.isAnilistTrackingEnabled(deps.getResolvedConfig()), true);
assert.equal(deps.getCurrentMediaKey(), 'media');
assert.equal(deps.hasMpvClient(), true);
assert.equal(deps.getTrackedMediaKey(), 'media');
deps.resetTrackedMedia('media');
assert.equal(deps.getWatchedSeconds(), 100);
assert.equal(await deps.maybeProbeAnilistDuration('media'), 120);
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { title: 'x', episode: 1 });
assert.equal(deps.hasAttemptedUpdateKey('k'), false);
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
deps.enqueueRetry('k', 't', 1);
deps.markRetryFailure('k', 'bad');
deps.markRetrySuccess('k');
deps.refreshRetryQueueState();
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
status: 'updated',
message: 'done',
});
deps.rememberAttemptedUpdateKey('k');
deps.showMpvOsd('ok');
deps.logInfo('x');
deps.logWarn('y');
assert.equal(deps.minWatchSeconds, 5);
assert.equal(deps.minWatchRatio, 0.5);
assert.deepEqual(calls, [
'in-flight',
'reset',
'enqueue',
'retry-fail',
'retry-ok',
'refresh',
'remember',
'osd',
'info:x',
'warn:y',
]);
});

View File

@@ -0,0 +1,63 @@
import type {
createMaybeRunAnilistPostWatchUpdateHandler,
createProcessNextAnilistRetryUpdateHandler,
} from './anilist-post-watch';
type ProcessNextAnilistRetryUpdateMainDeps = Parameters<
typeof createProcessNextAnilistRetryUpdateHandler
>[0];
type MaybeRunAnilistPostWatchUpdateMainDeps = Parameters<
typeof createMaybeRunAnilistPostWatchUpdateHandler
>[0];
export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
deps: ProcessNextAnilistRetryUpdateMainDeps,
) {
return (): ProcessNextAnilistRetryUpdateMainDeps => ({
nextReady: () => deps.nextReady(),
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
setLastAttemptAt: (value: number) => deps.setLastAttemptAt(value),
setLastError: (value: string | null) => deps.setLastError(value),
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
markSuccess: (key: string) => deps.markSuccess(key),
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
markFailure: (key: string, message: string) => deps.markFailure(key, message),
logInfo: (message: string) => deps.logInfo(message),
now: () => deps.now(),
});
}
export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
deps: MaybeRunAnilistPostWatchUpdateMainDeps,
) {
return (): MaybeRunAnilistPostWatchUpdateMainDeps => ({
getInFlight: () => deps.getInFlight(),
setInFlight: (value: boolean) => deps.setInFlight(value),
getResolvedConfig: () => deps.getResolvedConfig(),
isAnilistTrackingEnabled: (config) => deps.isAnilistTrackingEnabled(config),
getCurrentMediaKey: () => deps.getCurrentMediaKey(),
hasMpvClient: () => deps.hasMpvClient(),
getTrackedMediaKey: () => deps.getTrackedMediaKey(),
resetTrackedMedia: (mediaKey) => deps.resetTrackedMedia(mediaKey),
getWatchedSeconds: () => deps.getWatchedSeconds(),
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
enqueueRetry: (key: string, title: string, episode: number) => deps.enqueueRetry(key, title, episode),
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
logInfo: (message: string) => deps.logInfo(message),
logWarn: (message: string) => deps.logWarn(message),
minWatchSeconds: deps.minWatchSeconds,
minWatchRatio: deps.minWatchRatio,
});
}

View File

@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildAnilistAttemptKey,
createMaybeRunAnilistPostWatchUpdateHandler,
createProcessNextAnilistRetryUpdateHandler,
rememberAnilistAttemptedUpdateKey,
} from './anilist-post-watch';
test('buildAnilistAttemptKey formats media and episode', () => {
assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3');
});
test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
const set = new Set<string>(['a', 'b']);
rememberAnilistAttemptedUpdateKey(set, 'c', 2);
assert.deepEqual(Array.from(set), ['b', 'c']);
});
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
const calls: string[] = [];
const handler = createProcessNextAnilistRetryUpdateHandler({
nextReady: () => ({ key: 'k1', title: 'Show', episode: 1 }),
refreshRetryQueueState: () => calls.push('refresh'),
setLastAttemptAt: () => calls.push('attempt'),
setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
refreshAnilistClientSecretState: async () => 'token',
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'updated ok' }),
markSuccess: () => calls.push('success'),
rememberAttemptedUpdateKey: () => calls.push('remember'),
markFailure: () => calls.push('failure'),
logInfo: () => calls.push('info'),
now: () => 1,
});
const result = await handler();
assert.deepEqual(result, { ok: true, message: 'updated ok' });
assert.ok(calls.includes('success'));
assert.ok(calls.includes('remember'));
});
test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', async () => {
const calls: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => null,
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.ok(calls.includes('enqueue'));
assert.ok(calls.includes('mark-failure'));
assert.ok(calls.includes('osd:AniList: access token not configured'));
assert.ok(calls.includes('inflight:true'));
assert.ok(calls.includes('inflight:false'));
});

View File

@@ -0,0 +1,195 @@
type AnilistGuess = {
title: string;
episode: number | null;
};
type AnilistUpdateResult = {
status: 'updated' | 'skipped' | 'error';
message: string;
};
type RetryQueueItem = {
key: string;
title: string;
episode: number;
};
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
return `${mediaKey}::${episode}`;
}
export function rememberAnilistAttemptedUpdateKey(
attemptedKeys: Set<string>,
key: string,
maxSize: number,
): void {
attemptedKeys.add(key);
if (attemptedKeys.size <= maxSize) {
return;
}
const oldestKey = attemptedKeys.values().next().value;
if (typeof oldestKey === 'string') {
attemptedKeys.delete(oldestKey);
}
}
export function createProcessNextAnilistRetryUpdateHandler(deps: {
nextReady: () => RetryQueueItem | null;
refreshRetryQueueState: () => void;
setLastAttemptAt: (value: number) => void;
setLastError: (value: string | null) => void;
refreshAnilistClientSecretState: () => Promise<string | null>;
updateAnilistPostWatchProgress: (
accessToken: string,
title: string,
episode: number,
) => Promise<AnilistUpdateResult>;
markSuccess: (key: string) => void;
rememberAttemptedUpdateKey: (key: string) => void;
markFailure: (key: string, message: string) => void;
logInfo: (message: string) => void;
now: () => number;
}) {
return async (): Promise<{ ok: boolean; message: string }> => {
const queued = deps.nextReady();
deps.refreshRetryQueueState();
if (!queued) {
return { ok: true, message: 'AniList queue has no ready items.' };
}
deps.setLastAttemptAt(deps.now());
const accessToken = await deps.refreshAnilistClientSecretState();
if (!accessToken) {
deps.setLastError('AniList token unavailable for queued retry.');
return { ok: false, message: 'AniList token unavailable for queued retry.' };
}
const result = await deps.updateAnilistPostWatchProgress(accessToken, queued.title, queued.episode);
if (result.status === 'updated' || result.status === 'skipped') {
deps.markSuccess(queued.key);
deps.rememberAttemptedUpdateKey(queued.key);
deps.setLastError(null);
deps.refreshRetryQueueState();
deps.logInfo(`[AniList queue] ${result.message}`);
return { ok: true, message: result.message };
}
deps.markFailure(queued.key, result.message);
deps.setLastError(result.message);
deps.refreshRetryQueueState();
return { ok: false, message: result.message };
};
}
export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
getInFlight: () => boolean;
setInFlight: (value: boolean) => void;
getResolvedConfig: () => unknown;
isAnilistTrackingEnabled: (config: unknown) => boolean;
getCurrentMediaKey: () => string | null;
hasMpvClient: () => boolean;
getTrackedMediaKey: () => string | null;
resetTrackedMedia: (mediaKey: string | null) => void;
getWatchedSeconds: () => number;
maybeProbeAnilistDuration: (mediaKey: string) => Promise<number | null>;
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistGuess | null>;
hasAttemptedUpdateKey: (key: string) => boolean;
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
refreshAnilistClientSecretState: () => Promise<string | null>;
enqueueRetry: (key: string, title: string, episode: number) => void;
markRetryFailure: (key: string, message: string) => void;
markRetrySuccess: (key: string) => void;
refreshRetryQueueState: () => void;
updateAnilistPostWatchProgress: (
accessToken: string,
title: string,
episode: number,
) => Promise<AnilistUpdateResult>;
rememberAttemptedUpdateKey: (key: string) => void;
showMpvOsd: (message: string) => void;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
minWatchSeconds: number;
minWatchRatio: number;
}) {
return async (): Promise<void> => {
if (deps.getInFlight()) {
return;
}
const resolved = deps.getResolvedConfig();
if (!deps.isAnilistTrackingEnabled(resolved)) {
return;
}
const mediaKey = deps.getCurrentMediaKey();
if (!mediaKey || !deps.hasMpvClient()) {
return;
}
if (deps.getTrackedMediaKey() !== mediaKey) {
deps.resetTrackedMedia(mediaKey);
}
const watchedSeconds = deps.getWatchedSeconds();
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
return;
}
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
if (!duration || duration <= 0) {
return;
}
if (watchedSeconds / duration < deps.minWatchRatio) {
return;
}
const guess = await deps.ensureAnilistMediaGuess(mediaKey);
if (!guess?.title || !guess.episode || guess.episode <= 0) {
return;
}
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
if (deps.hasAttemptedUpdateKey(attemptKey)) {
return;
}
deps.setInFlight(true);
try {
await deps.processNextAnilistRetryUpdate();
const accessToken = await deps.refreshAnilistClientSecretState();
if (!accessToken) {
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
deps.refreshRetryQueueState();
deps.showMpvOsd('AniList: access token not configured');
return;
}
const result = await deps.updateAnilistPostWatchProgress(accessToken, guess.title, guess.episode);
if (result.status === 'updated') {
deps.rememberAttemptedUpdateKey(attemptKey);
deps.markRetrySuccess(attemptKey);
deps.refreshRetryQueueState();
deps.showMpvOsd(result.message);
deps.logInfo(result.message);
return;
}
if (result.status === 'skipped') {
deps.rememberAttemptedUpdateKey(attemptKey);
deps.markRetrySuccess(attemptKey);
deps.refreshRetryQueueState();
deps.logInfo(result.message);
return;
}
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
deps.markRetryFailure(attemptKey, result.message);
deps.refreshRetryQueueState();
deps.showMpvOsd(`AniList: ${result.message}`);
deps.logWarn(result.message);
} finally {
deps.setInFlight(false);
}
};
}

View File

@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler,
createBuildHandleAnilistSetupProtocolUrlMainDepsHandler,
createBuildNotifyAnilistSetupMainDepsHandler,
createBuildRegisterSubminerProtocolClientMainDepsHandler,
} from './anilist-setup-protocol-main-deps';
test('notify anilist setup main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildNotifyAnilistSetupMainDepsHandler({
hasMpvClient: () => true,
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title) => calls.push(`notify:${title}`),
logInfo: (message) => calls.push(`log:${message}`),
})();
assert.equal(deps.hasMpvClient(), true);
deps.showMpvOsd('ok');
deps.showDesktopNotification('SubMiner', { body: 'x' });
deps.logInfo('done');
assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']);
});
test('consume anilist setup token main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({
consumeAnilistSetupCallbackUrl: () => true,
saveToken: () => calls.push('save'),
setCachedToken: () => calls.push('cache'),
setResolvedState: () => calls.push('resolved'),
setSetupPageOpened: () => calls.push('opened'),
onSuccess: () => calls.push('success'),
closeWindow: () => calls.push('close'),
})();
assert.equal(
deps.consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup',
saveToken: () => {},
setCachedToken: () => {},
setResolvedState: () => {},
setSetupPageOpened: () => {},
onSuccess: () => {},
closeWindow: () => {},
}),
true,
);
deps.saveToken('token');
deps.setCachedToken('token');
deps.setResolvedState(Date.now());
deps.setSetupPageOpened(true);
deps.onSuccess();
deps.closeWindow();
assert.deepEqual(calls, ['save', 'cache', 'resolved', 'opened', 'success', 'close']);
});
test('handle anilist setup protocol url main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildHandleAnilistSetupProtocolUrlMainDepsHandler({
consumeAnilistSetupTokenFromUrl: () => true,
logWarn: (message) => calls.push(`warn:${message}`),
})();
assert.equal(deps.consumeAnilistSetupTokenFromUrl('subminer://anilist-setup'), true);
deps.logWarn('missing', null);
assert.deepEqual(calls, ['warn:missing']);
});
test('register subminer protocol client main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildRegisterSubminerProtocolClientMainDepsHandler({
isDefaultApp: () => true,
getArgv: () => ['electron', 'entry.js'],
execPath: '/tmp/electron',
resolvePath: (value) => `/abs/${value}`,
setAsDefaultProtocolClient: () => true,
logWarn: (message) => calls.push(`warn:${message}`),
})();
assert.equal(deps.isDefaultApp(), true);
assert.deepEqual(deps.getArgv(), ['electron', 'entry.js']);
assert.equal(deps.execPath, '/tmp/electron');
assert.equal(deps.resolvePath('entry.js'), '/abs/entry.js');
assert.equal(deps.setAsDefaultProtocolClient('subminer'), true);
});

View File

@@ -0,0 +1,64 @@
import type {
createConsumeAnilistSetupTokenFromUrlHandler,
createHandleAnilistSetupProtocolUrlHandler,
createNotifyAnilistSetupHandler,
createRegisterSubminerProtocolClientHandler,
} from './anilist-setup-protocol';
type NotifyAnilistSetupMainDeps = Parameters<typeof createNotifyAnilistSetupHandler>[0];
type ConsumeAnilistSetupTokenMainDeps = Parameters<
typeof createConsumeAnilistSetupTokenFromUrlHandler
>[0];
type HandleAnilistSetupProtocolUrlMainDeps = Parameters<
typeof createHandleAnilistSetupProtocolUrlHandler
>[0];
type RegisterSubminerProtocolClientMainDeps = Parameters<
typeof createRegisterSubminerProtocolClientHandler
>[0];
export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) {
return (): NotifyAnilistSetupMainDeps => ({
hasMpvClient: () => deps.hasMpvClient(),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
logInfo: (message: string) => deps.logInfo(message),
});
}
export function createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler(
deps: ConsumeAnilistSetupTokenMainDeps,
) {
return (): ConsumeAnilistSetupTokenMainDeps => ({
consumeAnilistSetupCallbackUrl: (input) => deps.consumeAnilistSetupCallbackUrl(input),
saveToken: (token: string) => deps.saveToken(token),
setCachedToken: (token: string) => deps.setCachedToken(token),
setResolvedState: (resolvedAt: number) => deps.setResolvedState(resolvedAt),
setSetupPageOpened: (opened: boolean) => deps.setSetupPageOpened(opened),
onSuccess: () => deps.onSuccess(),
closeWindow: () => deps.closeWindow(),
});
}
export function createBuildHandleAnilistSetupProtocolUrlMainDepsHandler(
deps: HandleAnilistSetupProtocolUrlMainDeps,
) {
return (): HandleAnilistSetupProtocolUrlMainDeps => ({
consumeAnilistSetupTokenFromUrl: (rawUrl: string) => deps.consumeAnilistSetupTokenFromUrl(rawUrl),
logWarn: (message: string, details: unknown) => deps.logWarn(message, details),
});
}
export function createBuildRegisterSubminerProtocolClientMainDepsHandler(
deps: RegisterSubminerProtocolClientMainDeps,
) {
return (): RegisterSubminerProtocolClientMainDeps => ({
isDefaultApp: () => deps.isDefaultApp(),
getArgv: () => deps.getArgv(),
execPath: deps.execPath,
resolvePath: (value: string) => deps.resolvePath(value),
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) =>
deps.setAsDefaultProtocolClient(scheme, path, args),
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
});
}

View File

@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createConsumeAnilistSetupTokenFromUrlHandler,
createHandleAnilistSetupProtocolUrlHandler,
createNotifyAnilistSetupHandler,
createRegisterSubminerProtocolClientHandler,
} from './anilist-setup-protocol';
test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
const calls: string[] = [];
const notify = createNotifyAnilistSetupHandler({
hasMpvClient: () => true,
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: () => calls.push('desktop'),
logInfo: () => calls.push('log'),
});
notify('AniList login success');
assert.deepEqual(calls, ['osd:AniList login success']);
});
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
const consume = createConsumeAnilistSetupTokenFromUrlHandler({
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
saveToken: () => {},
setCachedToken: () => {},
setResolvedState: () => {},
setSetupPageOpened: () => {},
onSuccess: () => {},
closeWindow: () => {},
});
assert.equal(consume('subminer://anilist-setup?access_token=ok'), true);
assert.equal(consume('subminer://anilist-setup'), false);
});
test('createHandleAnilistSetupProtocolUrlHandler validates scheme and logs missing token', () => {
const warnings: string[] = [];
const handleProtocolUrl = createHandleAnilistSetupProtocolUrlHandler({
consumeAnilistSetupTokenFromUrl: () => false,
logWarn: (message) => warnings.push(message),
});
assert.equal(handleProtocolUrl('https://example.com'), false);
assert.equal(handleProtocolUrl('subminer://anilist-setup'), true);
assert.deepEqual(warnings, ['AniList setup protocol URL missing access token']);
});
test('createRegisterSubminerProtocolClientHandler registers default app entry', () => {
const calls: string[] = [];
const register = createRegisterSubminerProtocolClientHandler({
isDefaultApp: () => true,
getArgv: () => ['electron', './entry.js'],
execPath: '/usr/local/bin/electron',
resolvePath: (value) => `/resolved/${value}`,
setAsDefaultProtocolClient: (_scheme, _path, args) => {
calls.push(`register:${String(args?.[0])}`);
return true;
},
logWarn: (message) => calls.push(`warn:${message}`),
});
register();
assert.deepEqual(calls, ['register:/resolved/./entry.js']);
});

View File

@@ -0,0 +1,91 @@
export type ConsumeAnilistSetupTokenDeps = {
consumeAnilistSetupCallbackUrl: (input: {
rawUrl: string;
saveToken: (token: string) => void;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
onSuccess: () => void;
closeWindow: () => void;
}) => boolean;
saveToken: (token: string) => void;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
onSuccess: () => void;
closeWindow: () => void;
};
export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilistSetupTokenDeps) {
return (rawUrl: string): boolean =>
deps.consumeAnilistSetupCallbackUrl({
rawUrl,
saveToken: deps.saveToken,
setCachedToken: deps.setCachedToken,
setResolvedState: deps.setResolvedState,
setSetupPageOpened: deps.setSetupPageOpened,
onSuccess: deps.onSuccess,
closeWindow: deps.closeWindow,
});
}
export function createNotifyAnilistSetupHandler(deps: {
hasMpvClient: () => boolean;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
logInfo: (message: string) => void;
}) {
return (message: string): void => {
if (deps.hasMpvClient()) {
deps.showMpvOsd(message);
return;
}
deps.showDesktopNotification('SubMiner AniList', { body: message });
deps.logInfo(`[AniList setup] ${message}`);
};
}
export function createHandleAnilistSetupProtocolUrlHandler(deps: {
consumeAnilistSetupTokenFromUrl: (rawUrl: string) => boolean;
logWarn: (message: string, details: unknown) => void;
}) {
return (rawUrl: string): boolean => {
if (!rawUrl.startsWith('subminer://anilist-setup')) {
return false;
}
if (deps.consumeAnilistSetupTokenFromUrl(rawUrl)) {
return true;
}
deps.logWarn('AniList setup protocol URL missing access token', { rawUrl });
return true;
};
}
export function createRegisterSubminerProtocolClientHandler(deps: {
isDefaultApp: () => boolean;
getArgv: () => string[];
execPath: string;
resolvePath: (value: string) => string;
setAsDefaultProtocolClient: (
scheme: string,
path?: string,
args?: string[],
) => boolean;
logWarn: (message: string, details?: unknown) => void;
}) {
return (): void => {
try {
const defaultAppEntry = deps.isDefaultApp() ? deps.getArgv()[1] : undefined;
const success = defaultAppEntry
? deps.setAsDefaultProtocolClient('subminer', deps.execPath, [
deps.resolvePath(defaultAppEntry),
])
: deps.setAsDefaultProtocolClient('subminer');
if (!success) {
deps.logWarn('Failed to register default protocol handler for subminer:// URLs');
}
} catch (error) {
deps.logWarn('Failed to register subminer:// protocol handler', error);
}
};
}

View File

@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildOpenAnilistSetupWindowMainDepsHandler } from './anilist-setup-window-main-deps';
test('open anilist setup window main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildOpenAnilistSetupWindowMainDepsHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => ({}) as never,
buildAuthorizeUrl: () => 'https://anilist.co/auth',
consumeCallbackUrl: () => true,
openSetupInBrowser: (url) => calls.push(`browser:${url}`),
loadManualTokenEntry: () => calls.push('manual'),
redirectUri: 'subminer://anilist-auth',
developerSettingsUrl: 'https://anilist.co/settings/developer',
isAllowedExternalUrl: () => true,
isAllowedNavigationUrl: () => true,
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
clearSetupWindow: () => calls.push('clear'),
setSetupPageOpened: (opened) => calls.push(`opened:${String(opened)}`),
setSetupWindow: () => calls.push('window'),
openExternal: (url) => calls.push(`external:${url}`),
})();
assert.equal(deps.maybeFocusExistingSetupWindow(), false);
assert.equal(deps.buildAuthorizeUrl(), 'https://anilist.co/auth');
assert.equal(deps.consumeCallbackUrl('subminer://anilist-setup?access_token=x'), true);
assert.equal(deps.redirectUri, 'subminer://anilist-auth');
assert.equal(deps.developerSettingsUrl, 'https://anilist.co/settings/developer');
assert.equal(deps.isAllowedExternalUrl('https://anilist.co'), true);
assert.equal(deps.isAllowedNavigationUrl('https://anilist.co/oauth'), true);
deps.openSetupInBrowser('https://anilist.co/auth');
deps.loadManualTokenEntry({} as never, 'https://anilist.co/auth');
deps.logWarn('warn');
deps.logError('error', null);
deps.clearSetupWindow();
deps.setSetupPageOpened(true);
deps.setSetupWindow({} as never);
deps.openExternal('https://anilist.co');
assert.deepEqual(calls, [
'browser:https://anilist.co/auth',
'manual',
'warn:warn',
'error:error',
'clear',
'opened:true',
'window',
'external:https://anilist.co',
]);
});

View File

@@ -0,0 +1,27 @@
import type { createOpenAnilistSetupWindowHandler } from './anilist-setup-window';
type OpenAnilistSetupWindowMainDeps = Parameters<typeof createOpenAnilistSetupWindowHandler>[0];
export function createBuildOpenAnilistSetupWindowMainDepsHandler(
deps: OpenAnilistSetupWindowMainDeps,
) {
return (): OpenAnilistSetupWindowMainDeps => ({
maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(),
createSetupWindow: () => deps.createSetupWindow(),
buildAuthorizeUrl: () => deps.buildAuthorizeUrl(),
consumeCallbackUrl: (rawUrl: string) => deps.consumeCallbackUrl(rawUrl),
openSetupInBrowser: (authorizeUrl: string) => deps.openSetupInBrowser(authorizeUrl),
loadManualTokenEntry: (setupWindow, authorizeUrl: string) =>
deps.loadManualTokenEntry(setupWindow, authorizeUrl),
redirectUri: deps.redirectUri,
developerSettingsUrl: deps.developerSettingsUrl,
isAllowedExternalUrl: (url: string) => deps.isAllowedExternalUrl(url),
isAllowedNavigationUrl: (url: string) => deps.isAllowedNavigationUrl(url),
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
logError: (message: string, details: unknown) => deps.logError(message, details),
clearSetupWindow: () => deps.clearSetupWindow(),
setSetupPageOpened: (opened: boolean) => deps.setSetupPageOpened(opened),
setSetupWindow: (setupWindow) => deps.setSetupWindow(setupWindow),
openExternal: (url: string) => deps.openExternal(url),
});
}

View File

@@ -0,0 +1,367 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHandleAnilistSetupWindowClosedHandler,
createMaybeFocusExistingAnilistSetupWindowHandler,
createHandleAnilistSetupWindowOpenedHandler,
createAnilistSetupDidFailLoadHandler,
createAnilistSetupDidFinishLoadHandler,
createAnilistSetupDidNavigateHandler,
createAnilistSetupFallbackHandler,
createAnilistSetupWillNavigateHandler,
createAnilistSetupWillRedirectHandler,
createAnilistSetupWindowOpenHandler,
createHandleManualAnilistSetupSubmissionHandler,
createOpenAnilistSetupWindowHandler,
} from './anilist-setup-window';
test('manual anilist setup submission forwards access token to callback consumer', () => {
const consumed: string[] = [];
const handleSubmission = createHandleManualAnilistSetupSubmissionHandler({
consumeCallbackUrl: (rawUrl) => {
consumed.push(rawUrl);
return true;
},
redirectUri: 'https://anilist.subminer.moe/',
logWarn: () => {},
});
const handled = handleSubmission('subminer://anilist-setup?access_token=abc123');
assert.equal(handled, true);
assert.equal(consumed.length, 1);
assert.ok(consumed[0]!.includes('https://anilist.subminer.moe/#access_token=abc123'));
});
test('maybe focus anilist setup window focuses existing window', () => {
let focused = false;
const handler = createMaybeFocusExistingAnilistSetupWindowHandler({
getSetupWindow: () => ({
focus: () => {
focused = true;
},
}),
});
const handled = handler();
assert.equal(handled, true);
assert.equal(focused, true);
});
test('manual anilist setup submission warns on missing token', () => {
const warnings: string[] = [];
const handleSubmission = createHandleManualAnilistSetupSubmissionHandler({
consumeCallbackUrl: () => false,
redirectUri: 'https://anilist.subminer.moe/',
logWarn: (message) => warnings.push(message),
});
const handled = handleSubmission('subminer://anilist-setup');
assert.equal(handled, true);
assert.deepEqual(warnings, ['AniList setup submission missing access token']);
});
test('anilist setup fallback handler triggers browser + manual entry on load fail', () => {
const calls: string[] = [];
const fallback = createAnilistSetupFallbackHandler({
authorizeUrl: 'https://anilist.co',
developerSettingsUrl: 'https://anilist.co/settings/developer',
setupWindow: {
isDestroyed: () => false,
},
openSetupInBrowser: () => calls.push('open-browser'),
loadManualTokenEntry: () => calls.push('load-manual'),
logError: () => calls.push('error'),
logWarn: () => calls.push('warn'),
});
fallback.onLoadFailure({
errorCode: -1,
errorDescription: 'failed',
validatedURL: 'about:blank',
});
assert.deepEqual(calls, ['error', 'open-browser', 'load-manual']);
});
test('anilist setup window open handler denies unsafe url', () => {
const calls: string[] = [];
const handler = createAnilistSetupWindowOpenHandler({
isAllowedExternalUrl: () => false,
openExternal: () => calls.push('open'),
logWarn: () => calls.push('warn'),
});
const result = handler({ url: 'https://malicious.example' });
assert.deepEqual(result, { action: 'deny' });
assert.deepEqual(calls, ['warn']);
});
test('anilist setup will-navigate handler blocks callback redirect uri', () => {
let prevented = false;
const handler = createAnilistSetupWillNavigateHandler({
handleManualSubmission: () => false,
consumeCallbackUrl: () => false,
redirectUri: 'https://anilist.subminer.moe/',
isAllowedNavigationUrl: () => true,
logWarn: () => {},
});
handler({
url: 'https://anilist.subminer.moe/#access_token=abc',
preventDefault: () => {
prevented = true;
},
});
assert.equal(prevented, true);
});
test('anilist setup will-navigate handler blocks unsafe urls', () => {
const calls: string[] = [];
let prevented = false;
const handler = createAnilistSetupWillNavigateHandler({
handleManualSubmission: () => false,
consumeCallbackUrl: () => false,
redirectUri: 'https://anilist.subminer.moe/',
isAllowedNavigationUrl: () => false,
logWarn: () => calls.push('warn'),
});
handler({
url: 'https://unsafe.example',
preventDefault: () => {
prevented = true;
},
});
assert.equal(prevented, true);
assert.deepEqual(calls, ['warn']);
});
test('anilist setup will-redirect handler prevents callback redirects', () => {
let prevented = false;
const handler = createAnilistSetupWillRedirectHandler({
consumeCallbackUrl: () => true,
});
handler({
url: 'https://anilist.subminer.moe/#access_token=abc',
preventDefault: () => {
prevented = true;
},
});
assert.equal(prevented, true);
});
test('anilist setup did-navigate handler consumes callback url', () => {
const seen: string[] = [];
const handler = createAnilistSetupDidNavigateHandler({
consumeCallbackUrl: (url) => {
seen.push(url);
return true;
},
});
handler('https://anilist.subminer.moe/#access_token=abc');
assert.deepEqual(seen, ['https://anilist.subminer.moe/#access_token=abc']);
});
test('anilist setup did-fail-load handler forwards details', () => {
const seen: Array<{ errorCode: number; errorDescription: string; validatedURL: string }> = [];
const handler = createAnilistSetupDidFailLoadHandler({
onLoadFailure: (details) => seen.push(details),
});
handler({
errorCode: -3,
errorDescription: 'timeout',
validatedURL: 'https://anilist.co/api/v2/oauth/authorize',
});
assert.equal(seen.length, 1);
assert.equal(seen[0]!.errorCode, -3);
});
test('anilist setup did-finish-load handler triggers fallback on blank page', () => {
const calls: string[] = [];
const handler = createAnilistSetupDidFinishLoadHandler({
getLoadedUrl: () => 'about:blank',
onBlankPageLoaded: () => calls.push('fallback'),
});
handler();
assert.deepEqual(calls, ['fallback']);
});
test('anilist setup did-finish-load handler no-ops on non-blank page', () => {
const calls: string[] = [];
const handler = createAnilistSetupDidFinishLoadHandler({
getLoadedUrl: () => 'https://anilist.co/api/v2/oauth/authorize',
onBlankPageLoaded: () => calls.push('fallback'),
});
handler();
assert.equal(calls.length, 0);
});
test('anilist setup window closed handler clears references', () => {
const calls: string[] = [];
const handler = createHandleAnilistSetupWindowClosedHandler({
clearSetupWindow: () => calls.push('clear-window'),
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
});
handler();
assert.deepEqual(calls, ['clear-window', 'opened:no']);
});
test('anilist setup window opened handler sets references', () => {
const calls: string[] = [];
const handler = createHandleAnilistSetupWindowOpenedHandler({
setSetupWindow: () => calls.push('set-window'),
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
});
handler();
assert.deepEqual(calls, ['set-window', 'opened:yes']);
});
test('open anilist setup handler no-ops when existing setup window focused', () => {
const calls: string[] = [];
const handler = createOpenAnilistSetupWindowHandler({
maybeFocusExistingSetupWindow: () => {
calls.push('focus-existing');
return true;
},
createSetupWindow: () => {
calls.push('create-window');
throw new Error('should not create');
},
buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084',
consumeCallbackUrl: () => false,
openSetupInBrowser: () => {},
loadManualTokenEntry: () => {},
redirectUri: 'https://anilist.subminer.moe/',
developerSettingsUrl: 'https://anilist.co/settings/developer',
isAllowedExternalUrl: () => true,
isAllowedNavigationUrl: () => true,
logWarn: () => {},
logError: () => {},
clearSetupWindow: () => {},
setSetupPageOpened: () => {},
setSetupWindow: () => {},
openExternal: () => {},
});
handler();
assert.deepEqual(calls, ['focus-existing']);
});
test('open anilist setup handler wires navigation, fallback, and lifecycle', () => {
let openHandler: ((params: { url: string }) => { action: 'deny' }) | null = null;
let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null;
let didNavigateHandler: ((event: unknown, url: string) => void) | null = null;
let didFinishLoadHandler: (() => void) | null = null;
let didFailLoadHandler:
| ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void)
| null = null;
let closedHandler: (() => void) | null = null;
let prevented = false;
const calls: string[] = [];
const fakeWindow = {
focus: () => {},
webContents: {
setWindowOpenHandler: (handler: (params: { url: string }) => { action: 'deny' }) => {
openHandler = handler;
},
on: (
event: 'will-navigate' | 'will-redirect' | 'did-navigate' | 'did-fail-load' | 'did-finish-load',
handler: (...args: any[]) => void,
) => {
if (event === 'will-navigate') willNavigateHandler = handler as never;
if (event === 'did-navigate') didNavigateHandler = handler as never;
if (event === 'did-finish-load') didFinishLoadHandler = handler as never;
if (event === 'did-fail-load') didFailLoadHandler = handler as never;
},
getURL: () => 'about:blank',
},
on: (event: 'closed', handler: () => void) => {
if (event === 'closed') closedHandler = handler;
},
isDestroyed: () => false,
};
const handler = createOpenAnilistSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => fakeWindow,
buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084',
consumeCallbackUrl: (rawUrl) => {
calls.push(`consume:${rawUrl}`);
return rawUrl.includes('access_token=');
},
openSetupInBrowser: () => calls.push('open-browser'),
loadManualTokenEntry: () => calls.push('load-manual'),
redirectUri: 'https://anilist.subminer.moe/',
developerSettingsUrl: 'https://anilist.co/settings/developer',
isAllowedExternalUrl: () => true,
isAllowedNavigationUrl: () => true,
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
clearSetupWindow: () => calls.push('clear-window'),
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
setSetupWindow: () => calls.push('set-window'),
openExternal: (url) => calls.push(`open:${url}`),
});
handler();
assert.ok(openHandler);
assert.ok(willNavigateHandler);
assert.ok(didNavigateHandler);
assert.ok(didFinishLoadHandler);
assert.ok(didFailLoadHandler);
assert.ok(closedHandler);
assert.deepEqual(calls.slice(0, 3), ['load-manual', 'set-window', 'opened:yes']);
const onOpen = openHandler as ((params: { url: string }) => { action: 'deny' }) | null;
if (!onOpen) throw new Error('missing window open handler');
assert.deepEqual(onOpen({ url: 'https://anilist.co/settings/developer' }), { action: 'deny' });
assert.ok(calls.includes('open:https://anilist.co/settings/developer'));
const onWillNavigate = willNavigateHandler as
| ((event: { preventDefault: () => void }, url: string) => void)
| null;
if (!onWillNavigate) throw new Error('missing will navigate handler');
onWillNavigate(
{
preventDefault: () => {
prevented = true;
},
},
'https://anilist.subminer.moe/#access_token=abc',
);
assert.equal(prevented, true);
const onDidNavigate = didNavigateHandler as ((event: unknown, url: string) => void) | null;
if (!onDidNavigate) throw new Error('missing did navigate handler');
onDidNavigate({}, 'https://anilist.subminer.moe/#access_token=abc');
const onDidFinishLoad = didFinishLoadHandler as (() => void) | null;
if (!onDidFinishLoad) throw new Error('missing did finish load handler');
onDidFinishLoad();
assert.ok(calls.includes('warn:AniList setup loaded a blank page; using fallback'));
assert.ok(calls.includes('open-browser'));
const onDidFailLoad = didFailLoadHandler as
| ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void)
| null;
if (!onDidFailLoad) throw new Error('missing did fail load handler');
onDidFailLoad({}, -1, 'load failed', 'about:blank');
assert.ok(calls.includes('error:AniList setup window failed to load'));
const onClosed = closedHandler as (() => void) | null;
if (!onClosed) throw new Error('missing closed handler');
onClosed();
assert.ok(calls.includes('clear-window'));
assert.ok(calls.includes('opened:no'));
});

View File

@@ -0,0 +1,323 @@
type SetupWindowLike = {
isDestroyed: () => boolean;
};
type OpenHandlerDecision = { action: 'deny' };
type FocusableWindowLike = {
focus: () => void;
};
type AnilistSetupWebContentsLike = {
setWindowOpenHandler: (...args: any[]) => unknown;
on: (...args: any[]) => unknown;
getURL: () => string;
};
type AnilistSetupWindowLike = FocusableWindowLike & {
webContents: AnilistSetupWebContentsLike;
on: (...args: any[]) => unknown;
isDestroyed: () => boolean;
};
export function createHandleManualAnilistSetupSubmissionHandler(deps: {
consumeCallbackUrl: (rawUrl: string) => boolean;
redirectUri: string;
logWarn: (message: string) => void;
}) {
return (rawUrl: string): boolean => {
if (!rawUrl.startsWith('subminer://anilist-setup')) {
return false;
}
try {
const parsed = new URL(rawUrl);
const accessToken = parsed.searchParams.get('access_token')?.trim() ?? '';
if (accessToken.length > 0) {
return deps.consumeCallbackUrl(
`${deps.redirectUri}#access_token=${encodeURIComponent(accessToken)}`,
);
}
deps.logWarn('AniList setup submission missing access token');
return true;
} catch {
deps.logWarn('AniList setup submission had invalid callback input');
return true;
}
};
}
export function createMaybeFocusExistingAnilistSetupWindowHandler(deps: {
getSetupWindow: () => FocusableWindowLike | null;
}) {
return (): boolean => {
const window = deps.getSetupWindow();
if (!window) {
return false;
}
window.focus();
return true;
};
}
export function createAnilistSetupWindowOpenHandler(deps: {
isAllowedExternalUrl: (url: string) => boolean;
openExternal: (url: string) => void;
logWarn: (message: string, details?: unknown) => void;
}) {
return ({ url }: { url: string }): OpenHandlerDecision => {
if (!deps.isAllowedExternalUrl(url)) {
deps.logWarn('Blocked unsafe AniList setup external URL', { url });
return { action: 'deny' };
}
deps.openExternal(url);
return { action: 'deny' };
};
}
export function createAnilistSetupWillNavigateHandler(deps: {
handleManualSubmission: (url: string) => boolean;
consumeCallbackUrl: (url: string) => boolean;
redirectUri: string;
isAllowedNavigationUrl: (url: string) => boolean;
logWarn: (message: string, details?: unknown) => void;
}) {
return (params: { url: string; preventDefault: () => void }): void => {
const { url, preventDefault } = params;
if (deps.handleManualSubmission(url)) {
preventDefault();
return;
}
if (deps.consumeCallbackUrl(url)) {
preventDefault();
return;
}
if (url.startsWith(deps.redirectUri)) {
preventDefault();
return;
}
if (url.startsWith(`${deps.redirectUri}#`)) {
preventDefault();
return;
}
if (deps.isAllowedNavigationUrl(url)) {
return;
}
preventDefault();
deps.logWarn('Blocked unsafe AniList setup navigation URL', { url });
};
}
export function createAnilistSetupWillRedirectHandler(deps: {
consumeCallbackUrl: (url: string) => boolean;
}) {
return (params: { url: string; preventDefault: () => void }): void => {
if (deps.consumeCallbackUrl(params.url)) {
params.preventDefault();
}
};
}
export function createAnilistSetupDidNavigateHandler(deps: {
consumeCallbackUrl: (url: string) => boolean;
}) {
return (url: string): void => {
deps.consumeCallbackUrl(url);
};
}
export function createAnilistSetupDidFailLoadHandler(deps: {
onLoadFailure: (details: { errorCode: number; errorDescription: string; validatedURL: string }) => void;
}) {
return (details: { errorCode: number; errorDescription: string; validatedURL: string }): void => {
deps.onLoadFailure(details);
};
}
export function createAnilistSetupDidFinishLoadHandler(deps: {
getLoadedUrl: () => string;
onBlankPageLoaded: () => void;
}) {
return (): void => {
const loadedUrl = deps.getLoadedUrl();
if (!loadedUrl || loadedUrl === 'about:blank') {
deps.onBlankPageLoaded();
}
};
}
export function createHandleAnilistSetupWindowClosedHandler(deps: {
clearSetupWindow: () => void;
setSetupPageOpened: (opened: boolean) => void;
}) {
return (): void => {
deps.clearSetupWindow();
deps.setSetupPageOpened(false);
};
}
export function createHandleAnilistSetupWindowOpenedHandler(deps: {
setSetupWindow: () => void;
setSetupPageOpened: (opened: boolean) => void;
}) {
return (): void => {
deps.setSetupWindow();
deps.setSetupPageOpened(true);
};
}
export function createAnilistSetupFallbackHandler(deps: {
authorizeUrl: string;
developerSettingsUrl: string;
setupWindow: SetupWindowLike;
openSetupInBrowser: () => void;
loadManualTokenEntry: () => void;
logError: (message: string, details: unknown) => void;
logWarn: (message: string) => void;
}) {
return {
onLoadFailure: (details: { errorCode: number; errorDescription: string; validatedURL: string }) => {
deps.logError('AniList setup window failed to load', details);
deps.openSetupInBrowser();
if (!deps.setupWindow.isDestroyed()) {
deps.loadManualTokenEntry();
}
},
onBlankPageLoaded: () => {
deps.logWarn('AniList setup loaded a blank page; using fallback');
deps.openSetupInBrowser();
if (!deps.setupWindow.isDestroyed()) {
deps.loadManualTokenEntry();
}
},
};
}
export function createOpenAnilistSetupWindowHandler<TWindow extends AnilistSetupWindowLike>(deps: {
maybeFocusExistingSetupWindow: () => boolean;
createSetupWindow: () => TWindow;
buildAuthorizeUrl: () => string;
consumeCallbackUrl: (rawUrl: string) => boolean;
openSetupInBrowser: (authorizeUrl: string) => void;
loadManualTokenEntry: (setupWindow: TWindow, authorizeUrl: string) => void;
redirectUri: string;
developerSettingsUrl: string;
isAllowedExternalUrl: (url: string) => boolean;
isAllowedNavigationUrl: (url: string) => boolean;
logWarn: (message: string, details?: unknown) => void;
logError: (message: string, details: unknown) => void;
clearSetupWindow: () => void;
setSetupPageOpened: (opened: boolean) => void;
setSetupWindow: (window: TWindow) => void;
openExternal: (url: string) => void;
}) {
return (): void => {
if (deps.maybeFocusExistingSetupWindow()) {
return;
}
const setupWindow = deps.createSetupWindow();
const authorizeUrl = deps.buildAuthorizeUrl();
const consumeCallbackUrl = (rawUrl: string): boolean => deps.consumeCallbackUrl(rawUrl);
const openSetupInBrowser = () => deps.openSetupInBrowser(authorizeUrl);
const loadManualTokenEntry = () => deps.loadManualTokenEntry(setupWindow, authorizeUrl);
const handleManualSubmission = createHandleManualAnilistSetupSubmissionHandler({
consumeCallbackUrl: (rawUrl) => consumeCallbackUrl(rawUrl),
redirectUri: deps.redirectUri,
logWarn: (message) => deps.logWarn(message),
});
const fallback = createAnilistSetupFallbackHandler({
authorizeUrl,
developerSettingsUrl: deps.developerSettingsUrl,
setupWindow,
openSetupInBrowser,
loadManualTokenEntry,
logError: (message, details) => deps.logError(message, details),
logWarn: (message) => deps.logWarn(message),
});
const handleWindowOpen = createAnilistSetupWindowOpenHandler({
isAllowedExternalUrl: (url) => deps.isAllowedExternalUrl(url),
openExternal: (url) => deps.openExternal(url),
logWarn: (message, details) => deps.logWarn(message, details),
});
const handleWillNavigate = createAnilistSetupWillNavigateHandler({
handleManualSubmission: (url) => handleManualSubmission(url),
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
redirectUri: deps.redirectUri,
isAllowedNavigationUrl: (url) => deps.isAllowedNavigationUrl(url),
logWarn: (message, details) => deps.logWarn(message, details),
});
const handleWillRedirect = createAnilistSetupWillRedirectHandler({
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
});
const handleDidNavigate = createAnilistSetupDidNavigateHandler({
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
});
const handleDidFailLoad = createAnilistSetupDidFailLoadHandler({
onLoadFailure: (details) => fallback.onLoadFailure(details),
});
const handleDidFinishLoad = createAnilistSetupDidFinishLoadHandler({
getLoadedUrl: () => setupWindow.webContents.getURL(),
onBlankPageLoaded: () => fallback.onBlankPageLoaded(),
});
const handleWindowClosed = createHandleAnilistSetupWindowClosedHandler({
clearSetupWindow: () => deps.clearSetupWindow(),
setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened),
});
const handleWindowOpened = createHandleAnilistSetupWindowOpenedHandler({
setSetupWindow: () => deps.setSetupWindow(setupWindow),
setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened),
});
setupWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) =>
handleWindowOpen({ url }),
);
setupWindow.webContents.on('will-navigate', (event: unknown, url: string) => {
handleWillNavigate({
url,
preventDefault: () => {
if (event && typeof event === 'object' && 'preventDefault' in event) {
const typedEvent = event as { preventDefault?: () => void };
typedEvent.preventDefault?.();
}
},
});
});
setupWindow.webContents.on('will-redirect', (event: unknown, url: string) => {
handleWillRedirect({
url,
preventDefault: () => {
if (event && typeof event === 'object' && 'preventDefault' in event) {
const typedEvent = event as { preventDefault?: () => void };
typedEvent.preventDefault?.();
}
},
});
});
setupWindow.webContents.on('did-navigate', (_event: unknown, url: string) => {
handleDidNavigate(url);
});
setupWindow.webContents.on(
'did-fail-load',
(
_event: unknown,
errorCode: number,
errorDescription: string,
validatedURL: string,
) => {
handleDidFailLoad({
errorCode,
errorDescription,
validatedURL,
});
},
);
setupWindow.webContents.on('did-finish-load', () => {
handleDidFinishLoad();
});
loadManualTokenEntry();
setupWindow.on('closed', () => {
handleWindowClosed();
});
handleWindowOpened();
};
}

View File

@@ -0,0 +1,148 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildAnilistSetupFallbackHtml,
buildAnilistManualTokenEntryHtml,
buildAnilistSetupUrl,
consumeAnilistSetupCallbackUrl,
extractAnilistAccessTokenFromUrl,
findAnilistSetupDeepLinkArgvUrl,
} from './anilist-setup';
test('buildAnilistSetupUrl includes required query params', () => {
const url = buildAnilistSetupUrl({
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
clientId: '36084',
responseType: 'token',
redirectUri: 'https://anilist.subminer.moe/',
});
assert.match(url, /client_id=36084/);
assert.match(url, /response_type=token/);
assert.match(url, /redirect_uri=https%3A%2F%2Fanilist\.subminer\.moe%2F/);
});
test('buildAnilistSetupUrl omits redirect_uri when unset', () => {
const url = buildAnilistSetupUrl({
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
clientId: '36084',
responseType: 'token',
});
assert.match(url, /client_id=36084/);
assert.match(url, /response_type=token/);
assert.equal(url.includes('redirect_uri='), false);
});
test('buildAnilistSetupFallbackHtml escapes reason content', () => {
const html = buildAnilistSetupFallbackHtml({
reason: '<script>alert(1)</script>',
authorizeUrl: 'https://anilist.example/auth',
developerSettingsUrl: 'https://anilist.example/dev',
});
assert.equal(html.includes('<script>alert(1)</script>'), false);
assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
});
test('buildAnilistManualTokenEntryHtml includes access-token submit route only', () => {
const html = buildAnilistManualTokenEntryHtml({
authorizeUrl: 'https://anilist.example/auth',
developerSettingsUrl: 'https://anilist.example/dev',
});
assert.match(html, /subminer:\/\/anilist-setup\?access_token=/);
assert.equal(html.includes('callback_url='), false);
assert.equal(html.includes('subminer://anilist-setup?code='), false);
});
test('extractAnilistAccessTokenFromUrl returns access token from hash fragment', () => {
const token = extractAnilistAccessTokenFromUrl(
'https://anilist.subminer.moe/#access_token=token-from-hash&token_type=Bearer',
);
assert.equal(token, 'token-from-hash');
});
test('extractAnilistAccessTokenFromUrl returns access token from query', () => {
const token = extractAnilistAccessTokenFromUrl(
'https://anilist.subminer.moe/?access_token=token-from-query&token_type=Bearer',
);
assert.equal(token, 'token-from-query');
});
test('findAnilistSetupDeepLinkArgvUrl finds subminer deep link from argv', () => {
const rawUrl = findAnilistSetupDeepLinkArgvUrl([
'/Applications/SubMiner.app/Contents/MacOS/SubMiner',
'--start',
'subminer://anilist-setup?access_token=argv-token',
]);
assert.equal(rawUrl, 'subminer://anilist-setup?access_token=argv-token');
});
test('findAnilistSetupDeepLinkArgvUrl returns null when missing', () => {
const rawUrl = findAnilistSetupDeepLinkArgvUrl([
'/Applications/SubMiner.app/Contents/MacOS/SubMiner',
'--start',
]);
assert.equal(rawUrl, null);
});
test('consumeAnilistSetupCallbackUrl persists token and closes window for callback URL', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
saveToken: (value: string) => events.push(`save:${value}`),
setCachedToken: (value: string) => events.push(`cache:${value}`),
setResolvedState: (timestampMs: number) =>
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, true);
assert.deepEqual(events, [
'save:saved-token',
'cache:saved-token',
'state:ok',
'opened:false',
'success',
'close',
]);
});
test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
saveToken: (value: string) => events.push(`save:${value}`),
setCachedToken: (value: string) => events.push(`cache:${value}`),
setResolvedState: (timestampMs: number) =>
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, true);
assert.deepEqual(events, [
'save:saved-token',
'cache:saved-token',
'state:ok',
'opened:false',
'success',
'close',
]);
});
test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'https://anilist.co/settings/developer',
saveToken: () => events.push('save'),
setCachedToken: () => events.push('cache'),
setResolvedState: () => events.push('state'),
setSetupPageOpened: () => events.push('opened'),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, false);
assert.deepEqual(events, []);
});

View File

@@ -0,0 +1,177 @@
import type { BrowserWindow } from 'electron';
import type { ResolvedConfig } from '../../types';
export type BuildAnilistSetupUrlDeps = {
authorizeUrl: string;
clientId: string;
responseType: string;
redirectUri?: string;
};
export type ConsumeAnilistSetupCallbackUrlDeps = {
rawUrl: string;
saveToken: (token: string) => void;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
onSuccess: () => void;
closeWindow: () => void;
};
export function isAnilistTrackingEnabled(resolved: ResolvedConfig): boolean {
return resolved.anilist.enabled;
}
export function buildAnilistSetupUrl(params: BuildAnilistSetupUrlDeps): string {
const authorizeUrl = new URL(params.authorizeUrl);
authorizeUrl.searchParams.set('client_id', params.clientId);
authorizeUrl.searchParams.set('response_type', params.responseType);
if (params.redirectUri && params.redirectUri.trim().length > 0) {
authorizeUrl.searchParams.set('redirect_uri', params.redirectUri);
}
return authorizeUrl.toString();
}
export function extractAnilistAccessTokenFromUrl(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl);
const fromQuery = parsed.searchParams.get('access_token')?.trim();
if (fromQuery && fromQuery.length > 0) {
return fromQuery;
}
const hash = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash;
if (hash.length === 0) {
return null;
}
const hashParams = new URLSearchParams(hash);
const fromHash = hashParams.get('access_token')?.trim();
if (fromHash && fromHash.length > 0) {
return fromHash;
}
return null;
} catch {
return null;
}
}
export function findAnilistSetupDeepLinkArgvUrl(argv: readonly string[]): string | null {
for (const value of argv) {
if (value.startsWith('subminer://anilist-setup')) {
return value;
}
}
return null;
}
export function consumeAnilistSetupCallbackUrl(
deps: ConsumeAnilistSetupCallbackUrlDeps,
): boolean {
const token = extractAnilistAccessTokenFromUrl(deps.rawUrl);
if (!token) {
return false;
}
const resolvedAt = Date.now();
deps.saveToken(token);
deps.setCachedToken(token);
deps.setResolvedState(resolvedAt);
deps.setSetupPageOpened(false);
deps.onSuccess();
deps.closeWindow();
return true;
}
export function openAnilistSetupInBrowser(params: {
authorizeUrl: string;
openExternal: (url: string) => Promise<void>;
logError: (message: string, error: unknown) => void;
}): void {
void params.openExternal(params.authorizeUrl).catch((error) => {
params.logError('Failed to open AniList authorize URL in browser', error);
});
}
export function buildAnilistSetupFallbackHtml(params: {
reason: string;
authorizeUrl: string;
developerSettingsUrl: string;
}): string {
const safeReason = params.reason.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const safeAuth = params.authorizeUrl.replace(/"/g, '&quot;');
const safeDev = params.developerSettingsUrl.replace(/"/g, '&quot;');
return `<!doctype html>
<html><head><meta charset="utf-8"><title>AniList Setup</title></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.5;">
<h1>AniList setup</h1>
<p>Automatic page load failed (${safeReason}).</p>
<p><a href="${safeAuth}">Open AniList authorize page</a></p>
<p><a href="${safeDev}">Open AniList developer settings</a></p>
</body></html>`;
}
export function buildAnilistManualTokenEntryHtml(params: {
authorizeUrl: string;
developerSettingsUrl: string;
}): string {
const safeAuth = params.authorizeUrl.replace(/"/g, '&quot;');
const safeDev = params.developerSettingsUrl.replace(/"/g, '&quot;');
return `<!doctype html>
<html><head><meta charset="utf-8"><title>AniList Setup</title></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.5;">
<h1>AniList setup</h1>
<p>Authorize in browser, then paste the access token below.</p>
<p><a href="${safeAuth}" target="_blank" rel="noreferrer">Open AniList authorize page</a></p>
<p><a href="${safeDev}" target="_blank" rel="noreferrer">Open AniList developer settings</a></p>
<form id="token-form">
<label for="token">Access token</label><br />
<input id="token" style="width: 100%; max-width: 760px; margin: 8px 0; padding: 8px;" autocomplete="off" />
<br />
<button type="submit" style="padding: 8px 12px;">Continue</button>
</form>
<script>
const form = document.getElementById('token-form');
const token = document.getElementById('token');
form?.addEventListener('submit', (event) => {
event.preventDefault();
const rawToken = String(token?.value || '').trim();
if (rawToken) {
window.location.href = 'subminer://anilist-setup?access_token=' + encodeURIComponent(rawToken);
}
});
</script>
</body></html>`;
}
export function loadAnilistSetupFallback(params: {
setupWindow: BrowserWindow;
reason: string;
authorizeUrl: string;
developerSettingsUrl: string;
logWarn: (message: string, data: unknown) => void;
}): void {
const html = buildAnilistSetupFallbackHtml({
reason: params.reason,
authorizeUrl: params.authorizeUrl,
developerSettingsUrl: params.developerSettingsUrl,
});
void params.setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
params.logWarn('Loaded AniList setup fallback page', { reason: params.reason });
}
export function loadAnilistManualTokenEntry(params: {
setupWindow: BrowserWindow;
authorizeUrl: string;
developerSettingsUrl: string;
logWarn: (message: string, data: unknown) => void;
}): void {
const html = buildAnilistManualTokenEntryHtml({
authorizeUrl: params.authorizeUrl,
developerSettingsUrl: params.developerSettingsUrl,
});
void params.setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
params.logWarn('Loaded AniList manual token entry page', {
authorizeUrl: params.authorizeUrl,
});
}

View File

@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAnilistStateRuntime } from './anilist-state';
import type { AnilistRetryQueueState, AnilistSecretResolutionState } from '../state';
function createRuntime() {
let clientState: AnilistSecretResolutionState = {
status: 'resolved',
source: 'stored',
message: 'ok' as string | null,
resolvedAt: 1000 as number | null,
errorAt: null as number | null,
};
let queueState: AnilistRetryQueueState = {
pending: 1,
ready: 2,
deadLetter: 3,
lastAttemptAt: 2000 as number | null,
lastError: 'none' as string | null,
};
let clearedStoredToken = false;
let clearedCachedToken = false;
const runtime = createAnilistStateRuntime({
getClientSecretState: () => clientState,
setClientSecretState: (next) => {
clientState = next;
},
getRetryQueueState: () => queueState,
setRetryQueueState: (next) => {
queueState = next;
},
getUpdateQueueSnapshot: () => ({
pending: 7,
ready: 8,
deadLetter: 9,
}),
clearStoredToken: () => {
clearedStoredToken = true;
},
clearCachedAccessToken: () => {
clearedCachedToken = true;
},
});
return {
runtime,
getClientState: () => clientState,
getQueueState: () => queueState,
getClearedStoredToken: () => clearedStoredToken,
getClearedCachedToken: () => clearedCachedToken,
};
}
test('setClientSecretState merges partial updates', () => {
const harness = createRuntime();
harness.runtime.setClientSecretState({
status: 'error',
source: 'none',
errorAt: 4000,
});
assert.deepEqual(harness.getClientState(), {
status: 'error',
source: 'none',
message: 'ok',
resolvedAt: 1000,
errorAt: 4000,
});
});
test('queue refresh preserves metadata while syncing counts', () => {
const harness = createRuntime();
const snapshot = harness.runtime.getQueueStatusSnapshot();
assert.deepEqual(snapshot, {
pending: 7,
ready: 8,
deadLetter: 9,
lastAttemptAt: 2000,
lastError: 'none',
});
assert.deepEqual(harness.getQueueState(), snapshot);
});
test('clearTokenState resets token state and clears caches', () => {
const harness = createRuntime();
const queueBeforeClear = { ...harness.getQueueState() };
harness.runtime.clearTokenState();
assert.equal(harness.getClearedStoredToken(), true);
assert.equal(harness.getClearedCachedToken(), true);
assert.deepEqual(harness.getClientState(), {
status: 'not_checked',
source: 'none',
message: 'stored token cleared',
resolvedAt: null,
errorAt: null,
});
assert.deepEqual(harness.getQueueState(), queueBeforeClear);
});

View File

@@ -0,0 +1,97 @@
import type { AnilistRetryQueueState, AnilistSecretResolutionState } from '../state';
type AnilistQueueSnapshot = Pick<AnilistRetryQueueState, 'pending' | 'ready' | 'deadLetter'>;
type AnilistStatusSnapshot = {
tokenStatus: AnilistSecretResolutionState['status'];
tokenSource: AnilistSecretResolutionState['source'];
tokenMessage: string | null;
tokenResolvedAt: number | null;
tokenErrorAt: number | null;
queuePending: number;
queueReady: number;
queueDeadLetter: number;
queueLastAttemptAt: number | null;
queueLastError: string | null;
};
export type AnilistStateRuntimeDeps = {
getClientSecretState: () => AnilistSecretResolutionState;
setClientSecretState: (next: AnilistSecretResolutionState) => void;
getRetryQueueState: () => AnilistRetryQueueState;
setRetryQueueState: (next: AnilistRetryQueueState) => void;
getUpdateQueueSnapshot: () => AnilistQueueSnapshot;
clearStoredToken: () => void;
clearCachedAccessToken: () => void;
};
export function createAnilistStateRuntime(deps: AnilistStateRuntimeDeps): {
setClientSecretState: (partial: Partial<AnilistSecretResolutionState>) => void;
refreshRetryQueueState: () => void;
getStatusSnapshot: () => AnilistStatusSnapshot;
getQueueStatusSnapshot: () => AnilistRetryQueueState;
clearTokenState: () => void;
} {
const setClientSecretState = (partial: Partial<AnilistSecretResolutionState>): void => {
deps.setClientSecretState({
...deps.getClientSecretState(),
...partial,
});
};
const refreshRetryQueueState = (): void => {
deps.setRetryQueueState({
...deps.getRetryQueueState(),
...deps.getUpdateQueueSnapshot(),
});
};
const getStatusSnapshot = (): AnilistStatusSnapshot => {
const client = deps.getClientSecretState();
const queue = deps.getRetryQueueState();
return {
tokenStatus: client.status,
tokenSource: client.source,
tokenMessage: client.message,
tokenResolvedAt: client.resolvedAt,
tokenErrorAt: client.errorAt,
queuePending: queue.pending,
queueReady: queue.ready,
queueDeadLetter: queue.deadLetter,
queueLastAttemptAt: queue.lastAttemptAt,
queueLastError: queue.lastError,
};
};
const getQueueStatusSnapshot = (): AnilistRetryQueueState => {
refreshRetryQueueState();
const queue = deps.getRetryQueueState();
return {
pending: queue.pending,
ready: queue.ready,
deadLetter: queue.deadLetter,
lastAttemptAt: queue.lastAttemptAt,
lastError: queue.lastError,
};
};
const clearTokenState = (): void => {
deps.clearStoredToken();
deps.clearCachedAccessToken();
setClientSecretState({
status: 'not_checked',
source: 'none',
message: 'stored token cleared',
resolvedAt: null,
errorAt: null,
});
};
return {
setClientSecretState,
refreshRetryQueueState,
getStatusSnapshot,
getQueueStatusSnapshot,
clearTokenState,
};
}

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildRefreshAnilistClientSecretStateMainDepsHandler } from './anilist-token-refresh-main-deps';
test('refresh anilist client secret state main deps builder maps callbacks', () => {
const calls: string[] = [];
const config = { anilist: { accessToken: 'token' } };
const deps = createBuildRefreshAnilistClientSecretStateMainDepsHandler({
getResolvedConfig: () => config as never,
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => 'cached',
setCachedAccessToken: () => calls.push('set-cache'),
saveStoredToken: () => calls.push('save'),
loadStoredToken: () => 'stored',
setClientSecretState: () => calls.push('set-state'),
getAnilistSetupPageOpened: () => false,
setAnilistSetupPageOpened: () => calls.push('set-opened'),
openAnilistSetupWindow: () => calls.push('open-window'),
now: () => 123,
})();
assert.equal(deps.getResolvedConfig(), config);
assert.equal(deps.isAnilistTrackingEnabled(config as never), true);
assert.equal(deps.getCachedAccessToken(), 'cached');
deps.setCachedAccessToken(null);
deps.saveStoredToken('x');
assert.equal(deps.loadStoredToken(), 'stored');
deps.setClientSecretState({} as never);
assert.equal(deps.getAnilistSetupPageOpened(), false);
deps.setAnilistSetupPageOpened(true);
deps.openAnilistSetupWindow();
assert.equal(deps.now(), 123);
assert.deepEqual(calls, ['set-cache', 'save', 'set-state', 'set-opened', 'open-window']);
});

View File

@@ -0,0 +1,23 @@
import type { createRefreshAnilistClientSecretStateHandler } from './anilist-token-refresh';
type RefreshAnilistClientSecretStateMainDeps = Parameters<
typeof createRefreshAnilistClientSecretStateHandler
>[0];
export function createBuildRefreshAnilistClientSecretStateMainDepsHandler(
deps: RefreshAnilistClientSecretStateMainDeps,
) {
return (): RefreshAnilistClientSecretStateMainDeps => ({
getResolvedConfig: () => deps.getResolvedConfig(),
isAnilistTrackingEnabled: (config) => deps.isAnilistTrackingEnabled(config),
getCachedAccessToken: () => deps.getCachedAccessToken(),
setCachedAccessToken: (token) => deps.setCachedAccessToken(token),
saveStoredToken: (token: string) => deps.saveStoredToken(token),
loadStoredToken: () => deps.loadStoredToken(),
setClientSecretState: (state) => deps.setClientSecretState(state),
getAnilistSetupPageOpened: () => deps.getAnilistSetupPageOpened(),
setAnilistSetupPageOpened: (opened: boolean) => deps.setAnilistSetupPageOpened(opened),
openAnilistSetupWindow: () => deps.openAnilistSetupWindow(),
now: () => deps.now(),
});
}

View File

@@ -0,0 +1,113 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRefreshAnilistClientSecretStateHandler } from './anilist-token-refresh';
test('refresh handler marks state not_checked when tracking disabled', async () => {
let cached: string | null = 'abc';
let opened = true;
const states: Array<{ status: string; source: string }> = [];
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
isAnilistTrackingEnabled: () => false,
getCachedAccessToken: () => cached,
setCachedAccessToken: (token) => {
cached = token;
},
saveStoredToken: () => {},
loadStoredToken: () => '',
setClientSecretState: (state) => {
states.push({ status: state.status, source: state.source });
},
getAnilistSetupPageOpened: () => opened,
setAnilistSetupPageOpened: (next) => {
opened = next;
},
openAnilistSetupWindow: () => {},
now: () => 100,
});
const token = await refresh();
assert.equal(token, null);
assert.equal(cached, null);
assert.equal(opened, false);
assert.deepEqual(states, [{ status: 'not_checked', source: 'none' }]);
});
test('refresh handler uses literal config token and stores it', async () => {
let cached: string | null = null;
const saves: string[] = [];
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: ' token-1 ' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => cached,
setCachedAccessToken: (token) => {
cached = token;
},
saveStoredToken: (token) => saves.push(token),
loadStoredToken: () => '',
setClientSecretState: () => {},
getAnilistSetupPageOpened: () => false,
setAnilistSetupPageOpened: () => {},
openAnilistSetupWindow: () => {},
now: () => 200,
});
const token = await refresh({ force: true });
assert.equal(token, 'token-1');
assert.equal(cached, 'token-1');
assert.deepEqual(saves, ['token-1']);
});
test('refresh handler prefers cached token when not forced', async () => {
let loadCalls = 0;
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => 'cached-token',
setCachedAccessToken: () => {},
saveStoredToken: () => {},
loadStoredToken: () => {
loadCalls += 1;
return 'stored-token';
},
setClientSecretState: () => {},
getAnilistSetupPageOpened: () => false,
setAnilistSetupPageOpened: () => {},
openAnilistSetupWindow: () => {},
now: () => 300,
});
const token = await refresh();
assert.equal(token, 'cached-token');
assert.equal(loadCalls, 0);
});
test('refresh handler falls back to stored token then opens setup when missing', async () => {
let cached: string | null = null;
let opened = false;
let openCalls = 0;
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => cached,
setCachedAccessToken: (token) => {
cached = token;
},
saveStoredToken: () => {},
loadStoredToken: () => '',
setClientSecretState: () => {},
getAnilistSetupPageOpened: () => opened,
setAnilistSetupPageOpened: (next) => {
opened = next;
},
openAnilistSetupWindow: () => {
openCalls += 1;
},
now: () => 400,
});
const token = await refresh({ force: true });
assert.equal(token, null);
assert.equal(cached, null);
assert.equal(openCalls, 1);
});

View File

@@ -0,0 +1,93 @@
type AnilistSecretResolutionState = {
status: 'not_checked' | 'resolved' | 'error';
source: 'none' | 'literal' | 'stored';
message: string | null;
resolvedAt: number | null;
errorAt: number | null;
};
type ConfigWithAnilistToken = {
anilist: {
accessToken: string;
};
};
export function createRefreshAnilistClientSecretStateHandler<TConfig extends ConfigWithAnilistToken>(deps: {
getResolvedConfig: () => TConfig;
isAnilistTrackingEnabled: (config: TConfig) => boolean;
getCachedAccessToken: () => string | null;
setCachedAccessToken: (token: string | null) => void;
saveStoredToken: (token: string) => void;
loadStoredToken: () => string | null | undefined;
setClientSecretState: (state: AnilistSecretResolutionState) => void;
getAnilistSetupPageOpened: () => boolean;
setAnilistSetupPageOpened: (opened: boolean) => void;
openAnilistSetupWindow: () => void;
now: () => number;
}) {
return async (options?: { force?: boolean }): Promise<string | null> => {
const resolved = deps.getResolvedConfig();
const now = deps.now();
if (!deps.isAnilistTrackingEnabled(resolved)) {
deps.setCachedAccessToken(null);
deps.setClientSecretState({
status: 'not_checked',
source: 'none',
message: 'anilist tracking disabled',
resolvedAt: null,
errorAt: null,
});
deps.setAnilistSetupPageOpened(false);
return null;
}
const rawAccessToken = resolved.anilist.accessToken.trim();
if (rawAccessToken.length > 0) {
if (options?.force || rawAccessToken !== deps.getCachedAccessToken()) {
deps.saveStoredToken(rawAccessToken);
}
deps.setCachedAccessToken(rawAccessToken);
deps.setClientSecretState({
status: 'resolved',
source: 'literal',
message: 'using configured anilist.accessToken',
resolvedAt: now,
errorAt: null,
});
deps.setAnilistSetupPageOpened(false);
return rawAccessToken;
}
const cachedAccessToken = deps.getCachedAccessToken();
if (!options?.force && cachedAccessToken && cachedAccessToken.length > 0) {
return cachedAccessToken;
}
const storedToken = deps.loadStoredToken()?.trim() ?? '';
if (storedToken.length > 0) {
deps.setCachedAccessToken(storedToken);
deps.setClientSecretState({
status: 'resolved',
source: 'stored',
message: 'using stored anilist access token',
resolvedAt: now,
errorAt: null,
});
deps.setAnilistSetupPageOpened(false);
return storedToken;
}
deps.setCachedAccessToken(null);
deps.setClientSecretState({
status: 'error',
source: 'none',
message: 'cannot authenticate without anilist.accessToken',
resolvedAt: null,
errorAt: now,
});
if (deps.isAnilistTrackingEnabled(resolved) && !deps.getAnilistSetupPageOpened()) {
deps.openAnilistSetupWindow();
}
return null;
};
}

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildMarkLastCardAsAudioCardMainDepsHandler,
createBuildMineSentenceCardMainDepsHandler,
createBuildRefreshKnownWordCacheMainDepsHandler,
createBuildTriggerFieldGroupingMainDepsHandler,
createBuildUpdateLastCardFromClipboardMainDepsHandler,
} from './anki-actions-main-deps';
test('anki action main deps builders map callbacks', async () => {
const calls: string[] = [];
const update = createBuildUpdateLastCardFromClipboardMainDepsHandler({
getAnkiIntegration: () => ({ enabled: true }),
readClipboardText: () => 'clip',
showMpvOsd: (text) => calls.push(`osd:${text}`),
updateLastCardFromClipboardCore: async () => {
calls.push('update');
},
})();
assert.deepEqual(update.getAnkiIntegration(), { enabled: true });
assert.equal(update.readClipboardText(), 'clip');
update.showMpvOsd('x');
await update.updateLastCardFromClipboardCore({
ankiIntegration: { enabled: true },
readClipboardText: () => '',
showMpvOsd: () => {},
});
const refresh = createBuildRefreshKnownWordCacheMainDepsHandler({
getAnkiIntegration: () => null,
missingIntegrationMessage: 'missing',
})();
assert.equal(refresh.getAnkiIntegration(), null);
assert.equal(refresh.missingIntegrationMessage, 'missing');
const fieldGrouping = createBuildTriggerFieldGroupingMainDepsHandler({
getAnkiIntegration: () => ({ enabled: true }),
showMpvOsd: (text) => calls.push(`fg:${text}`),
triggerFieldGroupingCore: async () => {
calls.push('trigger');
},
})();
fieldGrouping.showMpvOsd('fg');
await fieldGrouping.triggerFieldGroupingCore({
ankiIntegration: { enabled: true },
showMpvOsd: () => {},
});
const markAudio = createBuildMarkLastCardAsAudioCardMainDepsHandler({
getAnkiIntegration: () => ({ enabled: true }),
showMpvOsd: (text) => calls.push(`audio:${text}`),
markLastCardAsAudioCardCore: async () => {
calls.push('mark');
},
})();
markAudio.showMpvOsd('a');
await markAudio.markLastCardAsAudioCardCore({
ankiIntegration: { enabled: true },
showMpvOsd: () => {},
});
const mine = createBuildMineSentenceCardMainDepsHandler({
getAnkiIntegration: () => ({ enabled: true }),
getMpvClient: () => ({ connected: true }),
showMpvOsd: (text) => calls.push(`mine:${text}`),
mineSentenceCardCore: async () => true,
recordCardsMined: (count) => calls.push(`cards:${count}`),
})();
assert.deepEqual(mine.getMpvClient(), { connected: true });
mine.showMpvOsd('m');
await mine.mineSentenceCardCore({
ankiIntegration: { enabled: true },
mpvClient: { connected: true },
showMpvOsd: () => {},
});
mine.recordCardsMined(1);
assert.deepEqual(calls, [
'osd:x',
'update',
'fg:fg',
'trigger',
'audio:a',
'mark',
'mine:m',
'cards:1',
]);
});

View File

@@ -0,0 +1,88 @@
import type { createRefreshKnownWordCacheHandler } from './anki-actions';
type RefreshKnownWordCacheMainDeps = Parameters<typeof createRefreshKnownWordCacheHandler>[0];
export function createBuildUpdateLastCardFromClipboardMainDepsHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
updateLastCardFromClipboardCore: (options: {
ankiIntegration: TAnki;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return () => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
readClipboardText: () => deps.readClipboardText(),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
updateLastCardFromClipboardCore: (options: {
ankiIntegration: TAnki;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
}) => deps.updateLastCardFromClipboardCore(options),
});
}
export function createBuildRefreshKnownWordCacheMainDepsHandler(deps: RefreshKnownWordCacheMainDeps) {
return (): RefreshKnownWordCacheMainDeps => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
missingIntegrationMessage: deps.missingIntegrationMessage,
});
}
export function createBuildTriggerFieldGroupingMainDepsHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
showMpvOsd: (text: string) => void;
triggerFieldGroupingCore: (options: {
ankiIntegration: TAnki;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return () => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
triggerFieldGroupingCore: (options: { ankiIntegration: TAnki; showMpvOsd: (text: string) => void }) =>
deps.triggerFieldGroupingCore(options),
});
}
export function createBuildMarkLastCardAsAudioCardMainDepsHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
showMpvOsd: (text: string) => void;
markLastCardAsAudioCardCore: (options: {
ankiIntegration: TAnki;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return () => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
markLastCardAsAudioCardCore: (options: { ankiIntegration: TAnki; showMpvOsd: (text: string) => void }) =>
deps.markLastCardAsAudioCardCore(options),
});
}
export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
getAnkiIntegration: () => TAnki;
getMpvClient: () => TMpv;
showMpvOsd: (text: string) => void;
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number) => void;
}) {
return () => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
getMpvClient: () => deps.getMpvClient(),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
showMpvOsd: (text: string) => void;
}) => deps.mineSentenceCardCore(options),
recordCardsMined: (count: number) => deps.recordCardsMined(count),
});
}

View File

@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createMarkLastCardAsAudioCardHandler,
createMineSentenceCardHandler,
createRefreshKnownWordCacheHandler,
createTriggerFieldGroupingHandler,
createUpdateLastCardFromClipboardHandler,
} from './anki-actions';
test('update last card handler forwards integration/clipboard/osd deps', async () => {
const calls: string[] = [];
const integration = {};
const updateLastCard = createUpdateLastCardFromClipboardHandler({
getAnkiIntegration: () => integration,
readClipboardText: () => 'clipboard-value',
showMpvOsd: (text) => calls.push(`osd:${text}`),
updateLastCardFromClipboardCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
assert.equal(options.readClipboardText(), 'clipboard-value');
options.showMpvOsd('ok');
calls.push('core');
},
});
await updateLastCard();
assert.deepEqual(calls, ['osd:ok', 'core']);
});
test('refresh known word cache handler throws when Anki integration missing', async () => {
const refresh = createRefreshKnownWordCacheHandler({
getAnkiIntegration: () => null,
missingIntegrationMessage: 'AnkiConnect integration not enabled',
});
await assert.rejects(() => refresh(), /AnkiConnect integration not enabled/);
});
test('trigger and mark handlers delegate to core services', async () => {
const calls: string[] = [];
const integration = {};
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
getAnkiIntegration: () => integration,
showMpvOsd: (text) => calls.push(`osd:${text}`),
triggerFieldGroupingCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
options.showMpvOsd('group');
calls.push('group-core');
},
});
const markAudio = createMarkLastCardAsAudioCardHandler({
getAnkiIntegration: () => integration,
showMpvOsd: (text) => calls.push(`osd:${text}`),
markLastCardAsAudioCardCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
options.showMpvOsd('mark');
calls.push('mark-core');
},
});
await triggerFieldGrouping();
await markAudio();
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
});
test('mine sentence handler records mined cards only when core returns true', async () => {
const calls: string[] = [];
const integration = {};
const mpvClient = {};
let created = false;
const mineSentenceCard = createMineSentenceCardHandler({
getAnkiIntegration: () => integration,
getMpvClient: () => mpvClient,
showMpvOsd: (text) => calls.push(`osd:${text}`),
mineSentenceCardCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
assert.equal(options.mpvClient, mpvClient);
options.showMpvOsd('mine');
return created;
},
recordCardsMined: (count) => calls.push(`cards:${count}`),
});
created = false;
await mineSentenceCard();
created = true;
await mineSentenceCard();
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
});

View File

@@ -0,0 +1,90 @@
type AnkiIntegrationLike = {
refreshKnownWordCache: () => Promise<void>;
};
export function createUpdateLastCardFromClipboardHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
updateLastCardFromClipboardCore: (options: {
ankiIntegration: TAnki;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return async (): Promise<void> => {
await deps.updateLastCardFromClipboardCore({
ankiIntegration: deps.getAnkiIntegration(),
readClipboardText: deps.readClipboardText,
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createRefreshKnownWordCacheHandler(deps: {
getAnkiIntegration: () => AnkiIntegrationLike | null;
missingIntegrationMessage: string;
}) {
return async (): Promise<void> => {
const anki = deps.getAnkiIntegration();
if (!anki) {
throw new Error(deps.missingIntegrationMessage);
}
await anki.refreshKnownWordCache();
};
}
export function createTriggerFieldGroupingHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
showMpvOsd: (text: string) => void;
triggerFieldGroupingCore: (options: {
ankiIntegration: TAnki;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return async (): Promise<void> => {
await deps.triggerFieldGroupingCore({
ankiIntegration: deps.getAnkiIntegration(),
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createMarkLastCardAsAudioCardHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
showMpvOsd: (text: string) => void;
markLastCardAsAudioCardCore: (options: {
ankiIntegration: TAnki;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return async (): Promise<void> => {
await deps.markLastCardAsAudioCardCore({
ankiIntegration: deps.getAnkiIntegration(),
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
getAnkiIntegration: () => TAnki;
getMpvClient: () => TMpv;
showMpvOsd: (text: string) => void;
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number) => void;
}) {
return async (): Promise<void> => {
const created = await deps.mineSentenceCardCore({
ankiIntegration: deps.getAnkiIntegration(),
mpvClient: deps.getMpvClient(),
showMpvOsd: deps.showMpvOsd,
});
if (created) {
deps.recordCardsMined(1);
}
};
}

View File

@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createOnWillQuitCleanupHandler,
createRestoreWindowsOnActivateHandler,
createShouldRestoreWindowsOnActivateHandler,
} from './app-lifecycle-actions';
test('on will quit cleanup handler runs all cleanup steps', () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
stopWindowTracker: () => calls.push('stop-tracker'),
flushMpvLog: () => calls.push('flush-mpv-log'),
destroyMpvSocket: () => calls.push('destroy-socket'),
clearReconnectTimer: () => calls.push('clear-reconnect'),
destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'),
destroyImmersionTracker: () => calls.push('destroy-immersion'),
destroyAnkiIntegration: () => calls.push('destroy-anki'),
destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'),
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
cleanup();
assert.equal(calls.length, 21);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});
test('should restore windows on activate requires initialized runtime and no windows', () => {
let initialized = false;
let windowCount = 1;
const shouldRestore = createShouldRestoreWindowsOnActivateHandler({
isOverlayRuntimeInitialized: () => initialized,
getAllWindowCount: () => windowCount,
});
assert.equal(shouldRestore(), false);
initialized = true;
assert.equal(shouldRestore(), false);
windowCount = 0;
assert.equal(shouldRestore(), true);
});
test('restore windows on activate recreates windows then syncs visibility', () => {
const calls: string[] = [];
const restore = createRestoreWindowsOnActivateHandler({
createMainWindow: () => calls.push('main'),
createInvisibleWindow: () => calls.push('invisible'),
updateVisibleOverlayVisibility: () => calls.push('visible-sync'),
updateInvisibleOverlayVisibility: () => calls.push('invisible-sync'),
});
restore();
assert.deepEqual(calls, ['main', 'invisible', 'visible-sync', 'invisible-sync']);
});

View File

@@ -0,0 +1,68 @@
export function createOnWillQuitCleanupHandler(deps: {
destroyTray: () => void;
stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void;
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
destroyYomitanParserWindow: () => void;
clearYomitanParserState: () => void;
stopWindowTracker: () => void;
flushMpvLog: () => void;
destroyMpvSocket: () => void;
clearReconnectTimer: () => void;
destroySubtitleTimingTracker: () => void;
destroyImmersionTracker: () => void;
destroyAnkiIntegration: () => void;
destroyAnilistSetupWindow: () => void;
clearAnilistSetupWindow: () => void;
destroyJellyfinSetupWindow: () => void;
clearJellyfinSetupWindow: () => void;
stopJellyfinRemoteSession: () => void;
stopDiscordPresenceService: () => void;
}) {
return (): void => {
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
deps.destroyYomitanParserWindow();
deps.clearYomitanParserState();
deps.stopWindowTracker();
deps.flushMpvLog();
deps.destroyMpvSocket();
deps.clearReconnectTimer();
deps.destroySubtitleTimingTracker();
deps.destroyImmersionTracker();
deps.destroyAnkiIntegration();
deps.destroyAnilistSetupWindow();
deps.clearAnilistSetupWindow();
deps.destroyJellyfinSetupWindow();
deps.clearJellyfinSetupWindow();
deps.stopJellyfinRemoteSession();
deps.stopDiscordPresenceService();
};
}
export function createShouldRestoreWindowsOnActivateHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
getAllWindowCount: () => number;
}) {
return (): boolean => deps.isOverlayRuntimeInitialized() && deps.getAllWindowCount() === 0;
}
export function createRestoreWindowsOnActivateHandler(deps: {
createMainWindow: () => void;
createInvisibleWindow: () => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
}) {
return (): void => {
deps.createMainWindow();
deps.createInvisibleWindow();
deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility();
};
}

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildRestoreWindowsOnActivateMainDepsHandler,
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
} from './app-lifecycle-main-activate';
test('should restore windows on activate deps builder maps visibility state checks', () => {
const deps = createBuildShouldRestoreWindowsOnActivateMainDepsHandler({
isOverlayRuntimeInitialized: () => true,
getAllWindowCount: () => 0,
})();
assert.equal(deps.isOverlayRuntimeInitialized(), true);
assert.equal(deps.getAllWindowCount(), 0);
});
test('restore windows on activate deps builder maps all restoration callbacks', () => {
const calls: string[] = [];
const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({
createMainWindow: () => calls.push('main'),
createInvisibleWindow: () => calls.push('invisible'),
updateVisibleOverlayVisibility: () => calls.push('visible'),
updateInvisibleOverlayVisibility: () => calls.push('invisible-visible'),
})();
deps.createMainWindow();
deps.createInvisibleWindow();
deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility();
assert.deepEqual(calls, ['main', 'invisible', 'visible', 'invisible-visible']);
});

View File

@@ -0,0 +1,23 @@
export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
getAllWindowCount: () => number;
}) {
return () => ({
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
getAllWindowCount: () => deps.getAllWindowCount(),
});
}
export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: {
createMainWindow: () => void;
createInvisibleWindow: () => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
}) {
return () => ({
createMainWindow: () => deps.createMainWindow(),
createInvisibleWindow: () => deps.createInvisibleWindow(),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(),
});
}

View File

@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup';
import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
test('cleanup deps builder returns handlers that guard optional runtime objects', () => {
const calls: string[] = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 60_000);
let immersionTracker: { destroy: () => void } | null = {
destroy: () => calls.push('destroy-immersion'),
};
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
getYomitanParserWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-yomitan-window'),
}),
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
getWindowTracker: () => ({ stop: () => calls.push('stop-tracker') }),
flushMpvLog: () => calls.push('flush-mpv-log'),
getMpvSocket: () => ({ destroy: () => calls.push('destroy-socket') }),
getReconnectTimer: () => reconnectTimer,
clearReconnectTimerRef: () => {
reconnectTimer = null;
calls.push('clear-reconnect-ref');
},
getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }),
getImmersionTracker: () => immersionTracker,
clearImmersionTracker: () => {
immersionTracker = null;
calls.push('clear-immersion-ref');
},
getAnkiIntegration: () => ({ destroy: () => calls.push('destroy-anki') }),
getAnilistSetupWindow: () => ({ destroy: () => calls.push('destroy-anilist-window') }),
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
cleanup();
assert.ok(calls.includes('destroy-tray'));
assert.ok(calls.includes('destroy-yomitan-window'));
assert.ok(calls.includes('flush-mpv-log'));
assert.ok(calls.includes('destroy-socket'));
assert.ok(calls.includes('clear-reconnect-ref'));
assert.ok(calls.includes('destroy-immersion'));
assert.ok(calls.includes('clear-immersion-ref'));
assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('stop-discord-presence'));
assert.equal(reconnectTimer, null);
assert.equal(immersionTracker, null);
});
test('cleanup deps builder skips destroyed yomitan window', () => {
const calls: string[] = [];
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
getYomitanParserWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-yomitan-window'),
}),
clearYomitanParserState: () => {},
getWindowTracker: () => null,
flushMpvLog: () => {},
getMpvSocket: () => null,
getReconnectTimer: () => null,
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
stopJellyfinRemoteSession: () => {},
stopDiscordPresenceService: () => {},
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
cleanup();
assert.deepEqual(calls, []);
});

View File

@@ -0,0 +1,102 @@
// Narrow structural types used by cleanup assembly.
type Destroyable = {
destroy: () => void;
};
type DestroyableWindow = Destroyable & {
isDestroyed: () => boolean;
};
type Stoppable = {
stop: () => void;
};
type SocketLike = {
destroy: () => void;
};
type TimerLike = ReturnType<typeof setTimeout>;
export function createBuildOnWillQuitCleanupDepsHandler(deps: {
destroyTray: () => void;
stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void;
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
getYomitanParserWindow: () => DestroyableWindow | null;
clearYomitanParserState: () => void;
getWindowTracker: () => Stoppable | null;
flushMpvLog: () => void;
getMpvSocket: () => SocketLike | null;
getReconnectTimer: () => TimerLike | null;
clearReconnectTimerRef: () => void;
getSubtitleTimingTracker: () => Destroyable | null;
getImmersionTracker: () => Destroyable | null;
clearImmersionTracker: () => void;
getAnkiIntegration: () => Destroyable | null;
getAnilistSetupWindow: () => Destroyable | null;
clearAnilistSetupWindow: () => void;
getJellyfinSetupWindow: () => Destroyable | null;
clearJellyfinSetupWindow: () => void;
stopJellyfinRemoteSession: () => void;
stopDiscordPresenceService: () => void;
}) {
return () => ({
destroyTray: () => deps.destroyTray(),
stopConfigHotReload: () => deps.stopConfigHotReload(),
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),
destroyYomitanParserWindow: () => {
const window = deps.getYomitanParserWindow();
if (!window) return;
if (window.isDestroyed()) return;
window.destroy();
},
clearYomitanParserState: () => deps.clearYomitanParserState(),
stopWindowTracker: () => {
const tracker = deps.getWindowTracker();
tracker?.stop();
},
flushMpvLog: () => deps.flushMpvLog(),
destroyMpvSocket: () => {
const socket = deps.getMpvSocket();
socket?.destroy();
},
clearReconnectTimer: () => {
const timer = deps.getReconnectTimer();
if (!timer) return;
clearTimeout(timer);
deps.clearReconnectTimerRef();
},
destroySubtitleTimingTracker: () => {
deps.getSubtitleTimingTracker()?.destroy();
},
destroyImmersionTracker: () => {
const tracker = deps.getImmersionTracker();
if (!tracker) return;
tracker.destroy();
deps.clearImmersionTracker();
},
destroyAnkiIntegration: () => {
deps.getAnkiIntegration()?.destroy();
},
destroyAnilistSetupWindow: () => {
deps.getAnilistSetupWindow()?.destroy();
},
clearAnilistSetupWindow: () => deps.clearAnilistSetupWindow(),
destroyJellyfinSetupWindow: () => {
deps.getJellyfinSetupWindow()?.destroy();
},
clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});
}

View File

@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildAppReadyRuntimeMainDepsHandler } from './app-ready-main-deps';
test('app-ready main deps builder returns mapped app-ready runtime deps', async () => {
const calls: string[] = [];
const onReady = createBuildAppReadyRuntimeMainDepsHandler({
loadSubtitlePosition: () => calls.push('load-subtitle-position'),
resolveKeybindings: () => calls.push('resolve-keybindings'),
createMpvClient: () => calls.push('create-mpv-client'),
reloadConfig: () => calls.push('reload-config'),
getResolvedConfig: () => ({ websocket: {} }),
getConfigWarnings: () => [],
logConfigWarning: () => calls.push('log-config-warning'),
initRuntimeOptionsManager: () => calls.push('init-runtime-options'),
setSecondarySubMode: () => calls.push('set-secondary-sub-mode'),
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => calls.push('start-ws'),
log: () => calls.push('log'),
setLogLevel: () => calls.push('set-log-level'),
createMecabTokenizerAndCheck: async () => {
calls.push('create-mecab');
},
createSubtitleTimingTracker: () => calls.push('create-subtitle-tracker'),
createImmersionTracker: () => calls.push('create-immersion'),
startJellyfinRemoteSession: async () => {
calls.push('start-jellyfin');
},
loadYomitanExtension: async () => {
calls.push('load-yomitan');
},
prewarmSubtitleDictionaries: async () => {
calls.push('prewarm-dicts');
},
startBackgroundWarmups: () => calls.push('start-warmups'),
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
initializeOverlayRuntime: () => calls.push('init-overlay'),
handleInitialArgs: () => calls.push('handle-initial-args'),
onCriticalConfigErrors: () => {
throw new Error('should not call');
},
logDebug: () => calls.push('debug'),
now: () => 123,
})();
assert.equal(onReady.defaultSecondarySubMode, 'hover');
assert.equal(onReady.defaultWebsocketPort, 5174);
assert.equal(onReady.texthookerOnlyMode, false);
assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true);
assert.equal(onReady.now?.(), 123);
onReady.loadSubtitlePosition();
onReady.resolveKeybindings();
onReady.createMpvClient();
await onReady.createMecabTokenizerAndCheck();
await onReady.loadYomitanExtension();
await onReady.prewarmSubtitleDictionaries?.();
onReady.startBackgroundWarmups();
assert.deepEqual(calls, [
'load-subtitle-position',
'resolve-keybindings',
'create-mpv-client',
'create-mecab',
'load-yomitan',
'prewarm-dicts',
'start-warmups',
]);
});

View File

@@ -0,0 +1,38 @@
import type { AppReadyRuntimeDepsFactoryInput } from '../app-lifecycle';
export function createBuildAppReadyRuntimeMainDepsHandler(
deps: AppReadyRuntimeDepsFactoryInput,
) {
return (): AppReadyRuntimeDepsFactoryInput => ({
loadSubtitlePosition: deps.loadSubtitlePosition,
resolveKeybindings: deps.resolveKeybindings,
createMpvClient: deps.createMpvClient,
reloadConfig: deps.reloadConfig,
getResolvedConfig: deps.getResolvedConfig,
getConfigWarnings: deps.getConfigWarnings,
logConfigWarning: deps.logConfigWarning,
initRuntimeOptionsManager: deps.initRuntimeOptionsManager,
setSecondarySubMode: deps.setSecondarySubMode,
defaultSecondarySubMode: deps.defaultSecondarySubMode,
defaultWebsocketPort: deps.defaultWebsocketPort,
hasMpvWebsocketPlugin: deps.hasMpvWebsocketPlugin,
startSubtitleWebsocket: deps.startSubtitleWebsocket,
log: deps.log,
setLogLevel: deps.setLogLevel,
createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck,
createSubtitleTimingTracker: deps.createSubtitleTimingTracker,
createImmersionTracker: deps.createImmersionTracker,
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
loadYomitanExtension: deps.loadYomitanExtension,
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
startBackgroundWarmups: deps.startBackgroundWarmups,
texthookerOnlyMode: deps.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig:
deps.shouldAutoInitializeOverlayRuntimeFromConfig,
initializeOverlayRuntime: deps.initializeOverlayRuntime,
handleInitialArgs: deps.handleInitialArgs,
onCriticalConfigErrors: deps.onCriticalConfigErrors,
logDebug: deps.logDebug,
now: deps.now,
});
}

View File

@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildDestroyTrayMainDepsHandler,
createBuildEnsureTrayMainDepsHandler,
createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler,
createBuildOpenYomitanSettingsMainDepsHandler,
} from './app-runtime-main-deps';
test('ensure tray main deps trigger overlay bootstrap on tray click when runtime not initialized', () => {
const calls: string[] = [];
const deps = createBuildEnsureTrayMainDepsHandler({
getTray: () => null,
setTray: () => calls.push('set-tray'),
buildTrayMenu: () => ({}),
resolveTrayIconPath: () => null,
createImageFromPath: () => ({}),
createEmptyImage: () => ({}),
createTray: () => ({}),
trayTooltip: 'SubMiner',
platform: 'darwin',
logWarn: (message) => calls.push(`warn:${message}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
isOverlayRuntimeInitialized: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
})();
deps.ensureOverlayVisibleFromTrayClick();
assert.deepEqual(calls, ['init-overlay', 'set-visible:true']);
});
test('destroy tray main deps map passthrough getters/setters', () => {
let tray: unknown = { id: 'tray' };
const deps = createBuildDestroyTrayMainDepsHandler({
getTray: () => tray,
setTray: (next) => {
tray = next;
},
})();
assert.deepEqual(deps.getTray(), { id: 'tray' });
deps.setTray(null);
assert.equal(tray, null);
});
test('initialize overlay runtime main deps map build options and callbacks', () => {
const calls: string[] = [];
const options = { id: 'opts' };
const deps = createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntimeCore: (value) => {
calls.push(`core:${JSON.stringify(value)}`);
return { invisibleOverlayVisible: true };
},
buildOptions: () => options,
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`),
startBackgroundWarmups: () => calls.push('warmups'),
})();
assert.equal(deps.isOverlayRuntimeInitialized(), false);
assert.equal(deps.buildOptions(), options);
assert.deepEqual(deps.initializeOverlayRuntimeCore(options), { invisibleOverlayVisible: true });
deps.setInvisibleOverlayVisible(true);
deps.setOverlayRuntimeInitialized(true);
deps.startBackgroundWarmups();
assert.deepEqual(calls, [
'core:{"id":"opts"}',
'set-invisible:true',
'set-initialized:true',
'warmups',
]);
});
test('open yomitan settings main deps map async open callbacks', async () => {
const calls: string[] = [];
let currentWindow: unknown = null;
const extension = { id: 'ext' };
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
ensureYomitanExtensionLoaded: async () => extension,
openYomitanSettingsWindow: ({ yomitanExt }) => calls.push(`open:${(yomitanExt as { id: string }).id}`),
getExistingWindow: () => currentWindow,
setWindow: (window) => {
currentWindow = window;
calls.push('set-window');
},
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
})();
assert.equal(await deps.ensureYomitanExtensionLoaded(), extension);
assert.equal(deps.getExistingWindow(), null);
deps.setWindow({ id: 'win' });
deps.openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window) => deps.setWindow(window),
});
deps.logWarn('warn');
deps.logError('error', new Error('boom'));
assert.deepEqual(calls, ['set-window', 'open:ext', 'warn:warn', 'error:error']);
assert.deepEqual(currentWindow, { id: 'win' });
});

View File

@@ -0,0 +1,89 @@
export function createBuildEnsureTrayMainDepsHandler<TTray, TTrayMenu, TTrayIcon>(deps: {
getTray: () => TTray | null;
setTray: (tray: TTray | null) => void;
buildTrayMenu: () => TTrayMenu;
resolveTrayIconPath: () => string | null;
createImageFromPath: (iconPath: string) => TTrayIcon;
createEmptyImage: () => TTrayIcon;
createTray: (icon: TTrayIcon) => TTray;
trayTooltip: string;
platform: string;
logWarn: (message: string) => void;
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
}) {
return () => ({
getTray: () => deps.getTray(),
setTray: (tray: TTray | null) => deps.setTray(tray),
buildTrayMenu: () => deps.buildTrayMenu(),
resolveTrayIconPath: () => deps.resolveTrayIconPath(),
createImageFromPath: (iconPath: string) => deps.createImageFromPath(iconPath),
createEmptyImage: () => deps.createEmptyImage(),
createTray: (icon: TTrayIcon) => deps.createTray(icon),
trayTooltip: deps.trayTooltip,
platform: deps.platform,
logWarn: (message: string) => deps.logWarn(message),
ensureOverlayVisibleFromTrayClick: () => {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
deps.setVisibleOverlayVisible(true);
},
});
}
export function createBuildDestroyTrayMainDepsHandler<TTray>(deps: {
getTray: () => TTray | null;
setTray: (tray: TTray | null) => void;
}) {
return () => ({
getTray: () => deps.getTray(),
setTray: (tray: TTray | null) => deps.setTray(tray),
});
}
export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOptions>(deps: {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntimeCore: (options: TOptions) => { invisibleOverlayVisible: boolean };
buildOptions: () => TOptions;
setInvisibleOverlayVisible: (visible: boolean) => void;
setOverlayRuntimeInitialized: (initialized: boolean) => void;
startBackgroundWarmups: () => void;
}) {
return () => ({
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
initializeOverlayRuntimeCore: (options: TOptions) => deps.initializeOverlayRuntimeCore(options),
buildOptions: () => deps.buildOptions(),
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
setOverlayRuntimeInitialized: (initialized: boolean) =>
deps.setOverlayRuntimeInitialized(initialized),
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
});
}
export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWindow>(deps: {
ensureYomitanExtensionLoaded: () => Promise<TYomitanExt | null>;
openYomitanSettingsWindow: (params: {
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
}) => void;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void;
}) {
return () => ({
ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(),
openYomitanSettingsWindow: (params: {
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
}) => deps.openYomitanSettingsWindow(params),
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window: TWindow | null) => deps.setWindow(window),
logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, error: unknown) => deps.logError(message, error),
});
}

View File

@@ -0,0 +1,83 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps';
test('build cli command context deps maps handlers and values', () => {
const calls: string[] = [];
const buildDeps = createBuildCliCommandContextDepsHandler({
getSocketPath: () => '/tmp/mpv.sock',
setSocketPath: (socketPath) => calls.push(`socket:${socketPath}`),
getMpvClient: () => null,
showOsd: (text) => calls.push(`osd:${text}`),
texthookerService: { start: () => null, status: () => ({ running: false }) } as never,
getTexthookerPort: () => 5174,
setTexthookerPort: (port) => calls.push(`port:${port}`),
shouldOpenBrowser: () => true,
openExternal: async (url) => calls.push(`open:${url}`),
logBrowserOpenError: (url) => calls.push(`open-error:${url}`),
isOverlayInitialized: () => true,
initializeOverlay: () => calls.push('init'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlay: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy'),
startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`),
mineSentenceCard: async () => {
calls.push('mine');
},
startPendingMineSentenceMultiple: (ms) => calls.push(`mine-multi:${ms}`),
updateLastCardFromClipboard: async () => {
calls.push('update');
},
refreshKnownWordCache: async () => {
calls.push('refresh');
},
triggerFieldGrouping: async () => {
calls.push('group');
},
triggerSubsyncFromConfig: async () => {
calls.push('subsync');
},
markLastCardAsAudioCard: async () => {
calls.push('mark');
},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => calls.push('clear-token'),
openAnilistSetup: () => calls.push('anilist'),
openJellyfinSetup: () => calls.push('jellyfin'),
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
openYomitanSettings: () => calls.push('yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
printHelp: () => calls.push('help'),
stopApp: () => calls.push('stop'),
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 5000,
schedule: (fn) => {
fn();
return setTimeout(() => {}, 0);
},
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
});
const deps = buildDeps();
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
assert.equal(deps.getTexthookerPort(), 5174);
assert.equal(deps.shouldOpenBrowser(), true);
assert.equal(deps.isOverlayInitialized(), true);
assert.equal(deps.hasMainWindow(), true);
assert.equal(deps.getMultiCopyTimeoutMs(), 5000);
deps.setSocketPath('/tmp/next.sock');
deps.showOsd('hello');
deps.setTexthookerPort(5175);
deps.printHelp();
assert.deepEqual(calls, ['socket:/tmp/next.sock', 'osd:hello', 'port:5175', 'help']);
});

View File

@@ -0,0 +1,94 @@
import type { CliArgs } from '../../cli/args';
import type { CliCommandContextFactoryDeps } from './cli-command-context';
export function createBuildCliCommandContextDepsHandler(deps: {
getSocketPath: () => string;
setSocketPath: (socketPath: string) => void;
getMpvClient: CliCommandContextFactoryDeps['getMpvClient'];
showOsd: (text: string) => void;
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
shouldOpenBrowser: () => boolean;
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken'];
openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup'];
openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup'];
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
}) {
return (): CliCommandContextFactoryDeps => ({
getSocketPath: deps.getSocketPath,
setSocketPath: deps.setSocketPath,
getMpvClient: deps.getMpvClient,
showOsd: deps.showOsd,
texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort,
shouldOpenBrowser: deps.shouldOpenBrowser,
openExternal: deps.openExternal,
logBrowserOpenError: deps.logBrowserOpenError,
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
setVisibleOverlay: deps.setVisibleOverlay,
setInvisibleOverlay: deps.setInvisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,
mineSentenceCard: deps.mineSentenceCard,
startPendingMineSentenceMultiple: deps.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: deps.updateLastCardFromClipboard,
refreshKnownWordCache: deps.refreshKnownWordCache,
triggerFieldGrouping: deps.triggerFieldGrouping,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
getAnilistStatus: deps.getAnilistStatus,
clearAnilistToken: deps.clearAnilistToken,
openAnilistSetup: deps.openAnilistSetup,
openJellyfinSetup: deps.openJellyfinSetup,
getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow,
runJellyfinCommand: deps.runJellyfinCommand,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
stopApp: deps.stopApp,
hasMainWindow: deps.hasMainWindow,
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
schedule: deps.schedule,
logInfo: deps.logInfo,
logWarn: deps.logWarn,
logError: deps.logError,
});
}

View File

@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createCliCommandContextFactory } from './cli-command-context-factory';
test('cli command context factory composes main deps and context handlers', () => {
const calls: string[] = [];
const appState = {
mpvSocketPath: '/tmp/mpv.sock',
mpvClient: null,
texthookerPort: 5174,
overlayRuntimeInitialized: false,
};
const createContext = createCliCommandContextFactory({
appState,
texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }),
openExternal: async () => {},
logBrowserOpenError: () => {},
showMpvOsd: (text) => calls.push(`osd:${text}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWordCache: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
getAnilistStatus: () => ({
tokenStatus: 'resolved',
tokenSource: 'literal',
tokenMessage: null,
tokenResolvedAt: null,
tokenErrorAt: null,
queuePending: 0,
queueReady: 0,
queueDeadLetter: 0,
queueLastAttemptAt: null,
queueLastError: null,
}),
clearAnilistToken: () => {},
openAnilistSetupWindow: () => {},
openJellyfinSetupWindow: () => {},
getAnilistQueueStatus: () => ({
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
}),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
runJellyfinCommand: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
stopApp: () => {},
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 5000,
schedule: (fn) => setTimeout(fn, 0),
logInfo: () => {},
logWarn: () => {},
logError: () => {},
});
const context = createContext();
context.setSocketPath('/tmp/new.sock');
context.showOsd('hello');
context.setVisibleOverlay(true);
context.setInvisibleOverlay(false);
context.toggleVisibleOverlay();
context.toggleInvisibleOverlay();
assert.equal(appState.mpvSocketPath, '/tmp/new.sock');
assert.deepEqual(calls, [
'osd:hello',
'set-visible:true',
'set-invisible:false',
'toggle-visible',
'toggle-invisible',
]);
});

View File

@@ -0,0 +1,16 @@
import { createCliCommandContext } from './cli-command-context';
import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps';
import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps';
type CliCommandContextMainDeps = Parameters<
typeof createBuildCliCommandContextMainDepsHandler
>[0];
export function createCliCommandContextFactory(deps: CliCommandContextMainDeps) {
const buildCliCommandContextMainDepsHandler = createBuildCliCommandContextMainDepsHandler(deps);
const cliCommandContextMainDeps = buildCliCommandContextMainDepsHandler();
const buildCliCommandContextDepsHandler =
createBuildCliCommandContextDepsHandler(cliCommandContextMainDeps);
return () => createCliCommandContext(buildCliCommandContextDepsHandler());
}

View File

@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps';
test('cli command context main deps builder maps state and callbacks', async () => {
const calls: string[] = [];
const appState = {
mpvSocketPath: '/tmp/mpv.sock',
mpvClient: null,
texthookerPort: 5174,
overlayRuntimeInitialized: false,
};
const build = createBuildCliCommandContextMainDepsHandler({
appState,
texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }),
openExternal: async (url) => {
calls.push(`open:${url}`);
},
logBrowserOpenError: (url) => calls.push(`open-error:${url}`),
showMpvOsd: (text) => calls.push(`osd:${text}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
mineSentenceCard: async () => {
calls.push('mine');
},
startPendingMineSentenceMultiple: (timeoutMs) => calls.push(`mine-multi:${timeoutMs}`),
updateLastCardFromClipboard: async () => {
calls.push('update-last-card');
},
refreshKnownWordCache: async () => {
calls.push('refresh-known');
},
triggerFieldGrouping: async () => {
calls.push('field-grouping');
},
triggerSubsyncFromConfig: async () => {
calls.push('subsync');
},
markLastCardAsAudioCard: async () => {
calls.push('mark-audio');
},
getAnilistStatus: () => ({
tokenStatus: 'resolved',
tokenSource: 'literal',
tokenMessage: null,
tokenResolvedAt: null,
tokenErrorAt: null,
queuePending: 0,
queueReady: 0,
queueDeadLetter: 0,
queueLastAttemptAt: null,
queueLastError: null,
}),
clearAnilistToken: () => calls.push('clear-token'),
openAnilistSetupWindow: () => calls.push('open-anilist-setup'),
openJellyfinSetupWindow: () => calls.push('open-jellyfin-setup'),
getAnilistQueueStatus: () => ({
pending: 1,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
}),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
openYomitanSettings: () => calls.push('open-yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
printHelp: () => calls.push('help'),
stopApp: () => calls.push('stop-app'),
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 5000,
schedule: (fn) => {
fn();
return setTimeout(() => {}, 0);
},
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
});
const deps = build();
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
deps.setSocketPath('/tmp/next.sock');
assert.equal(appState.mpvSocketPath, '/tmp/next.sock');
assert.equal(deps.getTexthookerPort(), 5174);
deps.setTexthookerPort(5175);
assert.equal(appState.texthookerPort, 5175);
assert.equal(deps.shouldOpenBrowser(), true);
deps.showOsd('hello');
deps.initializeOverlay();
deps.setVisibleOverlay(true);
deps.setInvisibleOverlay(false);
deps.printHelp();
assert.deepEqual(calls, [
'osd:hello',
'init-overlay',
'set-visible:true',
'set-invisible:false',
'help',
]);
const retry = await deps.retryAnilistQueueNow();
assert.deepEqual(retry, { ok: true, message: 'ok' });
});

View File

@@ -0,0 +1,105 @@
import type { CliArgs } from '../../cli/args';
import type { CliCommandContextFactoryDeps } from './cli-command-context';
type CliCommandContextMainState = {
mpvSocketPath: string;
mpvClient: ReturnType<CliCommandContextFactoryDeps['getMpvClient']>;
texthookerPort: number;
overlayRuntimeInitialized: boolean;
};
export function createBuildCliCommandContextMainDepsHandler(deps: {
appState: CliCommandContextMainState;
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getResolvedConfig: () => { texthooker?: { openBrowser?: boolean } };
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
showMpvOsd: (text: string) => void;
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: () => void;
openAnilistSetupWindow: () => void;
openJellyfinSetupWindow: () => void;
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
}) {
return (): CliCommandContextFactoryDeps => ({
getSocketPath: () => deps.appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
deps.appState.mpvSocketPath = socketPath;
},
getMpvClient: () => deps.appState.mpvClient,
showOsd: (text: string) => deps.showMpvOsd(text),
texthookerService: deps.texthookerService,
getTexthookerPort: () => deps.appState.texthookerPort,
setTexthookerPort: (port: number) => {
deps.appState.texthookerPort = port;
},
shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false,
openExternal: (url: string) => deps.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error),
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
toggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
setInvisibleOverlay: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => deps.mineSentenceCard(),
startPendingMineSentenceMultiple: (timeoutMs: number) =>
deps.startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(),
refreshKnownWordCache: () => deps.refreshKnownWordCache(),
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
getAnilistStatus: () => deps.getAnilistStatus(),
clearAnilistToken: () => deps.clearAnilistToken(),
openAnilistSetup: () => deps.openAnilistSetupWindow(),
openJellyfinSetup: () => deps.openJellyfinSetupWindow(),
getAnilistQueueStatus: () => deps.getAnilistQueueStatus(),
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
openYomitanSettings: () => deps.openYomitanSettings(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
printHelp: () => deps.printHelp(),
stopApp: () => deps.stopApp(),
hasMainWindow: () => deps.hasMainWindow(),
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
logInfo: (message: string) => deps.logInfo(message),
logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, err: unknown) => deps.logError(message, err),
});
}

View File

@@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCliCommandContext } from './cli-command-context';
function createDeps() {
let socketPath = '/tmp/mpv.sock';
const logs: string[] = [];
const browserErrors: string[] = [];
return {
deps: {
getSocketPath: () => socketPath,
setSocketPath: (value: string) => {
socketPath = value;
},
getMpvClient: () => null,
showOsd: () => {},
texthookerService: {} as never,
getTexthookerPort: () => 6677,
setTexthookerPort: () => {},
shouldOpenBrowser: () => true,
openExternal: async () => {},
logBrowserOpenError: (url: string) => browserErrors.push(url),
isOverlayInitialized: () => true,
initializeOverlay: () => {},
toggleVisibleOverlay: () => {},
toggleInvisibleOverlay: () => {},
setVisibleOverlay: () => {},
setInvisibleOverlay: () => {},
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWordCache: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
getAnilistStatus: () => ({} as never),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
openJellyfinSetup: () => {},
getAnilistQueueStatus: () => ({} as never),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
runJellyfinCommand: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
stopApp: () => {},
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 1000,
schedule: (fn: () => void) => setTimeout(fn, 0),
logInfo: (message: string) => {
logs.push(`i:${message}`);
},
logWarn: (message: string) => {
logs.push(`w:${message}`);
},
logError: (message: string) => {
logs.push(`e:${message}`);
},
},
getLogs: () => logs,
getBrowserErrors: () => browserErrors,
};
}
test('cli command context proxies socket path getters/setters', () => {
const { deps } = createDeps();
const context = createCliCommandContext(deps);
assert.equal(context.getSocketPath(), '/tmp/mpv.sock');
context.setSocketPath('/tmp/next.sock');
assert.equal(context.getSocketPath(), '/tmp/next.sock');
});
test('cli command context openInBrowser reports failures', async () => {
const { deps, getBrowserErrors } = createDeps();
deps.openExternal = async () => {
throw new Error('no browser');
};
const context = createCliCommandContext(deps);
context.openInBrowser('https://example.com');
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(getBrowserErrors(), ['https://example.com']);
});
test('cli command context log methods map to deps loggers', () => {
const { deps, getLogs } = createDeps();
const context = createCliCommandContext(deps);
context.log('info');
context.warn('warn');
context.error('error', new Error('x'));
assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']);
});

View File

@@ -0,0 +1,106 @@
import type { CliArgs } from '../../cli/args';
import type {
CliCommandRuntimeServiceContext,
CliCommandRuntimeServiceContextHandlers,
} from '../cli-runtime';
type MpvClientLike = CliCommandRuntimeServiceContext['getClient'] extends () => infer T ? T : never;
export type CliCommandContextFactoryDeps = {
getSocketPath: () => string;
setSocketPath: (socketPath: string) => void;
getMpvClient: () => MpvClientLike;
showOsd: (text: string) => void;
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
shouldOpenBrowser: () => boolean;
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken'];
openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup'];
openJellyfinSetup: CliCommandRuntimeServiceContext['openJellyfinSetup'];
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
};
export function createCliCommandContext(
deps: CliCommandContextFactoryDeps,
): CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers {
return {
getSocketPath: deps.getSocketPath,
setSocketPath: deps.setSocketPath,
getClient: deps.getMpvClient,
showOsd: deps.showOsd,
texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort,
shouldOpenBrowser: deps.shouldOpenBrowser,
openInBrowser: (url: string) => {
void deps.openExternal(url).catch((error) => {
deps.logBrowserOpenError(url, error);
});
},
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
setVisibleOverlay: deps.setVisibleOverlay,
setInvisibleOverlay: deps.setInvisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,
mineSentenceCard: deps.mineSentenceCard,
startPendingMineSentenceMultiple: deps.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: deps.updateLastCardFromClipboard,
refreshKnownWordCache: deps.refreshKnownWordCache,
triggerFieldGrouping: deps.triggerFieldGrouping,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
getAnilistStatus: deps.getAnilistStatus,
clearAnilistToken: deps.clearAnilistToken,
openAnilistSetup: deps.openAnilistSetup,
openJellyfinSetup: deps.openJellyfinSetup,
getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow,
runJellyfinCommand: deps.runJellyfinCommand,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
stopApp: deps.stopApp,
hasMainWindow: deps.hasMainWindow,
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
schedule: deps.schedule,
log: deps.logInfo,
warn: deps.logWarn,
error: deps.logError,
};
}

View File

@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './cli-command-prechecks-main-deps';
test('cli prechecks main deps builder maps transition handlers', () => {
const calls: string[] = [];
const deps = createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({
isTexthookerOnlyMode: () => true,
setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`),
commandNeedsOverlayRuntime: () => true,
startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`info:${message}`),
})();
assert.equal(deps.isTexthookerOnlyMode(), true);
assert.equal(deps.commandNeedsOverlayRuntime({} as never), true);
deps.setTexthookerOnlyMode(false);
deps.startBackgroundWarmups();
deps.logInfo('x');
assert.deepEqual(calls, ['set:false', 'warmups', 'info:x']);
});

View File

@@ -0,0 +1,17 @@
import type { CliArgs } from '../../cli/args';
export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(deps: {
isTexthookerOnlyMode: () => boolean;
setTexthookerOnlyMode: (enabled: boolean) => void;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
startBackgroundWarmups: () => void;
logInfo: (message: string) => void;
}) {
return () => ({
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled),
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
logInfo: (message: string) => deps.logInfo(message),
});
}

View File

@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHandleTexthookerOnlyModeTransitionHandler } from './cli-command-prechecks';
test('texthooker precheck no-ops when mode is disabled', () => {
let warmups = 0;
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
isTexthookerOnlyMode: () => false,
setTexthookerOnlyMode: () => {},
commandNeedsOverlayRuntime: () => true,
startBackgroundWarmups: () => {
warmups += 1;
},
logInfo: () => {},
});
handlePrecheck({ start: true, texthooker: false } as never);
assert.equal(warmups, 0);
});
test('texthooker precheck disables mode and warms up on start command', () => {
let mode = true;
let warmups = 0;
let logs = 0;
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
isTexthookerOnlyMode: () => mode,
setTexthookerOnlyMode: (enabled) => {
mode = enabled;
},
commandNeedsOverlayRuntime: () => false,
startBackgroundWarmups: () => {
warmups += 1;
},
logInfo: () => {
logs += 1;
},
});
handlePrecheck({ start: true, texthooker: false } as never);
assert.equal(mode, false);
assert.equal(warmups, 1);
assert.equal(logs, 1);
});
test('texthooker precheck no-ops for texthooker command', () => {
let mode = true;
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
isTexthookerOnlyMode: () => mode,
setTexthookerOnlyMode: (enabled) => {
mode = enabled;
},
commandNeedsOverlayRuntime: () => true,
startBackgroundWarmups: () => {},
logInfo: () => {},
});
handlePrecheck({ start: true, texthooker: true } as never);
assert.equal(mode, true);
});

View File

@@ -0,0 +1,21 @@
import type { CliArgs } from '../../cli/args';
export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
isTexthookerOnlyMode: () => boolean;
setTexthookerOnlyMode: (enabled: boolean) => void;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
startBackgroundWarmups: () => void;
logInfo: (message: string) => void;
}) {
return (args: CliArgs): void => {
if (
deps.isTexthookerOnlyMode() &&
!args.texthooker &&
(args.start || deps.commandNeedsOverlayRuntime(args))
) {
deps.setTexthookerOnlyMode(false);
deps.logInfo('Disabling texthooker-only mode after overlay/start command.');
deps.startBackgroundWarmups();
}
};
}

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createCliCommandRuntimeHandler } from './cli-command-runtime-handler';
test('cli command runtime handler applies precheck and forwards command with context', () => {
const calls: string[] = [];
const handler = createCliCommandRuntimeHandler({
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => true,
setTexthookerOnlyMode: () => calls.push('set-mode'),
commandNeedsOverlayRuntime: () => true,
startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`log:${message}`),
},
createCliCommandContext: () => {
calls.push('context');
return { id: 'ctx' };
},
handleCliCommandRuntimeServiceWithContext: (_args, source, context) => {
calls.push(`cli:${source}:${context.id}`);
},
});
handler({ start: true } as never);
assert.deepEqual(calls, [
'set-mode',
'log:Disabling texthooker-only mode after overlay/start command.',
'warmups',
'context',
'cli:initial:ctx',
]);
});

View File

@@ -0,0 +1,30 @@
import type { CliArgs, CliCommandSource } from '../../cli/args';
import { createHandleTexthookerOnlyModeTransitionHandler } from './cli-command-prechecks';
import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './cli-command-prechecks-main-deps';
type HandleTexthookerOnlyModeTransitionMainDeps = Parameters<
typeof createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler
>[0];
export function createCliCommandRuntimeHandler<TCliContext>(deps: {
handleTexthookerOnlyModeTransitionMainDeps: HandleTexthookerOnlyModeTransitionMainDeps;
createCliCommandContext: () => TCliContext;
handleCliCommandRuntimeServiceWithContext: (
args: CliArgs,
source: CliCommandSource,
cliContext: TCliContext,
) => void;
}) {
const handleTexthookerOnlyModeTransitionHandler =
createHandleTexthookerOnlyModeTransitionHandler(
createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(
deps.handleTexthookerOnlyModeTransitionMainDeps,
)(),
);
return (args: CliArgs, source: CliCommandSource = 'initial'): void => {
handleTexthookerOnlyModeTransitionHandler(args);
const cliContext = deps.createCliCommandContext();
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
};
}

View File

@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { appendClipboardVideoToQueueRuntime } from './clipboard-queue';
test('appendClipboardVideoToQueueRuntime returns disconnected when mpv unavailable', () => {
const result = appendClipboardVideoToQueueRuntime({
getMpvClient: () => null,
readClipboardText: () => '',
showMpvOsd: () => {},
sendMpvCommand: () => {},
});
assert.deepEqual(result, { ok: false, message: 'MPV is not connected.' });
});
test('appendClipboardVideoToQueueRuntime rejects unsupported clipboard path', () => {
const osdMessages: string[] = [];
const result = appendClipboardVideoToQueueRuntime({
getMpvClient: () => ({ connected: true }),
readClipboardText: () => 'not a media path',
showMpvOsd: (text) => osdMessages.push(text),
sendMpvCommand: () => {},
});
assert.equal(result.ok, false);
assert.equal(osdMessages[0], 'Clipboard does not contain a supported video path.');
});
test('appendClipboardVideoToQueueRuntime queues readable media file', () => {
const tempPath = path.join(process.cwd(), 'dist', 'clipboard-queue-test-video.mkv');
fs.writeFileSync(tempPath, 'stub');
const commands: Array<(string | number)[]> = [];
const osdMessages: string[] = [];
const result = appendClipboardVideoToQueueRuntime({
getMpvClient: () => ({ connected: true }),
readClipboardText: () => tempPath,
showMpvOsd: (text) => osdMessages.push(text),
sendMpvCommand: (command) => commands.push(command),
});
assert.equal(result.ok, true);
assert.deepEqual(commands[0], ['loadfile', tempPath, 'append']);
assert.equal(osdMessages[0], `Queued from clipboard: ${path.basename(tempPath)}`);
fs.unlinkSync(tempPath);
});

View File

@@ -0,0 +1,40 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseClipboardVideoPath } from '../../core/services';
type MpvClientLike = {
connected: boolean;
};
export type AppendClipboardVideoToQueueRuntimeDeps = {
getMpvClient: () => MpvClientLike | null;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
sendMpvCommand: (command: (string | number)[]) => void;
};
export function appendClipboardVideoToQueueRuntime(
deps: AppendClipboardVideoToQueueRuntimeDeps,
): { ok: boolean; message: string } {
const mpvClient = deps.getMpvClient();
if (!mpvClient || !mpvClient.connected) {
return { ok: false, message: 'MPV is not connected.' };
}
const clipboardText = deps.readClipboardText();
const parsedPath = parseClipboardVideoPath(clipboardText);
if (!parsedPath) {
deps.showMpvOsd('Clipboard does not contain a supported video path.');
return { ok: false, message: 'Clipboard does not contain a supported video path.' };
}
const resolvedPath = path.resolve(parsedPath);
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
deps.showMpvOsd('Clipboard path is not a readable file.');
return { ok: false, message: 'Clipboard path is not a readable file.' };
}
deps.sendMpvCommand(['loadfile', resolvedPath, 'append']);
deps.showMpvOsd(`Queued from clipboard: ${path.basename(resolvedPath)}`);
return { ok: true, message: `Queued ${resolvedPath}` };
}

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(),
};
}

View File

@@ -0,0 +1,64 @@
import type { RuntimeOptionsManager } from '../../runtime-options';
import type { JimakuApiResponse, JimakuLanguagePreference, ResolvedConfig } from '../../types';
import {
getInitialInvisibleOverlayVisibility as getInitialInvisibleOverlayVisibilityCore,
getJimakuLanguagePreference as getJimakuLanguagePreferenceCore,
getJimakuMaxEntryResults as getJimakuMaxEntryResultsCore,
isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore,
jimakuFetchJson as jimakuFetchJsonCore,
resolveJimakuApiKey as resolveJimakuApiKeyCore,
shouldAutoInitializeOverlayRuntimeFromConfig as shouldAutoInitializeOverlayRuntimeFromConfigCore,
shouldBindVisibleOverlayToMpvSubVisibility as shouldBindVisibleOverlayToMpvSubVisibilityCore,
} from '../../core/services';
export type ConfigDerivedRuntimeDeps = {
getResolvedConfig: () => ResolvedConfig;
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
platform: NodeJS.Platform;
defaultJimakuLanguagePreference: JimakuLanguagePreference;
defaultJimakuMaxEntryResults: number;
defaultJimakuApiBaseUrl: string;
};
export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): {
getInitialInvisibleOverlayVisibility: () => boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isAutoUpdateEnabledRuntime: () => boolean;
getJimakuLanguagePreference: () => JimakuLanguagePreference;
getJimakuMaxEntryResults: () => number;
resolveJimakuApiKey: () => Promise<string | null>;
jimakuFetchJson: <T>(
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<JimakuApiResponse<T>>;
} {
return {
getInitialInvisibleOverlayVisibility: () =>
getInitialInvisibleOverlayVisibilityCore(deps.getResolvedConfig(), deps.platform),
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfigCore(deps.getResolvedConfig()),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
shouldBindVisibleOverlayToMpvSubVisibilityCore(deps.getResolvedConfig()),
isAutoUpdateEnabledRuntime: () =>
isAutoUpdateEnabledRuntimeCore(deps.getResolvedConfig(), deps.getRuntimeOptionsManager()),
getJimakuLanguagePreference: () =>
getJimakuLanguagePreferenceCore(
() => deps.getResolvedConfig(),
deps.defaultJimakuLanguagePreference,
),
getJimakuMaxEntryResults: () =>
getJimakuMaxEntryResultsCore(() => deps.getResolvedConfig(), deps.defaultJimakuMaxEntryResults),
resolveJimakuApiKey: () => resolveJimakuApiKeyCore(() => deps.getResolvedConfig()),
jimakuFetchJson: <T>(
endpoint: string,
query: Record<string, string | number | boolean | null | undefined> = {},
): Promise<JimakuApiResponse<T>> =>
jimakuFetchJsonCore<T>(endpoint, query, {
getResolvedConfig: () => deps.getResolvedConfig(),
defaultBaseUrl: deps.defaultJimakuApiBaseUrl,
defaultMaxEntryResults: deps.defaultJimakuMaxEntryResults,
defaultLanguagePreference: deps.defaultJimakuLanguagePreference,
}),
};
}

View File

@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import {
buildRestartRequiredConfigMessage,
createConfigHotReloadAppliedHandler,
createConfigHotReloadMessageHandler,
} from './config-hot-reload-handlers';
test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
const calls: string[] = [];
const ankiPatches: Array<{ enabled: boolean }> = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`),
applyAnkiRuntimeConfigPatch: (patch) => {
ankiPatches.push({ enabled: patch.ai.enabled });
},
});
applyHotReload(
{
hotReloadFields: ['shortcuts', 'secondarySub.defaultMode', 'ankiConnect.ai'],
restartRequiredFields: [],
},
config,
);
assert.ok(calls.includes('set:keybindings'));
assert.ok(calls.includes('refresh:shortcuts'));
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
assert.ok(calls.includes('broadcast:config:hot-reload:object'));
assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]);
});
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
const calls: string[] = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: () => calls.push('set:secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
applyAnkiRuntimeConfigPatch: () => calls.push('anki:patch'),
});
applyHotReload(
{
hotReloadFields: [],
restartRequiredFields: [],
},
config,
);
assert.deepEqual(calls, ['set:keybindings']);
});
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
const calls: string[] = [];
const handleMessage = createConfigHotReloadMessageHandler({
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
});
handleMessage('Config reload failed');
assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']);
});
test('buildRestartRequiredConfigMessage formats changed fields', () => {
assert.equal(
buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']),
'Config updated; restart required for: websocket, subtitleStyle',
);
});

View File

@@ -0,0 +1,73 @@
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { resolveKeybindings } from '../../core/utils';
import { DEFAULT_KEYBINDINGS } from '../../config';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
type ConfigHotReloadAppliedDeps = {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void;
};
type ConfigHotReloadMessageDeps = {
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
};
export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
if (!config.subtitleStyle) {
return null;
}
return {
...config.subtitleStyle,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.ankiConnect.nPlusOne.knownWord,
enableJlpt: config.subtitleStyle.enableJlpt,
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
};
}
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
return {
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
subtitleStyle: resolveSubtitleStyleForRenderer(config),
secondarySubMode: config.secondarySub.defaultMode,
};
}
export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadAppliedDeps) {
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
const payload = buildConfigHotReloadPayload(config);
deps.setKeybindings(payload.keybindings);
if (diff.hotReloadFields.includes('shortcuts')) {
deps.refreshGlobalAndOverlayShortcuts();
}
if (diff.hotReloadFields.includes('secondarySub.defaultMode')) {
deps.setSecondarySubMode(payload.secondarySubMode);
deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
}
if (diff.hotReloadFields.includes('ankiConnect.ai')) {
deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai });
}
if (diff.hotReloadFields.length > 0) {
deps.broadcastToOverlayWindows('config:hot-reload', payload);
}
};
}
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
return (message: string): void => {
deps.showMpvOsd(message);
deps.showDesktopNotification('SubMiner', { body: message });
};
}
export function buildRestartRequiredConfigMessage(fields: string[]): string {
return `Config updated; restart required for: ${fields.join(', ')}`;
}

View File

@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ReloadConfigStrictResult } from '../../config';
import type { ResolvedConfig } from '../../types';
import {
createBuildConfigHotReloadMessageMainDepsHandler,
createBuildConfigHotReloadAppliedMainDepsHandler,
createBuildConfigHotReloadRuntimeMainDepsHandler,
createBuildWatchConfigPathMainDepsHandler,
createWatchConfigPathHandler,
} from './config-hot-reload-main-deps';
test('watch config path handler watches file directly when config exists', () => {
const calls: string[] = [];
const watchConfigPath = createWatchConfigPathHandler({
fileExists: () => true,
dirname: (path) => path.split('/').slice(0, -1).join('/'),
watchPath: (targetPath, nextListener) => {
calls.push(`watch:${targetPath}`);
nextListener('change', 'ignored');
return { close: () => calls.push('close') };
},
});
const watcher = watchConfigPath('/tmp/config.jsonc', () => calls.push('change'));
watcher.close();
assert.deepEqual(calls, ['watch:/tmp/config.jsonc', 'change', 'close']);
});
test('watch config path handler filters directory events to config files only', () => {
const calls: string[] = [];
const watchConfigPath = createWatchConfigPathHandler({
fileExists: () => false,
dirname: (path) => path.split('/').slice(0, -1).join('/'),
watchPath: (_targetPath, nextListener) => {
nextListener('change', 'foo.txt');
nextListener('change', 'config.json');
nextListener('change', 'config.jsonc');
nextListener('change', null);
return { close: () => {} };
},
});
watchConfigPath('/tmp/config.jsonc', () => calls.push('change'));
assert.deepEqual(calls, ['change', 'change', 'change']);
});
test('watch config path main deps builder maps filesystem callbacks', () => {
const calls: string[] = [];
const deps = createBuildWatchConfigPathMainDepsHandler({
fileExists: () => true,
dirname: (targetPath) => {
calls.push(`dirname:${targetPath}`);
return '/tmp';
},
watchPath: (targetPath, listener) => {
calls.push(`watch:${targetPath}`);
listener('change', 'config.jsonc');
return { close: () => calls.push('close') };
},
})();
assert.equal(deps.fileExists('/tmp/config.jsonc'), true);
assert.equal(deps.dirname('/tmp/config.jsonc'), '/tmp');
const watcher = deps.watchPath('/tmp/config.jsonc', () => calls.push('listener'));
watcher.close();
assert.deepEqual(calls, [
'dirname:/tmp/config.jsonc',
'watch:/tmp/config.jsonc',
'listener',
'close',
]);
});
test('config hot reload message main deps builder maps notifications', () => {
const calls: string[] = [];
const deps = createBuildConfigHotReloadMessageMainDepsHandler({
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title) => calls.push(`notify:${title}`),
})();
deps.showMpvOsd('updated');
deps.showDesktopNotification('SubMiner', { body: 'updated' });
assert.deepEqual(calls, ['osd:updated', 'notify:SubMiner']);
});
test('config hot reload applied main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildConfigHotReloadAppliedMainDepsHandler({
setKeybindings: () => calls.push('keybindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
setSecondarySubMode: () => calls.push('set-secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'),
})();
deps.setKeybindings([]);
deps.refreshGlobalAndOverlayShortcuts();
deps.setSecondarySubMode('hover');
deps.broadcastToOverlayWindows('config:hot-reload', {});
deps.applyAnkiRuntimeConfigPatch({ ai: {} as never });
assert.deepEqual(calls, [
'keybindings',
'refresh-shortcuts',
'set-secondary',
'broadcast:config:hot-reload',
'apply-anki',
]);
});
test('config hot reload runtime main deps builder maps runtime callbacks', () => {
const calls: string[] = [];
const deps = createBuildConfigHotReloadRuntimeMainDepsHandler({
getCurrentConfig: () => ({ id: 1 } as never as ResolvedConfig),
reloadConfigStrict: () =>
({
ok: true,
config: { id: 1 } as never as ResolvedConfig,
warnings: [],
path: '/tmp/config.jsonc',
}) as ReloadConfigStrictResult,
watchConfigPath: (_configPath, _onChange) => ({ close: () => calls.push('close') }),
setTimeout: (callback) => {
callback();
return 1 as never;
},
clearTimeout: () => calls.push('clear-timeout'),
debounceMs: 250,
onHotReloadApplied: () => calls.push('hot-reload'),
onRestartRequired: () => calls.push('restart-required'),
onInvalidConfig: () => calls.push('invalid-config'),
onValidationWarnings: () => calls.push('validation-warnings'),
})();
assert.deepEqual(deps.getCurrentConfig(), { id: 1 });
assert.deepEqual(deps.reloadConfigStrict(), {
ok: true,
config: { id: 1 },
warnings: [],
path: '/tmp/config.jsonc',
});
assert.equal(deps.debounceMs, 250);
deps.onHotReloadApplied({} as never, {} as never);
deps.onRestartRequired([]);
deps.onInvalidConfig('bad');
deps.onValidationWarnings('/tmp/config.jsonc', []);
assert.deepEqual(calls, ['hot-reload', 'restart-required', 'invalid-config', 'validation-warnings']);
});

View File

@@ -0,0 +1,102 @@
import type {
ConfigHotReloadDiff,
ConfigHotReloadRuntimeDeps,
} from '../../core/services/config-hot-reload';
import type { ReloadConfigStrictResult } from '../../config';
import type { ConfigHotReloadPayload, ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from '../../types';
import type { createConfigHotReloadMessageHandler } from './config-hot-reload-handlers';
type ConfigWatchListener = (eventType: string, filename: string | null) => void;
export function createWatchConfigPathHandler(deps: {
fileExists: (path: string) => boolean;
dirname: (path: string) => string;
watchPath: (targetPath: string, listener: ConfigWatchListener) => { close: () => void };
}) {
return (configPath: string, onChange: () => void): { close: () => void } => {
const watchTarget = deps.fileExists(configPath) ? configPath : deps.dirname(configPath);
const watcher = deps.watchPath(watchTarget, (_eventType, filename) => {
if (watchTarget === configPath) {
onChange();
return;
}
const normalized =
typeof filename === 'string' ? filename : filename ? String(filename) : undefined;
if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') {
onChange();
}
});
return {
close: () => watcher.close(),
};
};
}
type WatchConfigPathMainDeps = Parameters<typeof createWatchConfigPathHandler>[0];
type ConfigHotReloadMessageMainDeps = Parameters<typeof createConfigHotReloadMessageHandler>[0];
export function createBuildWatchConfigPathMainDepsHandler(deps: WatchConfigPathMainDeps) {
return (): WatchConfigPathMainDeps => ({
fileExists: (targetPath: string) => deps.fileExists(targetPath),
dirname: (targetPath: string) => deps.dirname(targetPath),
watchPath: (targetPath: string, listener: ConfigWatchListener) =>
deps.watchPath(targetPath, listener),
});
}
export function createBuildConfigHotReloadMessageMainDepsHandler(
deps: ConfigHotReloadMessageMainDeps,
) {
return (): ConfigHotReloadMessageMainDeps => ({
showMpvOsd: (message: string) => deps.showMpvOsd(message),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
});
}
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void;
}) {
return () => ({
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
deps.setKeybindings(keybindings),
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
deps.broadcastToOverlayWindows(channel, payload),
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) =>
deps.applyAnkiRuntimeConfigPatch(patch),
});
}
export function createBuildConfigHotReloadRuntimeMainDepsHandler(deps: {
getCurrentConfig: () => ResolvedConfig;
reloadConfigStrict: () => ReloadConfigStrictResult;
watchConfigPath: ConfigHotReloadRuntimeDeps['watchConfigPath'];
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
clearTimeout: (timeout: NodeJS.Timeout) => void;
debounceMs: number;
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
onRestartRequired: (fields: string[]) => void;
onInvalidConfig: (message: string) => void;
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void;
}) {
return (): ConfigHotReloadRuntimeDeps => ({
getCurrentConfig: () => deps.getCurrentConfig(),
reloadConfigStrict: () => deps.reloadConfigStrict(),
watchConfigPath: (configPath, onChange) => deps.watchConfigPath(configPath, onChange),
setTimeout: (callback: () => void, delayMs: number) => deps.setTimeout(callback, delayMs),
clearTimeout: (timeout: NodeJS.Timeout) => deps.clearTimeout(timeout),
debounceMs: deps.debounceMs,
onHotReloadApplied: (diff, config) => deps.onHotReloadApplied(diff, config),
onRestartRequired: (fields: string[]) => deps.onRestartRequired(fields),
onInvalidConfig: (message: string) => deps.onInvalidConfig(message),
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) =>
deps.onValidationWarnings(configPath, warnings),
});
}

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildDictionaryRootsMainHandler,
createBuildFrequencyDictionaryRootsMainHandler,
createBuildFrequencyDictionaryRuntimeMainDepsHandler,
createBuildJlptDictionaryRuntimeMainDepsHandler,
} from './dictionary-runtime-main-deps';
test('dictionary roots main handler returns expected root list', () => {
const roots = createBuildDictionaryRootsMainHandler({
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
userDataPath: '/Users/a/.config/SubMiner',
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
homeDir: '/Users/a',
cwd: '/repo',
joinPath: (...parts) => parts.join('/'),
})();
assert.equal(roots.length, 11);
assert.equal(roots[0], '/repo/dist/main/../../vendor/yomitan-jlpt-vocab');
assert.equal(roots[10], '/repo');
});
test('jlpt dictionary runtime main deps builder maps search paths and log prefix', () => {
const calls: string[] = [];
const deps = createBuildJlptDictionaryRuntimeMainDepsHandler({
isJlptEnabled: () => true,
getDictionaryRoots: () => ['/root/a'],
getJlptDictionarySearchPaths: ({ getDictionaryRoots }) => getDictionaryRoots().map((path) => `${path}/jlpt`),
setJlptLevelLookup: () => calls.push('set-lookup'),
logInfo: (message) => calls.push(`log:${message}`),
})();
assert.equal(deps.isJlptEnabled(), true);
assert.deepEqual(deps.getSearchPaths(), ['/root/a/jlpt']);
deps.setJlptLevelLookup(() => null);
deps.log('loaded');
assert.deepEqual(calls, ['set-lookup', 'log:[JLPT] loaded']);
});
test('frequency dictionary roots main handler returns expected root list', () => {
const roots = createBuildFrequencyDictionaryRootsMainHandler({
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
userDataPath: '/Users/a/.config/SubMiner',
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
homeDir: '/Users/a',
cwd: '/repo',
joinPath: (...parts) => parts.join('/'),
})();
assert.equal(roots.length, 15);
assert.equal(roots[0], '/repo/dist/main/../../vendor/jiten_freq_global');
assert.equal(roots[14], '/repo');
});
test('frequency dictionary runtime main deps builder maps search paths/source and log prefix', () => {
const calls: string[] = [];
const deps = createBuildFrequencyDictionaryRuntimeMainDepsHandler({
isFrequencyDictionaryEnabled: () => true,
getDictionaryRoots: () => ['/root/a', ''],
getFrequencyDictionarySearchPaths: ({ getDictionaryRoots, getSourcePath }) => [
...getDictionaryRoots().map((path) => `${path}/freq`),
getSourcePath() || '',
],
getSourcePath: () => '/custom/freq.json',
setFrequencyRankLookup: () => calls.push('set-rank'),
logInfo: (message) => calls.push(`log:${message}`),
})();
assert.equal(deps.isFrequencyDictionaryEnabled(), true);
assert.deepEqual(deps.getSearchPaths(), ['/root/a/freq', '/custom/freq.json']);
deps.setFrequencyRankLookup(() => null);
deps.log('loaded');
assert.deepEqual(calls, ['set-rank', 'log:[Frequency] loaded']);
});

Some files were not shown because too many files have changed in this diff Show More