Enhance AniList character dictionary sync and subtitle features (#15)

This commit is contained in:
2026-03-07 18:30:59 -08:00
committed by GitHub
parent 2f07c3407a
commit e18985fb14
696 changed files with 14297 additions and 173564 deletions

View File

@@ -19,6 +19,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
}
export interface AppReadyRuntimeDepsFactoryInput {
ensureDefaultConfigBootstrap: AppReadyRuntimeDeps['ensureDefaultConfigBootstrap'];
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
@@ -30,8 +31,12 @@ export interface AppReadyRuntimeDepsFactoryInput {
setSecondarySubMode: AppReadyRuntimeDeps['setSecondarySubMode'];
defaultSecondarySubMode: AppReadyRuntimeDeps['defaultSecondarySubMode'];
defaultWebsocketPort: AppReadyRuntimeDeps['defaultWebsocketPort'];
defaultAnnotationWebsocketPort: AppReadyRuntimeDeps['defaultAnnotationWebsocketPort'];
defaultTexthookerPort: AppReadyRuntimeDeps['defaultTexthookerPort'];
hasMpvWebsocketPlugin: AppReadyRuntimeDeps['hasMpvWebsocketPlugin'];
startSubtitleWebsocket: AppReadyRuntimeDeps['startSubtitleWebsocket'];
startAnnotationWebsocket: AppReadyRuntimeDeps['startAnnotationWebsocket'];
startTexthooker: AppReadyRuntimeDeps['startTexthooker'];
log: AppReadyRuntimeDeps['log'];
setLogLevel: AppReadyRuntimeDeps['setLogLevel'];
createMecabTokenizerAndCheck: AppReadyRuntimeDeps['createMecabTokenizerAndCheck'];
@@ -39,10 +44,12 @@ export interface AppReadyRuntimeDepsFactoryInput {
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode'];
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
setVisibleOverlayVisible: AppReadyRuntimeDeps['setVisibleOverlayVisible'];
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
@@ -74,6 +81,7 @@ export function createAppReadyRuntimeDeps(
params: AppReadyRuntimeDepsFactoryInput,
): AppReadyRuntimeDeps {
return {
ensureDefaultConfigBootstrap: params.ensureDefaultConfigBootstrap,
loadSubtitlePosition: params.loadSubtitlePosition,
resolveKeybindings: params.resolveKeybindings,
createMpvClient: params.createMpvClient,
@@ -85,8 +93,12 @@ export function createAppReadyRuntimeDeps(
setSecondarySubMode: params.setSecondarySubMode,
defaultSecondarySubMode: params.defaultSecondarySubMode,
defaultWebsocketPort: params.defaultWebsocketPort,
defaultAnnotationWebsocketPort: params.defaultAnnotationWebsocketPort,
defaultTexthookerPort: params.defaultTexthookerPort,
hasMpvWebsocketPlugin: params.hasMpvWebsocketPlugin,
startSubtitleWebsocket: params.startSubtitleWebsocket,
startAnnotationWebsocket: params.startAnnotationWebsocket,
startTexthooker: params.startTexthooker,
log: params.log,
setLogLevel: params.setLogLevel,
createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck,
@@ -94,11 +106,13 @@ export function createAppReadyRuntimeDeps(
createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension,
handleFirstRunSetup: params.handleFirstRunSetup,
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
startBackgroundWarmups: params.startBackgroundWarmups,
texthookerOnlyMode: params.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig:
params.shouldAutoInitializeOverlayRuntimeFromConfig,
setVisibleOverlayVisible: params.setVisibleOverlayVisible,
initializeOverlayRuntime: params.initializeOverlayRuntime,
handleInitialArgs: params.handleInitialArgs,
onCriticalConfigErrors: params.onCriticalConfigErrors,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ export interface CliCommandRuntimeServiceContext {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -33,6 +34,7 @@ export interface CliCommandRuntimeServiceContext {
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus'];
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate'];
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
openYomitanSettings: () => void;
@@ -94,11 +96,15 @@ function createCliCommandDepsFromContext(
getQueueStatus: context.getAnilistQueueStatus,
retryQueueNow: context.retryAnilistQueueNow,
},
dictionary: {
generate: context.generateCharacterDictionary,
},
jellyfin: {
openSetup: context.openJellyfinSetup,
runCommand: context.runJellyfinCommand,
},
ui: {
openFirstRunSetup: context.openFirstRunSetup,
openYomitanSettings: context.openYomitanSettings,
cycleSecondarySubMode: context.cycleSecondarySubMode,
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,

View File

@@ -151,11 +151,15 @@ export interface CliCommandRuntimeServiceDepsParams {
getQueueStatus: CliCommandDepsRuntimeOptions['anilist']['getQueueStatus'];
retryQueueNow: CliCommandDepsRuntimeOptions['anilist']['retryQueueNow'];
};
dictionary: {
generate: CliCommandDepsRuntimeOptions['dictionary']['generate'];
};
jellyfin: {
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
};
ui: {
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
@@ -182,6 +186,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
resolveProxyCommandOsd?: HandleMpvCommandFromIpcOptions['resolveProxyCommandOsd'];
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions['hasRuntimeOptionsManager'];
}
@@ -296,11 +301,15 @@ export function createCliCommandRuntimeServiceDeps(
getQueueStatus: params.anilist.getQueueStatus,
retryQueueNow: params.anilist.retryQueueNow,
},
dictionary: {
generate: params.dictionary.generate,
},
jellyfin: {
openSetup: params.jellyfin.openSetup,
runCommand: params.jellyfin.runCommand,
},
ui: {
openFirstRunSetup: params.ui.openFirstRunSetup,
openYomitanSettings: params.ui.openYomitanSettings,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
@@ -331,6 +340,7 @@ export function createMpvCommandRuntimeServiceDeps(
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
mpvSendCommand: params.mpvSendCommand,
resolveProxyCommandOsd: params.resolveProxyCommandOsd,
isMpvConnected: params.isMpvConnected,
hasRuntimeOptionsManager: params.hasRuntimeOptionsManager,
};

View File

@@ -2,6 +2,12 @@ import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../types';
import { handleMpvCommandFromIpc } from '../core/services';
import { createMpvCommandRuntimeServiceDeps } from './dependencies';
import { SPECIAL_COMMANDS } from '../config';
import { resolveProxyCommandOsdRuntime } from './runtime/mpv-proxy-osd';
type MpvPropertyClientLike = {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void;
@@ -12,6 +18,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
sendMpvCommand: (command: (string | number)[]) => void;
getMpvClient: () => MpvPropertyClientLike | null;
isMpvConnected: () => boolean;
hasRuntimeOptionsManager: () => boolean;
}
@@ -33,6 +40,8 @@ export function handleMpvCommandFromIpcRuntime(
shiftSubDelayToAdjacentSubtitle: (direction) =>
deps.shiftSubDelayToAdjacentSubtitle(direction),
mpvSendCommand: deps.sendMpvCommand,
resolveProxyCommandOsd: (nextCommand) =>
resolveProxyCommandOsdRuntime(nextCommand, deps.getMpvClient),
isMpvConnected: deps.isMpvConnected,
hasRuntimeOptionsManager: deps.hasRuntimeOptionsManager,
}),

View File

@@ -22,6 +22,8 @@ function createMockWindow(): MockWindow & {
isFocused: () => boolean;
getURL: () => string;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => void;
moveTop: () => void;
getShowCount: () => number;
getHideCount: () => number;
show: () => void;
@@ -59,6 +61,8 @@ function createMockWindow(): MockWindow & {
setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => {
state.ignoreMouseEvents = ignore;
},
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
moveTop: () => {},
getShowCount: () => state.showCount,
getHideCount: () => state.hideCount,
show: () => {
@@ -100,6 +104,27 @@ function createMockWindow(): MockWindow & {
},
});
Object.defineProperty(window, 'visible', {
get: () => state.visible,
set: (value: boolean) => {
state.visible = value;
},
});
Object.defineProperty(window, 'focused', {
get: () => state.focused,
set: (value: boolean) => {
state.focused = value;
},
});
Object.defineProperty(window, 'webContentsFocused', {
get: () => state.webContentsFocused,
set: (value: boolean) => {
state.webContentsFocused = value;
},
});
Object.defineProperty(window, 'url', {
get: () => state.url,
set: (value: string) => {
@@ -318,7 +343,7 @@ test('notifyOverlayModalOpened enables input on visible main overlay window when
runtime.notifyOverlayModalOpened('runtime-options');
assert.equal(sent, true);
assert.equal(state, [true]);
assert.deepEqual(state, [true]);
assert.equal(mainWindow.ignoreMouseEvents, false);
assert.equal(mainWindow.isFocused(), true);
assert.equal(mainWindow.webContentsFocused, true);
@@ -400,7 +425,7 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
});
assert.equal(window.getShowCount(), 1);
assert.equal(window.ignoreMouseEvents, true);
assert.equal(window.ignoreMouseEvents, false);
runtime.notifyOverlayModalOpened('jimaku');
assert.equal(window.ignoreMouseEvents, false);

View File

@@ -59,7 +59,7 @@ export function createOverlayModalRuntimeService(
const getTargetOverlayWindow = (): BrowserWindow | null => {
const visibleMainWindow = deps.getMainWindow();
if (visibleMainWindow && !visibleMainWindow.isDestroyed()) {
if (visibleMainWindow && !visibleMainWindow.isDestroyed() && visibleMainWindow.isVisible()) {
return visibleMainWindow;
}
return null;
@@ -221,7 +221,13 @@ export function createOverlayModalRuntimeService(
showModalWindow(modalWindow);
}
sendOrQueueForWindow(modalWindow, sendNow);
sendOrQueueForWindow(modalWindow, (window) => {
if (payload === undefined) {
window.webContents.send(channel);
} else {
window.webContents.send(channel, payload);
}
});
return true;
}

View File

@@ -76,7 +76,7 @@ test('register subminer protocol client main deps builder maps callbacks', () =>
execPath: '/tmp/electron',
resolvePath: (value) => `/abs/${value}`,
setAsDefaultProtocolClient: () => true,
logWarn: (message) => calls.push(`warn:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
})();
assert.equal(deps.isDefaultApp(), true);

View File

@@ -60,6 +60,6 @@ export function createBuildRegisterSubminerProtocolClientMainDepsHandler(
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),
logDebug: (message: string, details?: unknown) => deps.logDebug(message, details),
});
}

View File

@@ -56,9 +56,26 @@ test('createRegisterSubminerProtocolClientHandler registers default app entry',
calls.push(`register:${String(args?.[0])}`);
return true;
},
logWarn: (message) => calls.push(`warn:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
});
register();
assert.deepEqual(calls, ['register:/resolved/./entry.js']);
});
test('createRegisterSubminerProtocolClientHandler keeps unsupported registration at debug level', () => {
const calls: string[] = [];
const register = createRegisterSubminerProtocolClientHandler({
isDefaultApp: () => false,
getArgv: () => ['SubMiner.AppImage'],
execPath: '/tmp/SubMiner.AppImage',
resolvePath: (value) => value,
setAsDefaultProtocolClient: () => false,
logDebug: (message) => calls.push(`debug:${message}`),
});
register();
assert.deepEqual(calls, [
'debug:Failed to register default protocol handler for subminer:// URLs',
]);
});

View File

@@ -67,7 +67,7 @@ export function createRegisterSubminerProtocolClientHandler(deps: {
execPath: string;
resolvePath: (value: string) => string;
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) => boolean;
logWarn: (message: string, details?: unknown) => void;
logDebug: (message: string, details?: unknown) => void;
}) {
return (): void => {
try {
@@ -78,10 +78,10 @@ export function createRegisterSubminerProtocolClientHandler(deps: {
])
: deps.setAsDefaultProtocolClient('subminer');
if (!success) {
deps.logWarn('Failed to register default protocol handler for subminer:// URLs');
deps.logDebug('Failed to register default protocol handler for subminer:// URLs');
}
} catch (error) {
deps.logWarn('Failed to register subminer:// protocol handler', error);
deps.logDebug('Failed to register subminer:// protocol handler', error);
}
};
}

View File

@@ -5,6 +5,7 @@ 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({
ensureDefaultConfigBootstrap: () => calls.push('bootstrap-config'),
loadSubtitlePosition: () => calls.push('load-subtitle-position'),
resolveKeybindings: () => calls.push('resolve-keybindings'),
createMpvClient: () => calls.push('create-mpv-client'),
@@ -16,8 +17,12 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
setSecondarySubMode: () => calls.push('set-secondary-sub-mode'),
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => calls.push('start-ws'),
startAnnotationWebsocket: () => calls.push('start-annotation-ws'),
startTexthooker: () => calls.push('start-texthooker'),
log: () => calls.push('log'),
setLogLevel: () => calls.push('set-log-level'),
createMecabTokenizerAndCheck: async () => {
@@ -31,12 +36,16 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
loadYomitanExtension: async () => {
calls.push('load-yomitan');
},
handleFirstRunSetup: async () => {
calls.push('handle-first-run-setup');
},
prewarmSubtitleDictionaries: async () => {
calls.push('prewarm-dicts');
},
startBackgroundWarmups: () => calls.push('start-warmups'),
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
setVisibleOverlayVisible: () => calls.push('set-visible-overlay'),
initializeOverlayRuntime: () => calls.push('init-overlay'),
handleInitialArgs: () => calls.push('handle-initial-args'),
onCriticalConfigErrors: () => {
@@ -48,6 +57,8 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
assert.equal(onReady.defaultSecondarySubMode, 'hover');
assert.equal(onReady.defaultWebsocketPort, 5174);
assert.equal(onReady.defaultAnnotationWebsocketPort, 6678);
assert.equal(onReady.defaultTexthookerPort, 5174);
assert.equal(onReady.texthookerOnlyMode, false);
assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true);
assert.equal(onReady.now?.(), 123);
@@ -56,8 +67,11 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
onReady.createMpvClient();
await onReady.createMecabTokenizerAndCheck();
await onReady.loadYomitanExtension();
await onReady.handleFirstRunSetup();
await onReady.prewarmSubtitleDictionaries?.();
onReady.startBackgroundWarmups();
onReady.startTexthooker(5174);
onReady.setVisibleOverlayVisible(true);
assert.deepEqual(calls, [
'load-subtitle-position',
@@ -65,7 +79,10 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
'create-mpv-client',
'create-mecab',
'load-yomitan',
'handle-first-run-setup',
'prewarm-dicts',
'start-warmups',
'start-texthooker',
'set-visible-overlay',
]);
});

View File

@@ -2,6 +2,7 @@ import type { AppReadyRuntimeDepsFactoryInput } from '../app-lifecycle';
export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeDepsFactoryInput) {
return (): AppReadyRuntimeDepsFactoryInput => ({
ensureDefaultConfigBootstrap: deps.ensureDefaultConfigBootstrap,
loadSubtitlePosition: deps.loadSubtitlePosition,
resolveKeybindings: deps.resolveKeybindings,
createMpvClient: deps.createMpvClient,
@@ -13,8 +14,12 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
setSecondarySubMode: deps.setSecondarySubMode,
defaultSecondarySubMode: deps.defaultSecondarySubMode,
defaultWebsocketPort: deps.defaultWebsocketPort,
defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort,
defaultTexthookerPort: deps.defaultTexthookerPort,
hasMpvWebsocketPlugin: deps.hasMpvWebsocketPlugin,
startSubtitleWebsocket: deps.startSubtitleWebsocket,
startAnnotationWebsocket: deps.startAnnotationWebsocket,
startTexthooker: deps.startTexthooker,
log: deps.log,
setLogLevel: deps.setLogLevel,
createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck,
@@ -22,10 +27,12 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
createImmersionTracker: deps.createImmersionTracker,
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
loadYomitanExtension: deps.loadYomitanExtension,
handleFirstRunSetup: deps.handleFirstRunSetup,
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
startBackgroundWarmups: deps.startBackgroundWarmups,
texthookerOnlyMode: deps.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: deps.shouldAutoInitializeOverlayRuntimeFromConfig,
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
initializeOverlayRuntime: deps.initializeOverlayRuntime,
handleInitialArgs: deps.handleInitialArgs,
onCriticalConfigErrors: deps.onCriticalConfigErrors,

View File

@@ -0,0 +1,269 @@
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import { createCharacterDictionaryAutoSyncRuntimeService } from './character-dictionary-auto-sync';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
}
test('auto sync imports merged dictionary and persists MRU state', async () => {
const userDataPath = makeTempDir();
const imported: string[] = [];
const deleted: string[] = [];
const upserts: Array<{ title: string; scope: 'all' | 'active' }> = [];
const mergedBuilds: number[][] = [];
const logs: string[] = [];
let importedRevision: string | null = null;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 3,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async () => ({
mediaId: 130298,
mediaTitle: 'The Eminence in Shadow',
entryCount: 2544,
fromCache: false,
updatedAt: 1000,
}),
buildMergedDictionary: async (mediaIds) => {
mergedBuilds.push([...mediaIds]);
return {
zipPath: '/tmp/subminer-character-dictionary.zip',
revision: 'rev-1',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 2544,
};
},
getYomitanDictionaryInfo: async () =>
importedRevision
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
: [],
importYomitanDictionary: async (zipPath) => {
imported.push(zipPath);
importedRevision = 'rev-1';
return true;
},
deleteYomitanDictionary: async (dictionaryTitle) => {
deleted.push(dictionaryTitle);
importedRevision = null;
return true;
},
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
upserts.push({ title: dictionaryTitle, scope: profileScope });
return true;
},
now: () => 1000,
logInfo: (message) => {
logs.push(message);
},
});
await runtime.runSyncNow();
assert.deepEqual(mergedBuilds, [[130298]]);
assert.deepEqual(imported, ['/tmp/subminer-character-dictionary.zip']);
assert.deepEqual(deleted, []);
assert.deepEqual(upserts, [{ title: 'SubMiner Character Dictionary', scope: 'all' }]);
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
activeMediaIds: number[];
mergedRevision: string | null;
mergedDictionaryTitle: string | null;
};
assert.deepEqual(state.activeMediaIds, [130298]);
assert.equal(state.mergedRevision, 'rev-1');
assert.equal(state.mergedDictionaryTitle, 'SubMiner Character Dictionary');
assert.deepEqual(logs, [
'[dictionary:auto-sync] syncing current anime snapshot',
'[dictionary:auto-sync] active AniList media set: 130298',
'[dictionary:auto-sync] rebuilding merged dictionary for active anime set',
'[dictionary:auto-sync] importing merged dictionary: /tmp/subminer-character-dictionary.zip',
'[dictionary:auto-sync] applying Yomitan settings for SubMiner Character Dictionary',
'[dictionary:auto-sync] synced AniList 130298: SubMiner Character Dictionary (2544 entries)',
]);
});
test('auto sync skips rebuild/import on unchanged revisit when merged dictionary is current', async () => {
const userDataPath = makeTempDir();
const mergedBuilds: number[][] = [];
const imports: string[] = [];
let importedRevision: string | null = null;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 3,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async () => ({
mediaId: 7,
mediaTitle: 'Frieren',
entryCount: 100,
fromCache: true,
updatedAt: 1000,
}),
buildMergedDictionary: async (mediaIds) => {
mergedBuilds.push([...mediaIds]);
return {
zipPath: '/tmp/merged.zip',
revision: 'rev-7',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 100,
};
},
getYomitanDictionaryInfo: async () =>
importedRevision
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
: [],
importYomitanDictionary: async (zipPath) => {
imports.push(zipPath);
importedRevision = 'rev-7';
return true;
},
deleteYomitanDictionary: async () => true,
upsertYomitanDictionarySettings: async () => true,
now: () => 1000,
});
await runtime.runSyncNow();
await runtime.runSyncNow();
assert.deepEqual(mergedBuilds, [[7]]);
assert.deepEqual(imports, ['/tmp/merged.zip']);
});
test('auto sync rebuilds merged dictionary when MRU order changes', async () => {
const userDataPath = makeTempDir();
const sequence = [1, 2, 1];
const mergedBuilds: number[][] = [];
const deleted: string[] = [];
let importedRevision: string | null = null;
let runIndex = 0;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 3,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async () => {
const mediaId = sequence[Math.min(runIndex, sequence.length - 1)]!;
runIndex += 1;
return {
mediaId,
mediaTitle: `Title ${mediaId}`,
entryCount: 10,
fromCache: true,
updatedAt: mediaId,
};
},
buildMergedDictionary: async (mediaIds) => {
mergedBuilds.push([...mediaIds]);
const revision = `rev-${mediaIds.join('-')}`;
return {
zipPath: `/tmp/${revision}.zip`,
revision,
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: mediaIds.length * 10,
};
},
getYomitanDictionaryInfo: async () =>
importedRevision
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
: [],
importYomitanDictionary: async (zipPath) => {
importedRevision = path.basename(zipPath, '.zip');
return true;
},
deleteYomitanDictionary: async (dictionaryTitle) => {
deleted.push(dictionaryTitle);
importedRevision = null;
return true;
},
upsertYomitanDictionarySettings: async () => true,
now: () => 1000,
});
await runtime.runSyncNow();
await runtime.runSyncNow();
await runtime.runSyncNow();
assert.deepEqual(mergedBuilds, [[1], [2, 1], [1, 2]]);
assert.ok(deleted.length >= 2);
});
test('auto sync evicts least recently used media from merged set', async () => {
const userDataPath = makeTempDir();
const sequence = [1, 2, 3, 4];
const mergedBuilds: number[][] = [];
let runIndex = 0;
let importedRevision: string | null = null;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 3,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async () => {
const mediaId = sequence[Math.min(runIndex, sequence.length - 1)]!;
runIndex += 1;
return {
mediaId,
mediaTitle: `Title ${mediaId}`,
entryCount: 10,
fromCache: true,
updatedAt: mediaId,
};
},
buildMergedDictionary: async (mediaIds) => {
mergedBuilds.push([...mediaIds]);
const revision = `rev-${mediaIds.join('-')}`;
return {
zipPath: `/tmp/${revision}.zip`,
revision,
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: mediaIds.length * 10,
};
},
getYomitanDictionaryInfo: async () =>
importedRevision
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
: [],
importYomitanDictionary: async (zipPath) => {
importedRevision = path.basename(zipPath, '.zip');
return true;
},
deleteYomitanDictionary: async () => {
importedRevision = null;
return true;
},
upsertYomitanDictionarySettings: async () => true,
now: () => Date.now(),
});
await runtime.runSyncNow();
await runtime.runSyncNow();
await runtime.runSyncNow();
await runtime.runSyncNow();
assert.deepEqual(mergedBuilds, [[1], [2, 1], [3, 2, 1], [4, 3, 2]]);
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
activeMediaIds: number[];
};
assert.deepEqual(state.activeMediaIds, [4, 3, 2]);
});

View File

@@ -0,0 +1,257 @@
import * as fs from 'fs';
import * as path from 'path';
import type { AnilistCharacterDictionaryProfileScope } from '../../types';
import type {
CharacterDictionarySnapshotResult,
MergedCharacterDictionaryBuildResult,
} from '../character-dictionary-runtime';
type AutoSyncState = {
activeMediaIds: number[];
mergedRevision: string | null;
mergedDictionaryTitle: string | null;
};
type AutoSyncDictionaryInfo = {
title: string;
revision?: string | number;
};
export interface CharacterDictionaryAutoSyncConfig {
enabled: boolean;
maxLoaded: number;
profileScope: AnilistCharacterDictionaryProfileScope;
}
export interface CharacterDictionaryAutoSyncRuntimeDeps {
userDataPath: string;
getConfig: () => CharacterDictionaryAutoSyncConfig;
getOrCreateCurrentSnapshot: (targetPath?: string) => Promise<CharacterDictionarySnapshotResult>;
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
upsertYomitanDictionarySettings: (
dictionaryTitle: string,
profileScope: AnilistCharacterDictionaryProfileScope,
) => Promise<boolean>;
now: () => number;
schedule?: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
clearSchedule?: (timer: ReturnType<typeof setTimeout>) => void;
operationTimeoutMs?: number;
logInfo?: (message: string) => void;
logWarn?: (message: string) => void;
}
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function readAutoSyncState(statePath: string): AutoSyncState {
try {
const raw = fs.readFileSync(statePath, 'utf8');
const parsed = JSON.parse(raw) as Partial<AutoSyncState>;
const activeMediaIds = Array.isArray(parsed.activeMediaIds)
? parsed.activeMediaIds
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
.map((value) => Math.max(1, Math.floor(value)))
.filter((value, index, all) => all.indexOf(value) === index)
: [];
return {
activeMediaIds,
mergedRevision:
typeof parsed.mergedRevision === 'string' && parsed.mergedRevision.length > 0
? parsed.mergedRevision
: null,
mergedDictionaryTitle:
typeof parsed.mergedDictionaryTitle === 'string' && parsed.mergedDictionaryTitle.length > 0
? parsed.mergedDictionaryTitle
: null,
};
} catch {
return {
activeMediaIds: [],
mergedRevision: null,
mergedDictionaryTitle: null,
};
}
}
function writeAutoSyncState(statePath: string, state: AutoSyncState): void {
ensureDir(path.dirname(statePath));
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
}
function arraysEqual(left: number[], right: number[]): boolean {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
if (left[i] !== right[i]) return false;
}
return true;
}
export function createCharacterDictionaryAutoSyncRuntimeService(
deps: CharacterDictionaryAutoSyncRuntimeDeps,
): {
scheduleSync: () => void;
runSyncNow: () => Promise<void>;
} {
const dictionariesDir = path.join(deps.userDataPath, 'character-dictionaries');
const statePath = path.join(dictionariesDir, 'auto-sync-state.json');
const schedule = deps.schedule ?? ((fn, delayMs) => setTimeout(fn, delayMs));
const clearSchedule = deps.clearSchedule ?? ((timer) => clearTimeout(timer));
const debounceMs = 800;
const operationTimeoutMs = Math.max(1, Math.floor(deps.operationTimeoutMs ?? 7_000));
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let syncInFlight = false;
let runQueued = false;
const withOperationTimeout = async <T>(label: string, promise: Promise<T>): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${operationTimeoutMs}ms`));
}, operationTimeoutMs);
}),
]);
} finally {
if (timer !== null) {
clearTimeout(timer);
}
}
};
const runSyncOnce = async (): Promise<void> => {
const config = deps.getConfig();
if (!config.enabled) {
return;
}
deps.logInfo?.('[dictionary:auto-sync] syncing current anime snapshot');
const snapshot = await deps.getOrCreateCurrentSnapshot();
const state = readAutoSyncState(statePath);
const nextActiveMediaIds = [
snapshot.mediaId,
...state.activeMediaIds.filter((mediaId) => mediaId !== snapshot.mediaId),
].slice(0, Math.max(1, Math.floor(config.maxLoaded)));
deps.logInfo?.(
`[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds.join(', ')}`,
);
const retainedChanged = !arraysEqual(nextActiveMediaIds, state.activeMediaIds);
let merged: MergedCharacterDictionaryBuildResult | null = null;
if (
retainedChanged ||
!state.mergedRevision ||
!state.mergedDictionaryTitle ||
!snapshot.fromCache
) {
deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set');
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
}
const dictionaryTitle = merged?.dictionaryTitle ?? state.mergedDictionaryTitle;
const revision = merged?.revision ?? state.mergedRevision;
if (!dictionaryTitle || !revision) {
throw new Error('Merged character dictionary state is incomplete.');
}
const dictionaryInfo = await withOperationTimeout(
'getYomitanDictionaryInfo',
deps.getYomitanDictionaryInfo(),
);
const existing = dictionaryInfo.find((entry) => entry.title === dictionaryTitle) ?? null;
const existingRevision =
existing && (typeof existing.revision === 'string' || typeof existing.revision === 'number')
? String(existing.revision)
: null;
const shouldImport =
merged !== null ||
existing === null ||
existingRevision === null ||
existingRevision !== revision;
if (shouldImport) {
if (existing !== null) {
await withOperationTimeout(
`deleteYomitanDictionary(${dictionaryTitle})`,
deps.deleteYomitanDictionary(dictionaryTitle),
);
}
if (merged === null) {
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
}
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
const imported = await withOperationTimeout(
`importYomitanDictionary(${path.basename(merged.zipPath)})`,
deps.importYomitanDictionary(merged.zipPath),
);
if (!imported) {
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
}
}
deps.logInfo?.(`[dictionary:auto-sync] applying Yomitan settings for ${dictionaryTitle}`);
await withOperationTimeout(
`upsertYomitanDictionarySettings(${dictionaryTitle})`,
deps.upsertYomitanDictionarySettings(dictionaryTitle, config.profileScope),
);
writeAutoSyncState(statePath, {
activeMediaIds: nextActiveMediaIds,
mergedRevision: merged?.revision ?? revision,
mergedDictionaryTitle: merged?.dictionaryTitle ?? dictionaryTitle,
});
deps.logInfo?.(
`[dictionary:auto-sync] synced AniList ${snapshot.mediaId}: ${dictionaryTitle} (${snapshot.entryCount} entries)`,
);
};
const enqueueSync = (): void => {
runQueued = true;
if (syncInFlight) {
return;
}
syncInFlight = true;
void (async () => {
while (runQueued) {
runQueued = false;
try {
await runSyncOnce();
} catch (error) {
deps.logWarn?.(
`[dictionary:auto-sync] sync failed: ${(error as Error)?.message ?? String(error)}`,
);
}
}
})().finally(() => {
syncInFlight = false;
});
};
return {
scheduleSync: () => {
const config = deps.getConfig();
if (!config.enabled) {
return;
}
if (debounceTimer !== null) {
clearSchedule(debounceTimer);
}
debounceTimer = schedule(() => {
debounceTimer = null;
enqueueSync();
}, debounceMs);
},
runSyncNow: async () => {
await runSyncOnce();
},
};
}

View File

@@ -18,6 +18,7 @@ test('build cli command context deps maps handlers and values', () => {
isOverlayInitialized: () => true,
initializeOverlay: () => calls.push('init'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
openFirstRunSetup: () => calls.push('setup'),
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy'),
startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`),
@@ -46,6 +47,13 @@ test('build cli command context deps maps handlers and values', () => {
openJellyfinSetup: () => calls.push('jellyfin'),
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 10,
}),
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},

View File

@@ -16,6 +16,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -32,6 +33,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup'];
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -60,6 +62,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
openFirstRunSetup: deps.openFirstRunSetup,
setVisibleOverlay: deps.setVisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,
@@ -76,6 +79,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openJellyfinSetup: deps.openJellyfinSetup,
getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow,
generateCharacterDictionary: deps.generateCharacterDictionary,
runJellyfinCommand: deps.runJellyfinCommand,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,

View File

@@ -20,6 +20,7 @@ test('cli command context factory composes main deps and context handlers', () =
showMpvOsd: (text) => calls.push(`osd:${text}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
openFirstRunSetupWindow: () => calls.push('setup'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
@@ -53,6 +54,13 @@ test('cli command context factory composes main deps and context handlers', () =
lastError: null,
}),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 10,
}),
runJellyfinCommand: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},

View File

@@ -23,6 +23,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
openFirstRunSetupWindow: () => calls.push('open-setup'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
@@ -70,6 +71,13 @@ test('cli command context main deps builder maps state and callbacks', async ()
lastError: null,
}),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 10,
}),
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
@@ -100,10 +108,11 @@ test('cli command context main deps builder maps state and callbacks', async ()
assert.equal(deps.shouldOpenBrowser(), true);
deps.showOsd('hello');
deps.initializeOverlay();
deps.openFirstRunSetup();
deps.setVisibleOverlay(true);
deps.printHelp();
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'set-visible:true', 'help']);
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'open-setup', 'set-visible:true', 'help']);
const retry = await deps.retryAnilistQueueNow();
assert.deepEqual(retry, { ok: true, message: 'ok' });

View File

@@ -19,6 +19,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
openFirstRunSetupWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -37,6 +38,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openJellyfinSetupWindow: () => void;
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
@@ -70,6 +72,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
@@ -87,6 +90,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openJellyfinSetup: () => deps.openJellyfinSetupWindow(),
getAnilistQueueStatus: () => deps.getAnilistQueueStatus(),
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) =>
deps.generateCharacterDictionary(targetPath),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
openYomitanSettings: () => deps.openYomitanSettings(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),

View File

@@ -24,6 +24,7 @@ function createDeps() {
isOverlayInitialized: () => true,
initializeOverlay: () => {},
toggleVisibleOverlay: () => {},
openFirstRunSetup: () => {},
setVisibleOverlay: () => {},
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
@@ -40,6 +41,13 @@ function createDeps() {
openJellyfinSetup: () => {},
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 1,
}),
runJellyfinCommand: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},

View File

@@ -21,6 +21,7 @@ export type CliCommandContextFactoryDeps = {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -37,6 +38,7 @@ export type CliCommandContextFactoryDeps = {
openJellyfinSetup: CliCommandRuntimeServiceContext['openJellyfinSetup'];
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -72,6 +74,7 @@ export function createCliCommandContext(
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
openFirstRunSetup: deps.openFirstRunSetup,
setVisibleOverlay: deps.setVisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,
@@ -88,6 +91,7 @@ export function createCliCommandContext(
openJellyfinSetup: deps.openJellyfinSetup,
getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow,
generateCharacterDictionary: deps.generateCharacterDictionary,
runJellyfinCommand: deps.runJellyfinCommand,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,

View File

@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseClipboardVideoPath } from '../../core/services';
import { parseClipboardVideoPath } from '../../core/services/overlay-drop';
type MpvClientLike = {
connected: boolean;

View File

@@ -29,7 +29,7 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
execPath: process.execPath,
resolvePath: (value) => value,
setAsDefaultProtocolClient: () => true,
logWarn: () => {},
logDebug: () => {},
},
});

View File

@@ -26,6 +26,7 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
},
},
appReadyRuntimeMainDeps: {
ensureDefaultConfigBootstrap: () => {},
loadSubtitlePosition: () => {},
resolveKeybindings: () => {},
createMpvClient: () => {},
@@ -37,17 +38,23 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {},
loadYomitanExtension: async () => {},
handleFirstRunSetup: async () => {},
startJellyfinRemoteSession: async () => {},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {},
initializeOverlayRuntime: () => {},
handleInitialArgs: () => {},
logDebug: () => {},

View File

@@ -22,10 +22,13 @@ 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>>;
const contractAssertions = [
true as Assert<IsAssignable<'notifyDeps', RequiredAnilistSetupInputKeys>>,
true as Assert<IsAssignable<'getMpvClient', RequiredJellyfinInputKeys>>,
true as Assert<IsAssignable<'registration', RequiredIpcInputKeys>>,
true as Assert<IsAssignable<'tokenizer', RequiredMpvInputKeys>>,
];
void contractAssertions;
// @ts-expect-error missing required notifyDeps should fail compile-time contract
const anilistMissingRequired: AnilistSetupComposerOptions = {

View File

@@ -16,6 +16,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
playNextSubtitle: () => {},
shiftSubDelayToAdjacentSubtitle: async () => {},
sendMpvCommand: () => {},
getMpvClient: () => null,
isMpvConnected: () => false,
hasRuntimeOptionsManager: () => true,
},

View File

@@ -1,13 +1,15 @@
import type { RuntimeOptionsManager } from '../../runtime-options';
import type { JimakuApiResponse, JimakuLanguagePreference, ResolvedConfig } from '../../types';
import {
isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore,
shouldAutoInitializeOverlayRuntimeFromConfig as shouldAutoInitializeOverlayRuntimeFromConfigCore,
} from '../../core/services/startup';
import {
getJimakuLanguagePreference as getJimakuLanguagePreferenceCore,
getJimakuMaxEntryResults as getJimakuMaxEntryResultsCore,
isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore,
jimakuFetchJson as jimakuFetchJsonCore,
resolveJimakuApiKey as resolveJimakuApiKeyCore,
shouldAutoInitializeOverlayRuntimeFromConfig as shouldAutoInitializeOverlayRuntimeFromConfigCore,
} from '../../core/services';
} from '../../core/services/jimaku';
export type ConfigDerivedRuntimeDeps = {
getResolvedConfig: () => ResolvedConfig;

View File

@@ -1,5 +1,5 @@
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { resolveKeybindings } from '../../core/utils';
import { resolveKeybindings } from '../../core/utils/keybindings';
import { DEFAULT_KEYBINDINGS } from '../../config';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
@@ -24,6 +24,7 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
...config.subtitleStyle,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.ankiConnect.nPlusOne.knownWord,
nameMatchColor: config.subtitleStyle.nameMatchColor,
enableJlpt: config.subtitleStyle.enableJlpt,
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
};

View File

@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
detectInstalledFirstRunPlugin,
installFirstRunPluginToDefaultLocation,
resolvePackagedFirstRunPluginAssets,
} from './first-run-setup-plugin';
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-plugin-test-'));
const result = fn(dir);
if (result instanceof Promise) {
return result.finally(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
}
fs.rmSync(dir, { recursive: true, force: true });
}
test('resolvePackagedFirstRunPluginAssets finds packaged plugin assets', () => {
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
const resolved = resolvePackagedFirstRunPluginAssets({
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
});
assert.deepEqual(resolved, {
pluginDirSource: path.join(pluginRoot, 'subminer'),
pluginConfigSource: path.join(pluginRoot, 'subminer.conf'),
});
});
});
test('installFirstRunPluginToDefaultLocation installs plugin and backs up existing files', () => {
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
fs.mkdirSync(installPaths.pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(path.join(installPaths.pluginDir, 'old.lua'), '-- old plugin');
fs.writeFileSync(installPaths.pluginConfigPath, 'old=true\n');
const result = installFirstRunPluginToDefaultLocation({
platform: 'linux',
homeDir,
xdgConfigHome,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(
fs.readFileSync(path.join(installPaths.pluginDir, 'main.lua'), 'utf8'),
'-- packaged plugin',
);
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n');
const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir);
const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir);
assert.equal(
scriptsDirEntries.some((entry) => entry.startsWith('subminer.bak.')),
true,
);
assert.equal(
scriptOptsEntries.some((entry) => entry.startsWith('subminer.conf.bak.')),
true,
);
});
});
test('installFirstRunPluginToDefaultLocation reports unsupported platforms', () => {
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir: '/tmp/home',
xdgConfigHome: '/tmp/xdg',
dirname: '/tmp/dist/main/runtime',
appPath: '/tmp/app',
resourcesPath: '/tmp/resources',
});
assert.equal(result.ok, false);
assert.equal(result.pluginInstallStatus, 'failed');
assert.match(result.message, /not supported/i);
});

View File

@@ -0,0 +1,100 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
import type { PluginInstallResult } from './first-run-setup-service';
function timestamp(): string {
return new Date().toISOString().replaceAll(':', '-');
}
function backupExistingPath(targetPath: string): void {
if (!fs.existsSync(targetPath)) return;
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
}
export function resolvePackagedFirstRunPluginAssets(deps: {
dirname: string;
appPath: string;
resourcesPath: string;
joinPath?: (...parts: string[]) => string;
existsSync?: (candidate: string) => boolean;
}): { pluginDirSource: string; pluginConfigSource: string } | null {
const joinPath = deps.joinPath ?? path.join;
const existsSync = deps.existsSync ?? fs.existsSync;
const roots = [
joinPath(deps.resourcesPath, 'plugin'),
joinPath(deps.resourcesPath, 'app.asar', 'plugin'),
joinPath(deps.appPath, 'plugin'),
joinPath(deps.dirname, '..', 'plugin'),
joinPath(deps.dirname, '..', '..', 'plugin'),
];
for (const root of roots) {
const pluginDirSource = joinPath(root, 'subminer');
const pluginConfigSource = joinPath(root, 'subminer.conf');
if (existsSync(pluginDirSource) && existsSync(pluginConfigSource)) {
return { pluginDirSource, pluginConfigSource };
}
}
return null;
}
export function detectInstalledFirstRunPlugin(
installPaths: MpvInstallPaths,
deps?: { existsSync?: (candidate: string) => boolean },
): boolean {
const existsSync = deps?.existsSync ?? fs.existsSync;
return existsSync(installPaths.pluginDir) && existsSync(installPaths.pluginConfigPath);
}
export function installFirstRunPluginToDefaultLocation(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
dirname: string;
appPath: string;
resourcesPath: string;
}): PluginInstallResult {
const installPaths = resolveDefaultMpvInstallPaths(
options.platform,
options.homeDir,
options.xdgConfigHome,
);
if (!installPaths.supported) {
return {
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Automatic mpv plugin install is not supported on this platform yet.',
};
}
const assets = resolvePackagedFirstRunPluginAssets({
dirname: options.dirname,
appPath: options.appPath,
resourcesPath: options.resourcesPath,
});
if (!assets) {
return {
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Packaged mpv plugin assets were not found.',
};
}
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
backupExistingPath(installPaths.pluginDir);
backupExistingPath(installPaths.pluginConfigPath);
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
return {
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`,
};
}

View File

@@ -0,0 +1,171 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service';
import type { CliArgs } from '../../cli/args';
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
const result = fn(dir);
if (result instanceof Promise) {
return result.finally(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
}
fs.rmSync(dir, { recursive: true, force: true });
}
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
settings: false,
setup: false,
show: false,
hide: false,
showVisibleOverlay: false,
hideVisibleOverlay: false,
copySubtitle: false,
copySubtitleMultiple: false,
mineSentence: false,
mineSentenceMultiple: false,
updateLastCardFromClipboard: false,
refreshKnownWords: false,
toggleSecondarySub: false,
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
openRuntimeOptions: false,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinLibraries: false,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false,
texthooker: false,
help: false,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
debug: false,
...overrides,
};
}
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
false,
);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
});
test('setup service auto-completes legacy installs with config and dictionaries', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 2,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
message: 'installed',
}),
onStateChanged: () => undefined,
});
const snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'completed');
assert.equal(snapshot.state.completionSource, 'legacy_auto_detected');
assert.equal(snapshot.dictionaryCount, 2);
assert.equal(snapshot.canFinish, true);
});
});
test('setup service requires explicit finish for incomplete installs and supports plugin skip/install', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let dictionaryCount = 0;
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => dictionaryCount,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
message: 'installed',
}),
onStateChanged: () => undefined,
});
const initial = await service.ensureSetupStateInitialized();
assert.equal(initial.state.status, 'incomplete');
assert.equal(initial.canFinish, false);
const skipped = await service.skipPluginInstall();
assert.equal(skipped.state.pluginInstallStatus, 'skipped');
const installed = await service.installMpvPlugin();
assert.equal(installed.state.pluginInstallStatus, 'installed');
assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv');
dictionaryCount = 1;
const refreshed = await service.refreshStatus();
assert.equal(refreshed.canFinish, true);
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.state.completionSource, 'user');
});
});
test('setup service marks cancelled when popup closes before completion', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
await service.ensureSetupStateInitialized();
await service.markSetupInProgress();
const cancelled = await service.markSetupCancelled();
assert.equal(cancelled.state.status, 'cancelled');
});
});

View File

@@ -0,0 +1,225 @@
import fs from 'node:fs';
import {
createDefaultSetupState,
getDefaultConfigFilePaths,
getSetupStatePath,
isSetupCompleted,
readSetupState,
writeSetupState,
type SetupPluginInstallStatus,
type SetupState,
} from '../../shared/setup-state';
import type { CliArgs } from '../../cli/args';
export interface SetupStatusSnapshot {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
message: string | null;
state: SetupState;
}
export interface PluginInstallResult {
ok: boolean;
pluginInstallStatus: SetupPluginInstallStatus;
pluginInstallPathSummary: string | null;
message: string;
}
export interface FirstRunSetupService {
ensureSetupStateInitialized: () => Promise<SetupStatusSnapshot>;
getSetupStatus: () => Promise<SetupStatusSnapshot>;
refreshStatus: (message?: string | null) => Promise<SetupStatusSnapshot>;
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
skipPluginInstall: () => Promise<SetupStatusSnapshot>;
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
isSetupCompleted: () => boolean;
}
function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
return Boolean(
args.toggle ||
args.toggleVisibleOverlay ||
args.settings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
args.hideVisibleOverlay ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.mineSentence ||
args.mineSentenceMultiple ||
args.updateLastCardFromClipboard ||
args.refreshKnownWords ||
args.toggleSecondarySub ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
args.markAudioCard ||
args.openRuntimeOptions ||
args.anilistStatus ||
args.anilistLogout ||
args.anilistSetup ||
args.anilistRetryQueue ||
args.dictionary ||
args.jellyfin ||
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinLibraries ||
args.jellyfinItems ||
args.jellyfinSubtitles ||
args.jellyfinPlay ||
args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth ||
args.texthooker ||
args.help,
);
}
export function shouldAutoOpenFirstRunSetup(args: CliArgs): boolean {
if (args.setup) return true;
if (!args.start && !args.background) return false;
return !hasAnyStartupCommandBeyondSetup(args);
}
function getPluginStatus(
state: SetupState,
pluginInstalled: boolean,
): SetupStatusSnapshot['pluginStatus'] {
if (pluginInstalled) return 'installed';
if (state.pluginInstallStatus === 'skipped') return 'skipped';
if (state.pluginInstallStatus === 'failed') return 'failed';
return 'optional';
}
export function createFirstRunSetupService(deps: {
configDir: string;
getYomitanDictionaryCount: () => Promise<number>;
detectPluginInstalled: () => boolean | Promise<boolean>;
installPlugin: () => Promise<PluginInstallResult>;
onStateChanged?: (state: SetupState) => void;
}): FirstRunSetupService {
const setupStatePath = getSetupStatePath(deps.configDir);
const configFilePaths = getDefaultConfigFilePaths(deps.configDir);
let completed = false;
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
const writeState = (state: SetupState): SetupState => {
writeSetupState(setupStatePath, state);
completed = state.status === 'completed';
deps.onStateChanged?.(state);
return state;
};
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
const dictionaryCount = await deps.getYomitanDictionaryCount();
const pluginInstalled = await deps.detectPluginInstalled();
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
return {
configReady,
dictionaryCount,
canFinish: dictionaryCount >= 1,
pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary,
message,
state,
} satisfies SetupStatusSnapshot;
};
const refreshWithState = async (state: SetupState, message: string | null = null) => {
const snapshot = await buildSnapshot(state, message);
if (snapshot.state.lastSeenYomitanDictionaryCount !== snapshot.dictionaryCount) {
snapshot.state = writeState({
...snapshot.state,
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
});
}
return snapshot;
};
return {
ensureSetupStateInitialized: async () => {
const state = readState();
if (isSetupCompleted(state)) {
completed = true;
return refreshWithState(state);
}
const dictionaryCount = await deps.getYomitanDictionaryCount();
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
if (configReady && dictionaryCount >= 1) {
const completedState = writeState({
...state,
status: 'completed',
completedAt: new Date().toISOString(),
completionSource: 'legacy_auto_detected',
lastSeenYomitanDictionaryCount: dictionaryCount,
});
return buildSnapshot(completedState);
}
return refreshWithState(
writeState({
...state,
status: state.status === 'cancelled' ? 'cancelled' : 'incomplete',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: dictionaryCount,
}),
);
},
getSetupStatus: async () => refreshWithState(readState()),
refreshStatus: async (message = null) => refreshWithState(readState(), message),
markSetupInProgress: async () => {
const state = readState();
if (state.status === 'completed') {
completed = true;
return refreshWithState(state);
}
return refreshWithState(writeState({ ...state, status: 'in_progress' }));
},
markSetupCancelled: async () => {
const state = readState();
if (state.status === 'completed') {
completed = true;
return refreshWithState(state);
}
return refreshWithState(writeState({ ...state, status: 'cancelled' }));
},
markSetupCompleted: async () => {
const state = readState();
const snapshot = await buildSnapshot(state);
if (!snapshot.canFinish) {
return snapshot;
}
return refreshWithState(
writeState({
...state,
status: 'completed',
completedAt: new Date().toISOString(),
completionSource: 'user',
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
}),
);
},
skipPluginInstall: async () =>
refreshWithState(writeState({ ...readState(), pluginInstallStatus: 'skipped' })),
installMpvPlugin: async () => {
const result = await deps.installPlugin();
return refreshWithState(
writeState({
...readState(),
pluginInstallStatus: result.pluginInstallStatus,
pluginInstallPathSummary: result.pluginInstallPathSummary,
}),
result.message,
);
},
isSetupCompleted: () => completed || isSetupCompleted(readState()),
};
}

View File

@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildFirstRunSetupHtml,
createHandleFirstRunSetupNavigationHandler,
createMaybeFocusExistingFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
} from './first-run-setup-window';
test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish state', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 0,
canFinish: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
message: 'Waiting for dictionaries',
});
assert.match(html, /SubMiner setup/);
assert.match(html, /Install mpv plugin/);
assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Finish setup/);
assert.match(html, /disabled/);
});
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
message: null,
});
assert.match(html, /Reinstall mpv plugin/);
});
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
});
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
});
test('first-run setup window handler focuses existing window', () => {
const calls: string[] = [];
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => ({
focus: () => calls.push('focus'),
}),
});
assert.equal(maybeFocus(), true);
assert.deepEqual(calls, ['focus']);
});
test('first-run setup navigation handler prevents default and dispatches action', async () => {
const calls: string[] = [];
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
handleAction: async (action) => {
calls.push(action);
},
logError: (message) => calls.push(message),
});
const prevented = handleNavigation({
url: 'subminer://first-run-setup?action=install-plugin',
preventDefault: () => calls.push('preventDefault'),
});
assert.equal(prevented, true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
});

View File

@@ -0,0 +1,329 @@
type FocusableWindowLike = {
focus: () => void;
};
type FirstRunSetupWebContentsLike = {
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
};
type FirstRunSetupWindowLike = FocusableWindowLike & {
webContents: FirstRunSetupWebContentsLike;
loadURL: (url: string) => unknown;
on: (event: 'closed', handler: () => void) => void;
isDestroyed: () => boolean;
close: () => void;
};
export type FirstRunSetupAction =
| 'install-plugin'
| 'open-yomitan-settings'
| 'refresh'
| 'skip-plugin'
| 'finish';
export interface FirstRunSetupHtmlModel {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
message: string | null;
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'danger'): string {
return `<span class="badge ${tone}">${escapeHtml(value)}</span>`;
}
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
const pluginActionLabel =
model.pluginStatus === 'installed' ? 'Reinstall mpv plugin' : 'Install mpv plugin';
const pluginLabel =
model.pluginStatus === 'installed'
? 'Installed'
: model.pluginStatus === 'skipped'
? 'Skipped'
: model.pluginStatus === 'failed'
? 'Failed'
: 'Optional';
const pluginTone =
model.pluginStatus === 'installed'
? 'ready'
: model.pluginStatus === 'failed'
? 'danger'
: model.pluginStatus === 'skipped'
? 'muted'
: 'warn';
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>SubMiner First-Run Setup</title>
<style>
:root {
color-scheme: dark;
--base: #24273a;
--mantle: #1e2030;
--surface: #363a4f;
--surface-strong: #494d64;
--text: #cad3f5;
--muted: #b8c0e0;
--blue: #8aadf4;
--green: #a6da95;
--yellow: #eed49f;
--red: #ed8796;
}
body {
margin: 0;
background: linear-gradient(180deg, var(--mantle), var(--base));
color: var(--text);
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
padding: 18px;
}
h1 {
margin: 0 0 6px;
font-size: 18px;
}
p {
margin: 0 0 14px;
color: var(--muted);
}
.card {
background: rgba(54, 58, 79, 0.92);
border: 1px solid rgba(202, 211, 245, 0.08);
border-radius: 12px;
padding: 12px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.meta {
color: var(--muted);
font-size: 12px;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 9px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
}
.badge.ready { background: rgba(166, 218, 149, 0.16); color: var(--green); }
.badge.warn { background: rgba(238, 212, 159, 0.18); color: var(--yellow); }
.badge.muted { background: rgba(184, 192, 224, 0.12); color: var(--muted); }
.badge.danger { background: rgba(237, 135, 150, 0.16); color: var(--red); }
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 14px;
}
button {
border: 0;
border-radius: 10px;
padding: 10px 12px;
cursor: pointer;
font-weight: 700;
color: var(--text);
background: var(--surface);
}
button.primary {
background: var(--blue);
color: #1e2030;
}
button.ghost {
background: transparent;
border: 1px solid rgba(202, 211, 245, 0.12);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.message {
min-height: 18px;
margin-top: 12px;
color: var(--muted);
}
.footer {
margin-top: 10px;
color: var(--muted);
font-size: 12px;
}
</style>
</head>
<body>
<main>
<h1>SubMiner setup</h1>
<div class="card">
<div>
<strong>Config file</strong>
<div class="meta">Default config directory seeded automatically.</div>
</div>
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
</div>
<div class="card">
<div>
<strong>mpv plugin</strong>
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
</div>
${renderStatusBadge(pluginLabel, pluginTone)}
</div>
<div class="card">
<div>
<strong>Yomitan dictionaries</strong>
<div class="meta">${model.dictionaryCount} installed</div>
</div>
${renderStatusBadge(
model.dictionaryCount >= 1 ? 'Ready' : 'Missing',
model.dictionaryCount >= 1 ? 'ready' : 'warn',
)}
</div>
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=skip-plugin'">Skip plugin</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
<div class="footer">Finish stays locked until Yomitan reports at least one installed dictionary.</div>
</main>
</body>
</html>`;
}
export function parseFirstRunSetupSubmissionUrl(
rawUrl: string,
): { action: FirstRunSetupAction } | null {
if (!rawUrl.startsWith('subminer://first-run-setup')) {
return null;
}
const parsed = new URL(rawUrl);
const action = parsed.searchParams.get('action');
if (
action !== 'install-plugin' &&
action !== 'open-yomitan-settings' &&
action !== 'refresh' &&
action !== 'skip-plugin' &&
action !== 'finish'
) {
return null;
}
return { action };
}
export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
getSetupWindow: () => FocusableWindowLike | null;
}) {
return (): boolean => {
const window = deps.getSetupWindow();
if (!window) return false;
window.focus();
return true;
};
}
export function createHandleFirstRunSetupNavigationHandler(deps: {
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
handleAction: (action: FirstRunSetupAction) => Promise<unknown>;
logError: (message: string, error: unknown) => void;
}) {
return (params: { url: string; preventDefault: () => void }): boolean => {
const submission = deps.parseSubmissionUrl(params.url);
if (!submission) return false;
params.preventDefault();
void deps.handleAction(submission.action).catch((error) => {
deps.logError('Failed handling first-run setup action', error);
});
return true;
};
}
export function createOpenFirstRunSetupWindowHandler<
TWindow extends FirstRunSetupWindowLike,
>(deps: {
maybeFocusExistingSetupWindow: () => boolean;
createSetupWindow: () => TWindow;
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
handleAction: (action: FirstRunSetupAction) => Promise<{ closeWindow?: boolean } | void>;
markSetupInProgress: () => Promise<unknown>;
markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean;
clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void;
encodeURIComponent: (value: string) => string;
logError: (message: string, error: unknown) => void;
}) {
return (): void => {
if (deps.maybeFocusExistingSetupWindow()) {
return;
}
const setupWindow = deps.createSetupWindow();
deps.setSetupWindow(setupWindow);
const render = async (): Promise<void> => {
const model = await deps.getSetupSnapshot();
const html = deps.buildSetupHtml(model);
await setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`);
};
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: deps.parseSubmissionUrl,
handleAction: async (action) => {
const result = await deps.handleAction(action);
if (result?.closeWindow) {
if (!setupWindow.isDestroyed()) {
setupWindow.close();
}
return;
}
if (!setupWindow.isDestroyed()) {
await render();
}
},
logError: deps.logError,
});
setupWindow.webContents.on('will-navigate', (event, url) => {
handleNavigation({
url,
preventDefault: () => {
if (event && typeof event === 'object' && 'preventDefault' in event) {
(event as { preventDefault?: () => void }).preventDefault?.();
}
},
});
});
setupWindow.on('closed', () => {
if (!deps.isSetupCompleted()) {
void deps.markSetupCancelled().catch((error) => {
deps.logError('Failed marking first-run setup cancelled', error);
});
}
deps.clearSetupWindow();
});
void deps
.markSetupInProgress()
.then(() => render())
.catch((error) => deps.logError('Failed opening first-run setup window', error));
};
}

View File

@@ -19,6 +19,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
playNextSubtitle: () => {},
shiftSubDelayToAdjacentSubtitle: async () => {},
sendMpvCommand: () => {},
getMpvClient: () => null,
isMpvConnected: () => true,
hasRuntimeOptionsManager: () => true,
}),

View File

@@ -16,6 +16,7 @@ test('handle mpv command handler forwards command and built deps', () => {
playNextSubtitle: () => {},
shiftSubDelayToAdjacentSubtitle: async () => {},
sendMpvCommand: () => {},
getMpvClient: () => null,
isMpvConnected: () => true,
hasRuntimeOptionsManager: () => true,
};

View File

@@ -15,6 +15,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
calls.push(`shift:${direction}`);
},
sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`),
getMpvClient: () => ({ connected: true, requestProperty: async () => null }),
isMpvConnected: () => true,
hasRuntimeOptionsManager: () => false,
})();
@@ -27,6 +28,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.playNextSubtitle();
void deps.shiftSubDelayToAdjacentSubtitle('next');
deps.sendMpvCommand(['show-text', 'ok']);
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
assert.equal(deps.isMpvConnected(), true);
assert.equal(deps.hasRuntimeOptionsManager(), false);
assert.deepEqual(calls, [

View File

@@ -12,6 +12,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
playNextSubtitle: () => deps.playNextSubtitle(),
shiftSubDelayToAdjacentSubtitle: (direction) => deps.shiftSubDelayToAdjacentSubtitle(direction),
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
getMpvClient: () => deps.getMpvClient(),
isMpvConnected: () => deps.isMpvConnected(),
hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),
});

View File

@@ -19,6 +19,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void;
refreshDiscordPresence: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
scheduleCharacterDictionarySync?: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
@@ -30,6 +31,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
deps.refreshDiscordPresence();
if (connected) {
deps.syncOverlayMpvSubtitleSuppression();
deps.scheduleCharacterDictionarySync?.();
return;
}
deps.reportJellyfinRemoteStopped();
@@ -67,8 +69,8 @@ export function createBindMpvClientEventHandlers(deps: {
onSubtitleAssChange: (payload: { text: string }) => void;
onSecondarySubtitleChange: (payload: { text: string }) => void;
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
onMediaPathChange: (payload: { path: string }) => void;
onMediaTitleChange: (payload: { title: string }) => void;
onMediaPathChange: (payload: { path: string | null }) => void;
onMediaTitleChange: (payload: { title: string | null }) => void;
onTimePosChange: (payload: { time: number }) => void;
onPauseChange: (payload: { paused: boolean }) => void;
onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void;

View File

@@ -57,6 +57,7 @@ test('media path change handler reports stop for empty path and probes media key
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
refreshDiscordPresence: () => calls.push('presence'),
});
@@ -80,6 +81,7 @@ test('media title change handler clears guess state and syncs immersion', () =>
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`),
syncImmersionMediaState: () => calls.push('sync'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
refreshDiscordPresence: () => calls.push('presence'),
});
@@ -89,6 +91,7 @@ test('media title change handler clears guess state and syncs immersion', () =>
'reset-guess',
'notify:Episode 1',
'sync',
'dict-sync',
'presence',
]);
});

View File

@@ -39,11 +39,13 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
maybeProbeAnilistDuration: (mediaKey: string) => void;
ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void;
scheduleCharacterDictionarySync?: () => void;
refreshDiscordPresence: () => void;
}) {
return ({ path }: { path: string }): void => {
deps.updateCurrentMediaPath(path);
if (!path) {
return ({ path }: { path: string | null }): void => {
const normalizedPath = typeof path === 'string' ? path : '';
deps.updateCurrentMediaPath(normalizedPath);
if (!normalizedPath) {
deps.reportJellyfinRemoteStopped();
deps.restoreMpvSubVisibility();
}
@@ -54,6 +56,9 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.ensureAnilistMediaGuess(mediaKey);
}
deps.syncImmersionMediaState();
if (normalizedPath.trim().length > 0) {
deps.scheduleCharacterDictionarySync?.();
}
deps.refreshDiscordPresence();
};
}
@@ -63,13 +68,18 @@ export function createHandleMpvMediaTitleChangeHandler(deps: {
resetAnilistMediaGuessState: () => void;
notifyImmersionTitleUpdate: (title: string) => void;
syncImmersionMediaState: () => void;
scheduleCharacterDictionarySync?: () => void;
refreshDiscordPresence: () => void;
}) {
return ({ title }: { title: string }): void => {
deps.updateCurrentMediaTitle(title);
return ({ title }: { title: string | null }): void => {
const normalizedTitle = typeof title === 'string' ? title : '';
deps.updateCurrentMediaTitle(normalizedTitle);
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate(title);
deps.notifyImmersionTitleUpdate(normalizedTitle);
deps.syncImmersionMediaState();
if (normalizedTitle.trim().length > 0) {
deps.scheduleCharacterDictionarySync?.();
}
deps.refreshDiscordPresence();
};
}

View File

@@ -20,6 +20,7 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
scheduleCharacterDictionarySync?: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
@@ -66,6 +67,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
@@ -103,6 +105,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvMediaTitleChange = createHandleMpvMediaTitleChangeHandler({
@@ -110,6 +113,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({

View File

@@ -33,6 +33,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
maybeProbeAnilistDuration: (mediaKey: string) => void;
ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void;
scheduleCharacterDictionarySync?: () => void;
updateCurrentMediaTitle: (title: string) => void;
resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
@@ -81,6 +82,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title: string) => {

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveProxyCommandOsdRuntime } from './mpv-proxy-osd';
function createClient() {
return {
connected: true,
requestProperty: async (name: string) => {
if (name === 'sid') return 3;
if (name === 'secondary-sid') return 8;
if (name === 'track-list') {
return [
{ id: 3, type: 'sub', title: 'Japanese', selected: true, external: false },
{ id: 8, type: 'sub', title: 'English Commentary', external: true },
];
}
return null;
},
};
}
test('resolveProxyCommandOsdRuntime formats the active primary subtitle track label', async () => {
const result = await resolveProxyCommandOsdRuntime(['cycle', 'sid'], () => createClient());
assert.equal(result, 'Subtitle track: Internal #3 - Japanese (active)');
});
test('resolveProxyCommandOsdRuntime formats the active secondary subtitle track label', async () => {
const result = await resolveProxyCommandOsdRuntime(
['set_property', 'secondary-sid', 'auto'],
() => createClient(),
);
assert.equal(result, 'Secondary subtitle track: External #8 - English Commentary');
});

View File

@@ -0,0 +1,100 @@
type MpvPropertyClientLike = {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
type MpvSubtitleTrack = {
id?: number;
type?: string;
selected?: boolean;
external?: boolean;
lang?: string;
title?: string;
};
function getTrackOsdPrefix(command: (string | number)[]): string | null {
const operation = typeof command[0] === 'string' ? command[0] : '';
const property = typeof command[1] === 'string' ? command[1] : '';
const modifiesProperty =
operation === 'add' ||
operation === 'set' ||
operation === 'set_property' ||
operation === 'cycle' ||
operation === 'cycle-values' ||
operation === 'multiply';
if (!modifiesProperty) return null;
if (property === 'sid') return 'Subtitle track';
if (property === 'secondary-sid') return 'Secondary subtitle track';
return null;
}
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed.length || trimmed === 'no' || trimmed === 'auto') {
return null;
}
const parsed = Number(trimmed);
if (Number.isInteger(parsed)) {
return parsed;
}
}
return null;
}
function normalizeTrackList(trackListRaw: unknown): MpvSubtitleTrack[] {
if (!Array.isArray(trackListRaw)) return [];
return trackListRaw
.filter(
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
)
.map((track) => {
const id = parseTrackId(track.id);
return {
...track,
id: id === null ? undefined : id,
} as MpvSubtitleTrack;
});
}
function formatSubtitleTrackLabel(track: MpvSubtitleTrack): string {
const trackId = typeof track.id === 'number' ? track.id : -1;
const source = track.external ? 'External' : 'Internal';
const label = track.lang || track.title || 'unknown';
const active = track.selected ? ' (active)' : '';
return `${source} #${trackId} - ${label}${active}`;
}
export async function resolveProxyCommandOsdRuntime(
command: (string | number)[],
getMpvClient: () => MpvPropertyClientLike | null,
): Promise<string | null> {
const prefix = getTrackOsdPrefix(command);
if (!prefix) return null;
const client = getMpvClient();
if (!client?.connected) return null;
const property = prefix === 'Subtitle track' ? 'sid' : 'secondary-sid';
const [trackListRaw, trackIdRaw] = await Promise.all([
client.requestProperty('track-list'),
client.requestProperty(property),
]);
const trackId = parseTrackId(trackIdRaw);
if (trackId === null) {
return `${prefix}: none`;
}
const track = normalizeTrackList(trackListRaw).find(
(entry) => entry.type === 'sub' && entry.id === trackId,
);
if (!track) {
return `${prefix}: #${trackId}`;
}
return `${prefix}: ${formatSubtitleTrackLabel(track)}`;
}

View File

@@ -5,7 +5,8 @@ async function loadRegistryOrSkip(t: test.TestContext) {
try {
return await import('./registry');
} catch (error) {
if (error instanceof Error && error.message.includes('node:sqlite')) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('node:sqlite')) {
t.skip('registry import requires node:sqlite support in this runtime');
return null;
}

View File

@@ -42,13 +42,12 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
calls.some((entry) => entry.includes('notify:SubMiner:1 config validation issue(s) detected.')),
);
assert.ok(calls.some((entry) => entry.includes('1. ankiConnect.pollingRate: must be >= 50')));
assert.ok(
calls.some((entry) =>
entry.includes(
'dialog:SubMiner config validation warning:SubMiner detected config validation issues.',
),
const showedWarningDialog = calls.some((entry) =>
entry.includes(
'dialog:SubMiner config validation warning:SubMiner detected config validation issues.',
),
);
assert.equal(showedWarningDialog, process.platform === 'darwin');
assert.ok(calls.some((entry) => entry.includes('actual=10 fallback=250')));
assert.ok(calls.includes('hotReload:start'));
assert.deepEqual(refreshCalls, [{ force: true }]);

View File

@@ -1,8 +1,8 @@
import type { MpvIpcClient } from '../../core/services';
import type { MpvIpcClient } from '../../core/services/mpv';
import {
runSubsyncManualFromIpcRuntime,
triggerSubsyncFromConfigRuntime,
} from '../../core/services';
} from '../../core/services/subsync-runner';
import type {
SubsyncResult,
SubsyncManualPayload,

View File

@@ -34,6 +34,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => 'N2',
getJlptEnabled: () => true,
getNameMatchEnabled: () => false,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'surface',
getFrequencyRank: () => 5,
@@ -48,10 +49,39 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
deps.setYomitanParserInitPromise(null);
assert.equal(deps.getNPlusOneEnabled?.(), true);
assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3);
assert.equal(deps.getNameMatchEnabled?.(), false);
assert.equal(deps.getFrequencyDictionaryMatchMode?.(), 'surface');
assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']);
});
test('tokenizer deps builder disables name matching when character dictionary is disabled', () => {
const deps = createBuildTokenizerDepsMainHandler({
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => undefined,
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => undefined,
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => undefined,
isKnownWord: () => false,
recordLookup: () => undefined,
getKnownWordMatchMode: () => 'surface',
getNPlusOneEnabled: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => 'N2',
getJlptEnabled: () => true,
getCharacterDictionaryEnabled: () => false,
getNameMatchEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'surface',
getFrequencyRank: () => 5,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
})();
assert.equal(deps.getNameMatchEnabled?.(), false);
});
test('mecab tokenizer check creates tokenizer once and runs availability check', async () => {
const calls: string[] = [];
type Tokenizer = { id: string };

View File

@@ -2,6 +2,8 @@ import type { TokenizerDepsRuntimeOptions } from '../../core/services/tokenizer'
type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
getJlptEnabled: NonNullable<TokenizerDepsRuntimeOptions['getJlptEnabled']>;
getCharacterDictionaryEnabled?: () => boolean;
getNameMatchEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchEnabled']>;
getFrequencyDictionaryEnabled: NonNullable<
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
>;
@@ -43,6 +45,12 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
getMinSentenceWordsForNPlusOne: () => deps.getMinSentenceWordsForNPlusOne(),
getJlptLevel: (text: string) => deps.getJlptLevel(text),
getJlptEnabled: () => deps.getJlptEnabled(),
...(deps.getNameMatchEnabled
? {
getNameMatchEnabled: () =>
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchEnabled!(),
}
: {}),
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),

View File

@@ -42,6 +42,7 @@ test('build tray template handler wires actions and init guards', () => {
const buildTemplate = createBuildTrayMenuTemplateHandler({
buildTrayMenuTemplateRuntime: (handlers) => {
handlers.openOverlay();
handlers.openFirstRunSetup();
handlers.openYomitanSettings();
handlers.openRuntimeOptions();
handlers.openJellyfinSetup();
@@ -55,6 +56,8 @@ test('build tray template handler wires actions and init guards', () => {
},
isOverlayRuntimeInitialized: () => initialized,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
@@ -67,6 +70,7 @@ test('build tray template handler wires actions and init guards', () => {
assert.deepEqual(calls, [
'init',
'visible:true',
'setup',
'yomitan',
'runtime-options',
'jellyfin',

View File

@@ -29,6 +29,8 @@ export function createResolveTrayIconPathHandler(deps: {
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -38,6 +40,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -52,6 +56,10 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
}
deps.setVisibleOverlayVisible(true);
},
openFirstRunSetup: () => {
deps.openFirstRunSetupWindow();
},
showFirstRunSetup: deps.showFirstRunSetup(),
openYomitanSettings: () => {
deps.openYomitanSettings();
},

View File

@@ -25,6 +25,8 @@ test('tray main deps builders return mapped handlers', () => {
initializeOverlayRuntime: () => calls.push('init'),
isOverlayRuntimeInitialized: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
@@ -34,6 +36,8 @@ test('tray main deps builders return mapped handlers', () => {
const template = menuDeps.buildTrayMenuTemplateRuntime({
openOverlay: () => calls.push('open-overlay'),
openFirstRunSetup: () => calls.push('open-setup'),
showFirstRunSetup: true,
openYomitanSettings: () => calls.push('open-yomitan'),
openRuntimeOptions: () => calls.push('open-runtime-options'),
openJellyfinSetup: () => calls.push('open-jellyfin'),

View File

@@ -28,6 +28,8 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -37,6 +39,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -48,6 +52,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
initializeOverlayRuntime: deps.initializeOverlayRuntime,
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
showFirstRunSetup: deps.showFirstRunSetup,
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,

View File

@@ -27,6 +27,8 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
setVisibleOverlayVisible: (visible) => {
visibleOverlay = visible;
},
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => {},
openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {},
openJellyfinSetupWindow: () => {},

View File

@@ -30,6 +30,8 @@ test('tray menu template contains expected entries and handlers', () => {
const calls: string[] = [];
const template = buildTrayMenuTemplateRuntime({
openOverlay: () => calls.push('overlay'),
openFirstRunSetup: () => calls.push('setup'),
showFirstRunSetup: true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptions: () => calls.push('runtime'),
openJellyfinSetup: () => calls.push('jellyfin'),
@@ -37,9 +39,26 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 7);
assert.equal(template.length, 8);
template[0]!.click?.();
template[5]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[6]!.click?.();
template[6]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[7]!.click?.();
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
});
test('tray menu template omits first-run setup entry when setup is complete', () => {
const labels = buildTrayMenuTemplateRuntime({
openOverlay: () => undefined,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openJellyfinSetup: () => undefined,
openAnilistSetup: () => undefined,
quitApp: () => undefined,
})
.map((entry) => entry.label)
.filter(Boolean);
assert.equal(labels.includes('Complete Setup'), false);
});

View File

@@ -31,6 +31,8 @@ export function resolveTrayIconPathRuntime(deps: {
export type TrayMenuActionHandlers = {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -48,6 +50,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Open Overlay',
click: handlers.openOverlay,
},
...(handlers.showFirstRunSetup
? [
{
label: 'Complete Setup',
click: handlers.openFirstRunSetup,
},
]
: []),
{
label: 'Open Yomitan Settings',
click: handlers.openYomitanSettings,

View File

@@ -147,6 +147,7 @@ export interface AppState {
yomitanParserWindow: BrowserWindow | null;
anilistSetupWindow: BrowserWindow | null;
jellyfinSetupWindow: BrowserWindow | null;
firstRunSetupWindow: BrowserWindow | null;
yomitanParserReadyPromise: Promise<void> | null;
yomitanParserInitPromise: Promise<boolean> | null;
mpvClient: MpvIpcClient | null;
@@ -193,6 +194,7 @@ export interface AppState {
frequencyRankLookup: FrequencyDictionaryLookup;
anilistSetupPageOpened: boolean;
anilistRetryQueueState: AnilistRetryQueueState;
firstRunSetupCompleted: boolean;
}
export interface AppStateInitialValues {
@@ -221,6 +223,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
yomitanParserWindow: null,
anilistSetupWindow: null,
jellyfinSetupWindow: null,
firstRunSetupWindow: null,
yomitanParserReadyPromise: null,
yomitanParserInitPromise: null,
mpvClient: null,
@@ -269,6 +272,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
frequencyRankLookup: () => null,
anilistSetupPageOpened: false,
anilistRetryQueueState: createInitialAnilistRetryQueueState(),
firstRunSetupCompleted: false,
};
}