mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix: harden AI subtitle fix response parsing
This commit is contained in:
@@ -201,7 +201,7 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
return { deps, calls, osd };
|
||||
}
|
||||
|
||||
test('handleCliCommand ignores --start for second-instance when overlay runtime is already initialized', () => {
|
||||
test('handleCliCommand reconnects MPV for second-instance --start when overlay runtime is already initialized', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
});
|
||||
@@ -209,11 +209,9 @@ test('handleCliCommand ignores --start for second-instance when overlay runtime
|
||||
|
||||
handleCliCommand(args, 'second-instance', deps);
|
||||
|
||||
assert.ok(calls.includes('log:Ignoring --start because SubMiner is already running.'));
|
||||
assert.equal(
|
||||
calls.some((value) => value.includes('connectMpvClient')),
|
||||
false,
|
||||
);
|
||||
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock'));
|
||||
assert.equal(calls.some((value) => value.includes('connectMpvClient')), true);
|
||||
assert.equal(calls.some((value) => value.includes('initializeOverlayRuntime')), false);
|
||||
});
|
||||
|
||||
test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
|
||||
|
||||
@@ -259,10 +259,9 @@ export function handleCliCommand(
|
||||
deps.setLogLevel?.(args.logLevel);
|
||||
}
|
||||
|
||||
const ignoreSecondInstanceStart =
|
||||
const reuseSecondInstanceStart =
|
||||
source === 'second-instance' && args.start && deps.isOverlayRuntimeInitialized();
|
||||
const shouldStart =
|
||||
(!ignoreSecondInstanceStart && args.start) || args.toggle || args.toggleVisibleOverlay;
|
||||
const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay;
|
||||
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
|
||||
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
|
||||
|
||||
@@ -285,8 +284,8 @@ export function handleCliCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ignoreSecondInstanceStart) {
|
||||
deps.log('Ignoring --start because SubMiner is already running.');
|
||||
if (reuseSecondInstanceStart) {
|
||||
deps.log('Reusing running SubMiner instance for --start.');
|
||||
}
|
||||
|
||||
if (shouldInitializeOverlayRuntime && !deps.isOverlayRuntimeInitialized()) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
OverlayShortcutRuntimeDeps,
|
||||
runOverlayShortcutLocalFallback,
|
||||
} from './overlay-shortcut-handler';
|
||||
import { shouldActivateOverlayShortcuts } from './overlay-shortcut';
|
||||
|
||||
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
||||
return {
|
||||
@@ -279,3 +280,36 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
|
||||
assert.equal(result, false);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('shouldActivateOverlayShortcuts disables macOS overlay shortcuts when tracked mpv is unfocused', () => {
|
||||
assert.equal(
|
||||
shouldActivateOverlayShortcuts({
|
||||
overlayRuntimeInitialized: true,
|
||||
isMacOSPlatform: true,
|
||||
trackedMpvWindowFocused: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldActivateOverlayShortcuts keeps macOS overlay shortcuts active when tracked mpv is focused', () => {
|
||||
assert.equal(
|
||||
shouldActivateOverlayShortcuts({
|
||||
overlayRuntimeInitialized: true,
|
||||
isMacOSPlatform: true,
|
||||
trackedMpvWindowFocused: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldActivateOverlayShortcuts preserves non-macOS behavior', () => {
|
||||
assert.equal(
|
||||
shouldActivateOverlayShortcuts({
|
||||
overlayRuntimeInitialized: true,
|
||||
isMacOSPlatform: false,
|
||||
trackedMpvWindowFocused: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,20 @@ export interface OverlayShortcutLifecycleDeps {
|
||||
cancelPendingMineSentenceMultiple: () => void;
|
||||
}
|
||||
|
||||
export function shouldActivateOverlayShortcuts(args: {
|
||||
overlayRuntimeInitialized: boolean;
|
||||
isMacOSPlatform: boolean;
|
||||
trackedMpvWindowFocused: boolean;
|
||||
}): boolean {
|
||||
if (!args.overlayRuntimeInitialized) {
|
||||
return false;
|
||||
}
|
||||
if (!args.isMacOSPlatform) {
|
||||
return true;
|
||||
}
|
||||
return args.trackedMpvWindowFocused;
|
||||
}
|
||||
|
||||
export function registerOverlayShortcuts(
|
||||
shortcuts: ConfiguredShortcuts,
|
||||
handlers: OverlayShortcutHandlers,
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
import {
|
||||
refreshOverlayShortcutsRuntime,
|
||||
registerOverlayShortcuts,
|
||||
shouldActivateOverlayShortcuts,
|
||||
syncOverlayShortcutsRuntime,
|
||||
unregisterOverlayShortcutsRuntime,
|
||||
} from '../core/services';
|
||||
} from '../core/services/overlay-shortcut';
|
||||
import { runOverlayShortcutLocalFallback } from '../core/services/overlay-shortcut-handler';
|
||||
|
||||
export interface OverlayShortcutRuntimeServiceInput {
|
||||
@@ -16,6 +17,8 @@ export interface OverlayShortcutRuntimeServiceInput {
|
||||
getShortcutsRegistered: () => boolean;
|
||||
setShortcutsRegistered: (registered: boolean) => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
isMacOSPlatform: () => boolean;
|
||||
isTrackedMpvWindowFocused: () => boolean;
|
||||
showMpvOsd: (text: string) => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJimaku: () => void;
|
||||
@@ -89,7 +92,12 @@ export function createOverlayShortcutsRuntimeService(
|
||||
};
|
||||
};
|
||||
|
||||
const shouldOverlayShortcutsBeActive = () => input.isOverlayRuntimeInitialized();
|
||||
const shouldOverlayShortcutsBeActive = () =>
|
||||
shouldActivateOverlayShortcuts({
|
||||
overlayRuntimeInitialized: input.isOverlayRuntimeInitialized(),
|
||||
isMacOSPlatform: input.isMacOSPlatform(),
|
||||
trackedMpvWindowFocused: input.isTrackedMpvWindowFocused(),
|
||||
});
|
||||
|
||||
return {
|
||||
tryHandleOverlayShortcutLocalFallback: (inputEvent) =>
|
||||
|
||||
@@ -58,6 +58,7 @@ test('media path change handler reports stop for empty path and probes media key
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
@@ -74,6 +75,34 @@ test('media path change handler reports stop for empty path and probes media key
|
||||
]);
|
||||
});
|
||||
|
||||
test('media path change handler signals autoplay-ready fast path for warm non-empty media', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvMediaPathChangeHandler({
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
handler({ path: '/tmp/video.mkv' });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'path:/tmp/video.mkv',
|
||||
'reset:null',
|
||||
'sync',
|
||||
'dict-sync',
|
||||
'autoplay:/tmp/video.mkv',
|
||||
'presence',
|
||||
]);
|
||||
});
|
||||
|
||||
test('media title change handler clears guess state and syncs immersion', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvMediaTitleChangeHandler({
|
||||
|
||||
@@ -40,6 +40,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
}) {
|
||||
return ({ path }: { path: string | null }): void => {
|
||||
@@ -58,6 +59,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
deps.syncImmersionMediaState();
|
||||
if (normalizedPath.trim().length > 0) {
|
||||
deps.scheduleCharacterDictionarySync?.();
|
||||
deps.signalAutoplayReadyIfWarm?.(normalizedPath);
|
||||
}
|
||||
deps.refreshDiscordPresence();
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
@@ -105,6 +106,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
|
||||
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
||||
signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path),
|
||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
@@ -82,6 +83,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
||||
signalAutoplayReadyIfWarm: (path: string) => deps.signalAutoplayReadyIfWarm?.(path),
|
||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
|
||||
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
|
||||
|
||||
@@ -13,6 +13,8 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
calls.push(`registered:${registered}`);
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
isMacOSPlatform: () => true,
|
||||
isTrackedMpvWindowFocused: () => false,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
@@ -40,6 +42,8 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
})();
|
||||
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), true);
|
||||
assert.equal(deps.isMacOSPlatform(), true);
|
||||
assert.equal(deps.isTrackedMpvWindowFocused(), false);
|
||||
assert.equal(deps.getShortcutsRegistered(), false);
|
||||
deps.setShortcutsRegistered(true);
|
||||
assert.equal(shortcutsRegistered, true);
|
||||
|
||||
@@ -8,6 +8,8 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
|
||||
getShortcutsRegistered: () => deps.getShortcutsRegistered(),
|
||||
setShortcutsRegistered: (registered: boolean) => deps.setShortcutsRegistered(registered),
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
isMacOSPlatform: () => deps.isMacOSPlatform(),
|
||||
isTrackedMpvWindowFocused: () => deps.isTrackedMpvWindowFocused(),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openJimaku: () => deps.openJimaku(),
|
||||
|
||||
@@ -21,13 +21,17 @@ import { WindowGeometry } from '../types';
|
||||
export type GeometryChangeCallback = (geometry: WindowGeometry) => void;
|
||||
export type WindowFoundCallback = (geometry: WindowGeometry) => void;
|
||||
export type WindowLostCallback = () => void;
|
||||
export type WindowFocusChangeCallback = (focused: boolean) => void;
|
||||
|
||||
export abstract class BaseWindowTracker {
|
||||
protected currentGeometry: WindowGeometry | null = null;
|
||||
protected windowFound: boolean = false;
|
||||
protected focusKnown: boolean = false;
|
||||
protected windowFocused: boolean = false;
|
||||
public onGeometryChange: GeometryChangeCallback | null = null;
|
||||
public onWindowFound: WindowFoundCallback | null = null;
|
||||
public onWindowLost: WindowLostCallback | null = null;
|
||||
public onWindowFocusChange: WindowFocusChangeCallback | null = null;
|
||||
|
||||
abstract start(): void;
|
||||
abstract stop(): void;
|
||||
@@ -40,6 +44,19 @@ export abstract class BaseWindowTracker {
|
||||
return this.windowFound;
|
||||
}
|
||||
|
||||
isFocused(): boolean {
|
||||
return this.focusKnown ? this.windowFocused : this.windowFound;
|
||||
}
|
||||
|
||||
protected updateFocus(focused: boolean): void {
|
||||
const changed = !this.focusKnown || this.windowFocused !== focused;
|
||||
this.focusKnown = true;
|
||||
this.windowFocused = focused;
|
||||
if (changed) {
|
||||
this.onWindowFocusChange?.(focused);
|
||||
}
|
||||
}
|
||||
|
||||
protected updateGeometry(newGeometry: WindowGeometry | null): void {
|
||||
if (newGeometry) {
|
||||
if (!this.windowFound) {
|
||||
@@ -58,6 +75,12 @@ export abstract class BaseWindowTracker {
|
||||
if (this.onGeometryChange) this.onGeometryChange(newGeometry);
|
||||
}
|
||||
} else {
|
||||
const focusChanged = this.focusKnown && this.windowFocused;
|
||||
this.focusKnown = false;
|
||||
this.windowFocused = false;
|
||||
if (focusChanged) {
|
||||
this.onWindowFocusChange?.(false);
|
||||
}
|
||||
if (this.windowFound) {
|
||||
this.windowFound = false;
|
||||
this.currentGeometry = null;
|
||||
|
||||
@@ -22,9 +22,56 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { BaseWindowTracker } from './base-tracker';
|
||||
import { createLogger } from '../logger';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
const log = createLogger('tracker').child('macos');
|
||||
|
||||
export interface MacOSHelperWindowState {
|
||||
geometry: WindowGeometry;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null {
|
||||
const trimmed = result.trim();
|
||||
if (!trimmed || trimmed === 'not-found') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = trimmed.split(',');
|
||||
if (parts.length !== 4 && parts.length !== 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const x = parseInt(parts[0]!, 10);
|
||||
const y = parseInt(parts[1]!, 10);
|
||||
const width = parseInt(parts[2]!, 10);
|
||||
const height = parseInt(parts[3]!, 10);
|
||||
if (
|
||||
!Number.isFinite(x) ||
|
||||
!Number.isFinite(y) ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const focusedRaw = parts[4]?.trim().toLowerCase();
|
||||
const focused =
|
||||
focusedRaw === undefined ? true : focusedRaw === '1' || focusedRaw === 'true';
|
||||
|
||||
return {
|
||||
geometry: {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
focused,
|
||||
};
|
||||
}
|
||||
|
||||
export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private pollInFlight = false;
|
||||
@@ -173,33 +220,12 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (stdout || '').trim();
|
||||
if (result && result !== 'not-found') {
|
||||
const parts = result.split(',');
|
||||
if (parts.length === 4) {
|
||||
const x = parseInt(parts[0]!, 10);
|
||||
const y = parseInt(parts[1]!, 10);
|
||||
const width = parseInt(parts[2]!, 10);
|
||||
const height = parseInt(parts[3]!, 10);
|
||||
|
||||
if (
|
||||
Number.isFinite(x) &&
|
||||
Number.isFinite(y) &&
|
||||
Number.isFinite(width) &&
|
||||
Number.isFinite(height) &&
|
||||
width > 0 &&
|
||||
height > 0
|
||||
) {
|
||||
this.updateGeometry({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
this.pollInFlight = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const parsed = parseMacOSHelperOutput(stdout || '');
|
||||
if (parsed) {
|
||||
this.updateFocus(parsed.focused);
|
||||
this.updateGeometry(parsed.geometry);
|
||||
this.pollInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateGeometry(null);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseX11WindowGeometry, parseX11WindowPid, X11WindowTracker } from './x11-tracker';
|
||||
import { parseMacOSHelperOutput } from './macos-tracker';
|
||||
|
||||
test('parseX11WindowGeometry parses xwininfo output', () => {
|
||||
const geometry = parseX11WindowGeometry(`
|
||||
@@ -52,3 +53,27 @@ Height: 360`;
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
test('parseMacOSHelperOutput parses geometry and focused state', () => {
|
||||
assert.deepEqual(parseMacOSHelperOutput('120,240,1280,720,1'), {
|
||||
geometry: {
|
||||
x: 120,
|
||||
y: 240,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
focused: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMacOSHelperOutput tolerates unfocused helper output', () => {
|
||||
assert.deepEqual(parseMacOSHelperOutput('120,240,1280,720,0'), {
|
||||
geometry: {
|
||||
x: 120,
|
||||
y: 240,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
focused: false,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user