mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
Fix Windows mpv logging and add log export (#88)
This commit is contained in:
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
openCharacterDictionary: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user