Fix Windows mpv logging and add log export (#88)

This commit is contained in:
2026-05-26 00:31:38 -07:00
committed by GitHub
parent 43ebc7d371
commit 11c196821d
150 changed files with 2748 additions and 582 deletions
-1
View File
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
+24 -17
View File
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
refreshKnownWords: false,
openRuntimeOptions: false,
openSessionHelp: false,
openCharacterDictionary: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
@@ -785,6 +784,30 @@ test('handleCliCommand dispatches cycle-runtime-option session action', async ()
});
});
test('handleCliCommand dispatches generic session action payloads', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(
makeArgs({
sessionAction: {
actionId: 'openCharacterDictionaryManager',
},
}),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'openCharacterDictionaryManager',
});
});
test('handleCliCommand dispatches mark-watched session action', async () => {
let request: unknown = null;
const { deps } = createDeps({
@@ -801,22 +824,6 @@ test('handleCliCommand dispatches mark-watched session action', async () => {
});
});
test('handleCliCommand opens character dictionary manager from CLI flag', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(makeArgs({ openCharacterDictionary: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'openCharacterDictionaryManager',
});
});
test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
+7 -7
View File
@@ -384,7 +384,13 @@ export function handleCliCommand(
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
}
if (args.toggle || args.toggleVisibleOverlay) {
if (args.sessionAction) {
dispatchCliSessionAction(
args.sessionAction,
`sessionAction:${args.sessionAction.actionId}`,
'Session action failed',
);
} else if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay();
} else if (args.togglePrimarySubtitleBar) {
deps.togglePrimarySubtitleBar();
@@ -490,12 +496,6 @@ export function handleCliCommand(
'openSessionHelp',
'Open session help failed',
);
} else if (args.openCharacterDictionary) {
dispatchCliSessionAction(
{ actionId: 'openCharacterDictionaryManager' },
'openCharacterDictionaryManager',
'Open character dictionary failed',
);
} else if (args.openControllerSelect) {
dispatchCliSessionAction(
{ actionId: 'openControllerSelect' },
@@ -25,6 +25,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
next.stats.toggleKey = 'F8';
next.stats.markWatchedKey = 'F9';
next.logging.level = 'debug';
next.logging.rotation = 14;
next.logging.files.mpv = true;
next.youtube.primarySubLanguages = ['ja', 'en'];
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
next.subsync.replace = !prev.subsync.replace;
@@ -56,6 +58,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
'mpv.aniskipButtonKey',
'stats.markWatchedKey',
'logging.level',
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'jimaku.maxEntryResults',
'subsync.replace',
+2
View File
@@ -61,6 +61,8 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'jimaku',
'subsync',
+76
View File
@@ -23,6 +23,37 @@ function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClie
};
}
function captureWarnLogs(run: () => void): string[] {
const originalWarn = console.warn;
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
const originalAppLog = process.env.SUBMINER_APP_LOG;
const messages: string[] = [];
console.warn = (...args: unknown[]) => {
messages.push(args.map(String).join(' '));
};
process.env.SUBMINER_LOG_LEVEL = 'warn';
process.env.SUBMINER_APP_LOG = process.platform === 'win32' ? 'NUL' : '/dev/null';
try {
run();
} finally {
console.warn = originalWarn;
if (originalLogLevel === undefined) {
delete process.env.SUBMINER_LOG_LEVEL;
} else {
process.env.SUBMINER_LOG_LEVEL = originalLogLevel;
}
if (originalAppLog === undefined) {
delete process.env.SUBMINER_APP_LOG;
} else {
process.env.SUBMINER_APP_LOG = originalAppLog;
}
}
return messages;
}
function invokeHandleMessage(client: MpvIpcClient, msg: unknown): Promise<void> {
return (client as unknown as { handleMessage: (msg: unknown) => Promise<void> }).handleMessage(
msg,
@@ -401,6 +432,51 @@ test('MpvIpcClient onClose requests app quit for managed playback', () => {
assert.equal(quitRequests, 1);
});
test('MpvIpcClient only warns once for repeated post-disconnect socket failures', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = () => true;
(client as any).scheduleReconnect = () => {};
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
const messages = captureWarnLogs(() => {
callbacks.onClose();
for (let index = 0; index < 3; index += 1) {
const error = Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), {
code: 'ENOENT',
});
callbacks.onError(error);
callbacks.onClose();
}
});
assert.equal(messages.filter((message) => message.includes('MPV IPC socket closed')).length, 1);
assert.equal(messages.filter((message) => message.includes('MPV IPC socket error')).length, 1);
});
test('MpvIpcClient warns again after MPV reconnects and disconnects later', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = () => true;
(client as any).scheduleReconnect = () => {};
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
const messages = captureWarnLogs(() => {
callbacks.onClose();
callbacks.onError(Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), { code: 'ENOENT' }));
callbacks.onClose();
callbacks.onConnect();
callbacks.onClose();
callbacks.onError(Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), { code: 'ENOENT' }));
callbacks.onClose();
});
assert.equal(messages.filter((message) => message.includes('MPV IPC socket closed')).length, 2);
assert.equal(messages.filter((message) => message.includes('MPV IPC socket error')).length, 2);
});
test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
+69 -6
View File
@@ -136,6 +136,7 @@ type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
export class MpvIpcClient implements MpvClient {
private deps: MpvIpcClientProtocolDeps;
private transport: MpvSocketTransport;
private socketPath: string;
public socket: ReturnType<MpvSocketTransport['getSocket']> = null;
private eventBus = new EventEmitter();
private buffer = '';
@@ -144,6 +145,7 @@ export class MpvIpcClient implements MpvClient {
private reconnectAttempt = 0;
private firstConnection = true;
private hasConnectedOnce = false;
private socketErrorWarnedForDisconnect = false;
public currentVideoPath = '';
public currentMediaTitle: string | null = null;
public currentTimePos = 0;
@@ -180,23 +182,30 @@ export class MpvIpcClient implements MpvClient {
constructor(socketPath: string, deps: MpvIpcClientDeps) {
this.deps = deps;
this.socketPath = socketPath;
this.transport = new MpvSocketTransport({
socketPath,
onConnect: () => {
logger.debug('Connected to MPV socket');
this.connected = true;
this.connecting = false;
this.socket = this.transport.getSocket();
this.reconnectAttempt = 0;
this.hasConnectedOnce = true;
this.socketErrorWarnedForDisconnect = false;
const resolvedConfig = this.deps.getResolvedConfig();
logger.info('MPV IPC socket connected', {
socketPath: this.socketPath,
autoStartOverlay: this.deps.autoStartOverlay,
configAutoStartOverlay: resolvedConfig.auto_start_overlay === true,
});
this.setSecondarySubVisibility(false);
subscribeToMpvProperties(this.send.bind(this));
requestMpvInitialState(this.send.bind(this));
this.emit('connection-change', { connected: true });
const shouldAutoStart =
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true;
this.deps.autoStartOverlay || resolvedConfig.auto_start_overlay === true;
if (this.firstConnection && shouldAutoStart) {
logger.debug('Auto-starting overlay, hiding mpv subtitles');
setTimeout(() => {
@@ -211,18 +220,30 @@ export class MpvIpcClient implements MpvClient {
this.processBuffer();
},
onError: (err: Error) => {
logger.debug('MPV socket error:', err.message);
this.logSocketError(err);
this.failPendingRequests();
},
onClose: () => {
logger.debug('MPV socket closed');
const wasConnected = this.connected;
const shouldQuitOnMpvShutdown = this.deps.shouldQuitOnMpvShutdown?.() === true;
if (wasConnected) {
logger.warn('MPV IPC socket closed', {
socketPath: this.socketPath,
shouldQuitOnMpvShutdown,
});
} else {
logger.debug('MPV IPC socket closed before first connection', {
socketPath: this.socketPath,
reconnectAttempt: this.reconnectAttempt,
});
}
this.connected = false;
this.connecting = false;
this.socket = null;
this.playbackPaused = null;
this.emit('connection-change', { connected: false });
this.failPendingRequests();
if (this.deps.shouldQuitOnMpvShutdown?.() === true) {
if (shouldQuitOnMpvShutdown) {
this.deps.requestAppQuit?.();
return;
}
@@ -261,6 +282,13 @@ export class MpvIpcClient implements MpvClient {
}
setSocketPath(socketPath: string): void {
if (socketPath !== this.socketPath) {
logger.info('MPV IPC socket path updated', {
previousSocketPath: this.socketPath,
socketPath,
});
}
this.socketPath = socketPath;
this.transport.setSocketPath(socketPath);
}
@@ -299,7 +327,9 @@ export class MpvIpcClient implements MpvClient {
getReconnectTimer: () => this.deps.getReconnectTimer(),
setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer),
onReconnectAttempt: (attempt, delay) => {
logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`);
logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`, {
socketPath: this.socketPath,
});
},
connect: () => {
this.connect();
@@ -307,6 +337,39 @@ export class MpvIpcClient implements MpvClient {
});
}
private shouldLogPreConnectionFailure(): boolean {
const nextAttempt = this.reconnectAttempt + 1;
return nextAttempt <= 3 || nextAttempt % 10 === 0;
}
private logSocketError(err: Error): void {
const errorWithCode = err as Error & { code?: unknown };
const details = {
socketPath: this.socketPath,
reconnectAttempt: this.reconnectAttempt,
hasConnectedOnce: this.hasConnectedOnce,
message: err.message,
code: typeof errorWithCode.code === 'string' ? errorWithCode.code : undefined,
};
if (!this.hasConnectedOnce) {
if (this.shouldLogPreConnectionFailure()) {
logger.warn('MPV IPC socket error', details);
return;
}
logger.debug('MPV IPC socket error', details);
return;
}
if (!this.socketErrorWarnedForDisconnect) {
this.socketErrorWarnedForDisconnect = true;
logger.warn('MPV IPC socket error', details);
return;
}
logger.debug('MPV IPC socket error', details);
}
private processBuffer(): void {
const parsed = splitMpvMessagesFromBuffer(
this.buffer,
@@ -46,9 +46,6 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
openRuntimeOptions: () => {
calls.push('openRuntimeOptions');
},
openCharacterDictionary: () => {
calls.push('openCharacterDictionary');
},
openCharacterDictionaryManager: () => {
calls.push('openCharacterDictionaryManager');
},
@@ -163,7 +160,6 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions',
},
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
@@ -197,7 +193,6 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
(_input, accelerator) => accelerator === 'Ctrl+M',
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
@@ -218,7 +213,6 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
(_input, accelerator) => accelerator === 'Ctrl+N',
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
@@ -256,7 +250,6 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
},
{
openRuntimeOptions: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openJimaku: () => {},
markAudioCard: () => {},
@@ -293,7 +286,6 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
},
{
openRuntimeOptions: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openJimaku: () => {},
markAudioCard: () => {},
@@ -322,9 +314,6 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
openRuntimeOptions: () => {
called = true;
},
openCharacterDictionary: () => {
called = true;
},
openCharacterDictionaryManager: () => {
called = true;
},
@@ -410,7 +399,6 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
@@ -438,7 +426,6 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
@@ -6,7 +6,6 @@ const logger = createLogger('main:overlay-shortcut-handler');
export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
markAudioCard: () => void;
@@ -23,7 +22,6 @@ export interface OverlayShortcutFallbackHandlers {
export interface OverlayShortcutRuntimeDeps {
showMpvOsd: (text: string) => void;
openRuntimeOptions: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
@@ -99,9 +97,6 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
openRuntimeOptions: () => {
deps.openRuntimeOptions();
},
openCharacterDictionary: () => {
deps.openCharacterDictionary();
},
openCharacterDictionaryManager: () => {
deps.openCharacterDictionaryManager();
},
@@ -112,7 +107,6 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
const fallbackHandlers: OverlayShortcutFallbackHandlers = {
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
openCharacterDictionary: overlayHandlers.openCharacterDictionary,
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
openJimaku: overlayHandlers.openJimaku,
markAudioCard: overlayHandlers.markAudioCard,
@@ -43,7 +43,6 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
@@ -64,7 +63,6 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
@@ -87,7 +85,6 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
-1
View File
@@ -10,7 +10,6 @@ export interface OverlayShortcutHandlers {
mineSentenceMultiple: (timeoutMs: number) => void;
toggleSecondarySub: () => void;
markAudioCard: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openRuntimeOptions: () => void;
openJimaku: () => void;
@@ -34,7 +34,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
},
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openSessionHelp: () => calls.push('session-help'),
openCharacterDictionary: () => calls.push('character-dictionary'),
openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'),
openControllerSelect: () => calls.push('controller-select'),
openControllerDebug: () => calls.push('controller-debug'),
-4
View File
@@ -18,7 +18,6 @@ export interface SessionActionExecutorDeps {
markActiveVideoWatched: () => Promise<boolean>;
openRuntimeOptionsPalette: () => void;
openSessionHelp: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openControllerSelect: () => void;
openControllerDebug: () => void;
@@ -97,9 +96,6 @@ export async function dispatchSessionAction(
case 'openSessionHelp':
deps.openSessionHelp();
return;
case 'openCharacterDictionary':
deps.openCharacterDictionaryManager();
return;
case 'openCharacterDictionaryManager':
deps.openCharacterDictionaryManager();
return;
+50 -5
View File
@@ -4,7 +4,7 @@ import type { Keybinding } from '../../types';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
import { resolveConfiguredShortcuts } from '../utils/shortcut-config';
import { compileSessionBindings } from './session-bindings';
import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './session-bindings';
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
@@ -220,10 +220,7 @@ test('compileSessionBindings keeps only the character dictionary manager bound b
const characterDictionaryBindings = result.bindings.flatMap((binding) => {
if (binding.actionType !== 'session-action') return [];
if (
binding.actionId !== 'openCharacterDictionary' &&
binding.actionId !== 'openCharacterDictionaryManager'
) {
if (binding.actionId !== 'openCharacterDictionaryManager') {
return [];
}
return [
@@ -471,3 +468,51 @@ test('compileSessionBindings wires every configured shortcut key into the shared
shortcutKeys.map((key) => `shortcuts.${key}`).sort(),
);
});
test('buildPluginSessionBindingsArtifact emits CLI args for plugin-bound session actions', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openCharacterDictionaryManager: 'Ctrl+D',
}),
keybindings: [
createKeybinding('Ctrl+Alt+KeyR', [
`${SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX}anki.autoUpdateNewCards:prev`,
]),
],
platform: 'linux',
});
const artifact = buildPluginSessionBindingsArtifact({
bindings: result.bindings,
warnings: result.warnings,
numericSelectionTimeoutMs: 2500,
now: new Date('2026-05-26T00:00:00.000Z'),
});
const byActionId = new Map(
artifact.bindings.flatMap((binding) =>
binding.actionType === 'session-action' ? [[binding.actionId, binding]] : [],
),
);
const compiledManagerBinding = result.bindings.find(
(binding) =>
binding.actionType === 'session-action' &&
binding.actionId === 'openCharacterDictionaryManager',
);
assert.equal(compiledManagerBinding && 'cliArgs' in compiledManagerBinding, false);
const managerCliArgs = byActionId.get('openCharacterDictionaryManager')?.cliArgs;
const cycleCliArgs = byActionId.get('cycleRuntimeOption')?.cliArgs;
assert.equal(managerCliArgs?.[0], '--session-action');
assert.deepEqual(JSON.parse(managerCliArgs?.[1] ?? ''), {
actionId: 'openCharacterDictionaryManager',
});
assert.equal(cycleCliArgs?.[0], '--session-action');
assert.deepEqual(JSON.parse(cycleCliArgs?.[1] ?? ''), {
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: 'anki.autoUpdateNewCards',
direction: -1,
},
});
});
+18 -1
View File
@@ -4,6 +4,7 @@ import type {
CompiledMpvCommandBinding,
CompiledSessionActionBinding,
CompiledSessionBinding,
PluginSessionBinding,
PluginSessionBindingsArtifact,
SessionActionId,
SessionBindingWarning,
@@ -344,6 +345,22 @@ function getBindingFingerprint(binding: CompiledSessionBinding): string {
return `session:${binding.actionId}:${JSON.stringify(binding.payload ?? null)}`;
}
function buildSessionActionCliArgs(binding: CompiledSessionActionBinding): string[] {
const request =
binding.payload === undefined
? { actionId: binding.actionId }
: { actionId: binding.actionId, payload: binding.payload };
return ['--session-action', JSON.stringify(request)];
}
function toPluginSessionBinding(binding: CompiledSessionBinding): PluginSessionBinding {
if (binding.actionType !== 'session-action') {
return binding;
}
return { ...binding, cliArgs: buildSessionActionCliArgs(binding) };
}
export function compileSessionBindings(input: CompileSessionBindingsInput): {
bindings: CompiledSessionBinding[];
warnings: SessionBindingWarning[];
@@ -516,7 +533,7 @@ export function buildPluginSessionBindingsArtifact(input: {
version: 1,
generatedAt: (input.now ?? new Date()).toISOString(),
numericSelectionTimeoutMs: input.numericSelectionTimeoutMs,
bindings: input.bindings,
bindings: input.bindings.map(toPluginSessionBinding),
warnings: input.warnings,
};
}
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
+13
View File
@@ -29,6 +29,7 @@ export interface StartupBootstrapRuntimeDeps {
argv: string[];
parseArgs: (argv: string[]) => CliArgs;
setLogLevel: (level: string, source: LogLevelSource) => void;
setLogRotation?: (rotation: number) => void;
forceX11Backend: (args: CliArgs) => void;
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
getDefaultSocketPath: () => string;
@@ -95,6 +96,12 @@ interface AppReadyConfigLike {
};
logging?: {
level?: 'debug' | 'info' | 'warn' | 'error';
rotation?: number;
files?: {
app?: boolean;
launcher?: boolean;
mpv?: boolean;
};
};
}
@@ -115,6 +122,10 @@ export interface AppReadyRuntimeDeps {
getConfigWarnings: () => ConfigValidationWarning[];
logConfigWarning: (warning: ConfigValidationWarning) => void;
setLogLevel: (level: string, source: LogLevelSource) => void;
setLogRotation?: (rotation: number) => void;
setLogFileToggles?: (
files: { app?: boolean; launcher?: boolean; mpv?: boolean } | undefined,
) => void;
initRuntimeOptionsManager: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
defaultSecondarySubMode: SecondarySubMode;
@@ -263,6 +274,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
}
deps.setLogLevel(config.logging?.level ?? 'info', 'config');
deps.setLogRotation?.(config.logging?.rotation ?? 7);
deps.setLogFileToggles?.(config.logging?.files);
for (const warning of deps.getConfigWarnings()) {
deps.logConfigWarning(warning);
}
+3
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PartOfSpeech } from '../../types';
import { setLogLevel } from '../../logger';
import { createTokenizerDepsRuntime, TokenizerServiceDeps, tokenizeSubtitle } from './tokenizer';
function makeDeps(overrides: Partial<TokenizerServiceDeps> = {}): TokenizerServiceDeps {
@@ -1865,6 +1866,7 @@ test('tokenizeSubtitle uses Yomitan parser result when available and drops no-he
test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled', async () => {
const infoLogs: string[] = [];
const originalInfo = console.info;
setLogLevel('info');
console.info = (...args: unknown[]) => {
infoLogs.push(args.map((value) => String(value)).join(' '));
};
@@ -1912,6 +1914,7 @@ test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled
);
} finally {
console.info = originalInfo;
setLogLevel(undefined);
}
assert.ok(infoLogs.some((line) => line.includes('Selected Yomitan token groups')));
@@ -601,6 +601,47 @@ test('requestYomitanScanTokens prefers parseText tokenization over termsFind fra
assert.ok(scripts.some((script) => script.includes('termsFind')));
});
test('requestYomitanScanTokens warns when active Yomitan profile has no dictionaries', async () => {
const warnings: Array<{ message: string; details: unknown }> = [];
const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [],
},
},
],
};
}
if (script.includes('parseText')) {
return [];
}
if (script.includes('termsFind')) {
return [];
}
return null;
});
await requestYomitanScanTokens('字幕', deps, {
error: () => undefined,
warn: (message, details) => warnings.push({ message, details }),
});
assert.equal(warnings.length, 1);
assert.match(warnings[0]!.message, /no enabled dictionaries/);
assert.deepEqual(warnings[0]!.details, {
profileIndex: 0,
scanLength: 40,
dictionaryCount: 0,
dictionaries: [],
omittedDictionaryCount: 0,
});
});
test('requestYomitanScanTokens keeps scanner metadata when parse spans agree', async () => {
const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) {
@@ -7,6 +7,7 @@ import { selectYomitanParseTokens } from './parser-selection-stage';
interface LoggerLike {
error: (message: string, ...args: unknown[]) => void;
info?: (message: string, ...args: unknown[]) => void;
warn?: (message: string, ...args: unknown[]) => void;
}
interface YomitanParserRuntimeDeps {
@@ -72,6 +73,7 @@ export interface YomitanAddNoteResult {
const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
const yomitanProfileDiagnosticsLoggedByWindow = new WeakSet<BrowserWindow>();
const yomitanFrequencyCacheByWindow = new WeakMap<
BrowserWindow,
Map<string, YomitanTermFrequency[]>
@@ -532,6 +534,7 @@ async function requestYomitanProfileMetadata(
return null;
}
yomitanProfileMetadataByWindow.set(parserWindow, metadata);
logYomitanProfileDiagnostics(parserWindow, metadata, logger);
return metadata;
} catch (err) {
logger.error('Yomitan parser metadata request failed:', (err as Error).message);
@@ -539,6 +542,37 @@ async function requestYomitanProfileMetadata(
}
}
function logYomitanProfileDiagnostics(
parserWindow: BrowserWindow,
metadata: YomitanProfileMetadata,
logger: LoggerLike,
): void {
if (yomitanProfileDiagnosticsLoggedByWindow.has(parserWindow)) {
return;
}
yomitanProfileDiagnosticsLoggedByWindow.add(parserWindow);
const visibleDictionaries = metadata.dictionaries.slice(0, 8);
const details = {
profileIndex: metadata.profileIndex,
scanLength: metadata.scanLength,
dictionaryCount: metadata.dictionaries.length,
dictionaries: visibleDictionaries,
omittedDictionaryCount: Math.max(0, metadata.dictionaries.length - visibleDictionaries.length),
};
if (metadata.dictionaries.length === 0) {
const logWarning = logger.warn ?? logger.info;
logWarning?.(
'Yomitan active profile has no enabled dictionaries; lookup popups may not show definitions.',
details,
);
return;
}
logger.info?.('Yomitan active profile dictionaries loaded.', details);
}
async function ensureYomitanParserWindow(
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
+12 -3
View File
@@ -14,6 +14,10 @@ type ExtensionCopyResult = {
copied: boolean;
};
type ExtensionCopyOptions = {
platform?: NodeJS.Platform;
};
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
function readManifestVersion(manifestPath: string): string | null {
@@ -142,8 +146,12 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
return sourceHash === null || targetHash === null || sourceHash !== targetHash;
}
export function ensureExtensionCopy(sourceDir: string, userDataPath: string): ExtensionCopyResult {
if (process.platform === 'win32') {
export function ensureExtensionCopy(
sourceDir: string,
userDataPath: string,
options?: ExtensionCopyOptions,
): ExtensionCopyResult {
if ((options?.platform ?? process.platform) === 'win32') {
return { targetDir: sourceDir, copied: false };
}
@@ -167,8 +175,9 @@ export function ensureExtensionCopy(sourceDir: string, userDataPath: string): Ex
export async function ensureExtensionCopyAsync(
sourceDir: string,
userDataPath: string,
options?: ExtensionCopyOptions,
): Promise<ExtensionCopyResult> {
if (process.platform === 'win32') {
if ((options?.platform ?? process.platform) === 'win32') {
return { targetDir: sourceDir, copied: false };
}
@@ -135,7 +135,7 @@ test('ensureExtensionCopy refreshes copied extension when display files change',
'old display code',
);
const result = ensureExtensionCopy(sourceDir, userDataRoot);
const result = ensureExtensionCopy(sourceDir, userDataRoot, { platform: 'linux' });
assert.equal(result.targetDir, targetDir);
assert.equal(result.copied, true);
@@ -170,7 +170,9 @@ test('ensureExtensionCopyAsync refreshes copied extension without completing syn
);
let completed = false;
const resultPromise = ensureExtensionCopyAsync(sourceDir, userDataRoot).then((result) => {
const resultPromise = ensureExtensionCopyAsync(sourceDir, userDataRoot, {
platform: 'linux',
}).then((result) => {
completed = true;
return result;
});
@@ -233,9 +235,9 @@ test('ensureExtensionCopyAsync shares an in-flight refresh for the same copied e
});
try {
const first = ensureExtensionCopyAsync(sourceDir, userDataRoot);
const first = ensureExtensionCopyAsync(sourceDir, userDataRoot, { platform: 'linux' });
await firstCopyStartedPromise;
const second = ensureExtensionCopyAsync(sourceDir, userDataRoot);
const second = ensureExtensionCopyAsync(sourceDir, userDataRoot, { platform: 'linux' });
releaseFirstCopy();
const results = await Promise.all([first, second]);
@@ -142,6 +142,10 @@ export async function loadYomitanExtension(
}
targetSession = session.fromPath(resolvedProfilePath);
logger.info('Loading Yomitan extension from external profile', {
profilePath: resolvedProfilePath,
extensionPath: extPath,
});
} else {
const searchPaths = getYomitanExtensionSearchPaths({
explicitPath: deps.extensionPath,
@@ -174,6 +178,10 @@ export async function loadYomitanExtension(
logger.debug(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
extPath = extensionCopy.targetDir;
logger.info('Loading bundled Yomitan extension', {
extensionPath: extPath,
copied: extensionCopy.copied,
});
}
clearParserState();
@@ -191,6 +199,12 @@ export async function loadYomitanExtension(
}),
);
deps.setYomitanExtension(extension);
logger.info('Yomitan extension loaded', {
extensionId: extension.id,
extensionName: extension.name,
extensionPath: extPath,
externalProfile: externalProfilePath.length > 0,
});
return extension;
} catch (err) {
logger.error('Failed to load Yomitan extension:', (err as Error).message);