mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
feat(stats): add live-action library support and harden stats runtime
- Add TMDB metadata, linking, filtering, and live-action library merging - Harden stats request validation, lifecycle handling, and compiled runtime coverage - Fix Anki media synchronization and configuration validation
This commit is contained in:
@@ -1,11 +1,31 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createForceQuitHandler,
|
||||
createOnWillQuitCleanupHandler,
|
||||
createRestoreWindowsOnActivateHandler,
|
||||
createShouldRestoreWindowsOnActivateHandler,
|
||||
} from './app-lifecycle-actions';
|
||||
|
||||
test('forced quit finalizes stats before exiting, even when finalization throws', async () => {
|
||||
for (const fails of [false, true]) {
|
||||
const calls: string[] = [];
|
||||
await createForceQuitHandler({
|
||||
destroyImmersionTracker: () => {
|
||||
calls.push('finalize');
|
||||
if (fails) throw new Error('flush failed');
|
||||
},
|
||||
logError: () => {
|
||||
calls.push('error');
|
||||
},
|
||||
exit: () => {
|
||||
calls.push('exit');
|
||||
},
|
||||
})();
|
||||
assert.deepEqual(calls, fails ? ['finalize', 'error', 'exit'] : ['finalize', 'exit']);
|
||||
}
|
||||
});
|
||||
|
||||
test('on will quit cleanup handler runs all cleanup steps', async () => {
|
||||
const calls: string[] = [];
|
||||
const cleanup = createOnWillQuitCleanupHandler({
|
||||
@@ -32,7 +52,13 @@ test('on will quit cleanup handler runs all cleanup steps', async () => {
|
||||
destroyMpvSocket: () => calls.push('destroy-socket'),
|
||||
clearReconnectTimer: () => calls.push('clear-reconnect'),
|
||||
destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'),
|
||||
destroyImmersionTracker: () => {
|
||||
stopStatsServer: async () => {
|
||||
calls.push('stop-stats-server-start');
|
||||
await Promise.resolve();
|
||||
calls.push('stop-stats-server-complete');
|
||||
},
|
||||
destroyImmersionTracker: async () => {
|
||||
await Promise.resolve();
|
||||
calls.push('destroy-immersion');
|
||||
},
|
||||
destroyAnkiIntegration: () => calls.push('destroy-anki'),
|
||||
@@ -54,7 +80,7 @@ test('on will quit cleanup handler runs all cleanup steps', async () => {
|
||||
});
|
||||
|
||||
await cleanup();
|
||||
assert.equal(calls.length, 36);
|
||||
assert.equal(calls.length, 38);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
@@ -65,6 +91,8 @@ test('on will quit cleanup handler runs all cleanup steps', async () => {
|
||||
assert.ok(calls.includes('cleanup-youtube-media'));
|
||||
assert.ok(calls.includes('cleanup-remote-media-windows'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
assert.ok(calls.indexOf('stop-stats-server-complete') < calls.indexOf('destroy-immersion'));
|
||||
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('destroy-anki'));
|
||||
});
|
||||
|
||||
for (const failedStep of [
|
||||
@@ -108,6 +136,7 @@ for (const failedStep of [
|
||||
destroyMpvSocket: () => {},
|
||||
clearReconnectTimer: () => {},
|
||||
destroySubtitleTimingTracker: () => {},
|
||||
stopStatsServer: () => {},
|
||||
destroyImmersionTracker: () => {},
|
||||
destroyAnkiIntegration: () => {},
|
||||
destroyAnilistSetupWindow: () => {},
|
||||
@@ -141,6 +170,32 @@ for (const failedStep of [
|
||||
});
|
||||
}
|
||||
|
||||
test('forced quit waits for asynchronous stats finalization', async () => {
|
||||
const calls: string[] = [];
|
||||
await createForceQuitHandler({
|
||||
destroyImmersionTracker: async () => {
|
||||
await Promise.resolve();
|
||||
calls.push('finalized');
|
||||
},
|
||||
logError: () => calls.push('error'),
|
||||
exit: () => calls.push('exit'),
|
||||
})();
|
||||
assert.deepEqual(calls, ['finalized', 'exit']);
|
||||
});
|
||||
|
||||
test('forced quit exits when asynchronous stats finalization never settles', async () => {
|
||||
const calls: string[] = [];
|
||||
await createForceQuitHandler({
|
||||
destroyImmersionTracker: () => new Promise<void>(() => {}),
|
||||
logError: (error) => {
|
||||
assert.match(String(error), /Stats finalization timed out/);
|
||||
calls.push('timeout');
|
||||
},
|
||||
exit: () => calls.push('exit'),
|
||||
})();
|
||||
assert.deepEqual(calls, ['timeout', 'exit']);
|
||||
});
|
||||
|
||||
test('should restore windows on activate requires initialized runtime and no windows', () => {
|
||||
let initialized = false;
|
||||
let windowCount = 1;
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
export function createForceQuitHandler(deps: {
|
||||
destroyImmersionTracker: () => void | Promise<void>;
|
||||
logError: (error: unknown) => void;
|
||||
exit: () => void;
|
||||
}) {
|
||||
return async () => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.resolve().then(() => deps.destroyImmersionTracker()),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error('Stats finalization timed out.')), 1_000);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
deps.logError(error);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
deps.exit();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyTray: () => void;
|
||||
stopConfigHotReload: () => void;
|
||||
@@ -18,6 +41,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyMpvSocket: () => void;
|
||||
clearReconnectTimer: () => void;
|
||||
destroySubtitleTimingTracker: () => void;
|
||||
stopStatsServer: () => Promise<void> | void;
|
||||
destroyImmersionTracker: () => void | Promise<void>;
|
||||
destroyAnkiIntegration: () => void;
|
||||
destroyAnilistSetupWindow: () => void;
|
||||
@@ -61,7 +85,16 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.destroyMpvSocket();
|
||||
deps.clearReconnectTimer();
|
||||
deps.destroySubtitleTimingTracker();
|
||||
await deps.destroyImmersionTracker();
|
||||
try {
|
||||
await deps.stopStatsServer();
|
||||
} catch (error) {
|
||||
cleanupErrors.push(error);
|
||||
}
|
||||
try {
|
||||
await deps.destroyImmersionTracker();
|
||||
} catch (error) {
|
||||
cleanupErrors.push(error);
|
||||
}
|
||||
deps.destroyAnkiIntegration();
|
||||
deps.destroyAnilistSetupWindow();
|
||||
deps.clearAnilistSetupWindow();
|
||||
|
||||
@@ -6,8 +6,11 @@ import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
|
||||
test('cleanup deps builder returns handlers that guard optional runtime objects', async () => {
|
||||
const calls: string[] = [];
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 60_000);
|
||||
let immersionTracker: { destroy: () => void } | null = {
|
||||
destroy: () => calls.push('destroy-immersion'),
|
||||
let immersionTracker: { destroy: () => Promise<void> } | null = {
|
||||
destroy: async () => {
|
||||
await Promise.resolve();
|
||||
calls.push('destroy-immersion');
|
||||
},
|
||||
};
|
||||
|
||||
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
||||
@@ -54,6 +57,9 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
|
||||
getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }),
|
||||
getImmersionTracker: () => immersionTracker,
|
||||
stopStatsServer: () => {
|
||||
calls.push('stop-stats-server');
|
||||
},
|
||||
clearImmersionTracker: () => {
|
||||
immersionTracker = null;
|
||||
calls.push('clear-immersion-ref');
|
||||
@@ -94,6 +100,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('clear-reconnect-ref'));
|
||||
assert.ok(calls.includes('destroy-immersion'));
|
||||
assert.ok(calls.includes('clear-immersion-ref'));
|
||||
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('clear-immersion-ref'));
|
||||
assert.ok(calls.includes('destroy-first-run-window'));
|
||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
@@ -144,6 +151,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
clearReconnectTimerRef: () => {},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getImmersionTracker: () => null,
|
||||
stopStatsServer: () => {},
|
||||
clearImmersionTracker: () => {},
|
||||
getAnkiIntegration: () => null,
|
||||
getAnilistSetupWindow: () => null,
|
||||
@@ -198,6 +206,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
clearReconnectTimerRef: () => {},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getImmersionTracker: () => null,
|
||||
stopStatsServer: () => {},
|
||||
clearImmersionTracker: () => {},
|
||||
getAnkiIntegration: () => null,
|
||||
getAnilistSetupWindow: () => null,
|
||||
|
||||
@@ -44,7 +44,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearReconnectTimerRef: () => void;
|
||||
|
||||
getSubtitleTimingTracker: () => Destroyable | null;
|
||||
getImmersionTracker: () => Destroyable | null;
|
||||
getImmersionTracker: () => { destroy: () => void | Promise<void> } | null;
|
||||
stopStatsServer: () => Promise<void> | void;
|
||||
clearImmersionTracker: () => void;
|
||||
getAnkiIntegration: () => Destroyable | null;
|
||||
|
||||
@@ -120,6 +121,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
destroySubtitleTimingTracker: () => {
|
||||
deps.getSubtitleTimingTracker()?.destroy();
|
||||
},
|
||||
stopStatsServer: () => deps.stopStatsServer(),
|
||||
destroyImmersionTracker: async () => {
|
||||
const tracker = deps.getImmersionTracker();
|
||||
if (!tracker) return;
|
||||
|
||||
@@ -24,10 +24,10 @@ function createDeps(
|
||||
return { deps, calls };
|
||||
}
|
||||
|
||||
test('ensures background stats server and logs local startup', () => {
|
||||
test('ensures background stats server and logs local startup', async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
createEnsureBackgroundStatsServerHandler(deps)();
|
||||
await createEnsureBackgroundStatsServerHandler(deps)();
|
||||
|
||||
assert.ok(calls.includes('ensureBackgroundStatsServerStarted'));
|
||||
assert.ok(
|
||||
@@ -35,7 +35,7 @@ test('ensures background stats server and logs local startup', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('logs reuse when a background stats server is already running', () => {
|
||||
test('logs reuse when a background stats server is already running', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
ensureBackgroundStatsServerStarted: () => ({
|
||||
url: 'http://127.0.0.1:3888',
|
||||
@@ -43,36 +43,53 @@ test('logs reuse when a background stats server is already running', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
createEnsureBackgroundStatsServerHandler(deps)();
|
||||
await createEnsureBackgroundStatsServerHandler(deps)();
|
||||
|
||||
assert.ok(
|
||||
calls.some((value) => value.startsWith('info:') && /already running|reusing/i.test(value)),
|
||||
);
|
||||
});
|
||||
|
||||
test('skips when stats.autoStartServer is disabled', () => {
|
||||
test('skips when stats.autoStartServer is disabled', async () => {
|
||||
const { deps, calls } = createDeps({ isStatsAutoStartEnabled: () => false });
|
||||
|
||||
createEnsureBackgroundStatsServerHandler(deps)();
|
||||
await createEnsureBackgroundStatsServerHandler(deps)();
|
||||
|
||||
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
|
||||
});
|
||||
|
||||
test('skips when immersion tracking is disabled', () => {
|
||||
test('skips when immersion tracking is disabled', async () => {
|
||||
const { deps, calls } = createDeps({ isImmersionTrackingEnabled: () => false });
|
||||
|
||||
createEnsureBackgroundStatsServerHandler(deps)();
|
||||
await createEnsureBackgroundStatsServerHandler(deps)();
|
||||
|
||||
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
|
||||
});
|
||||
|
||||
test('logs a warning instead of throwing when startup fails', () => {
|
||||
test('logs a warning instead of throwing when startup fails', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
throw new Error('port in use');
|
||||
},
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => createEnsureBackgroundStatsServerHandler(deps)());
|
||||
await assert.doesNotReject(createEnsureBackgroundStatsServerHandler(deps)());
|
||||
assert.ok(calls.some((value) => value.startsWith('warn:')));
|
||||
});
|
||||
|
||||
test('logs an asynchronously reported startup failure', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
ensureBackgroundStatsServerStarted: async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('address in use');
|
||||
},
|
||||
});
|
||||
|
||||
await createEnsureBackgroundStatsServerHandler(deps)();
|
||||
|
||||
assert.ok(calls.some((value) => value.startsWith('warn:')));
|
||||
assert.equal(
|
||||
calls.some((value) => value.startsWith('info:')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
export interface EnsureBackgroundStatsServerDeps {
|
||||
isStatsAutoStartEnabled: () => boolean;
|
||||
isImmersionTrackingEnabled: () => boolean;
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
};
|
||||
ensureBackgroundStatsServerStarted: () =>
|
||||
| Promise<{
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
}>
|
||||
| {
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
};
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export function createEnsureBackgroundStatsServerHandler(
|
||||
deps: EnsureBackgroundStatsServerDeps,
|
||||
): () => void {
|
||||
return () => {
|
||||
): () => Promise<void> {
|
||||
return async () => {
|
||||
if (!deps.isStatsAutoStartEnabled()) {
|
||||
deps.logInfo('Background start: stats.autoStartServer is disabled; skipping stats server.');
|
||||
return;
|
||||
@@ -22,7 +27,7 @@ export function createEnsureBackgroundStatsServerHandler(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = deps.ensureBackgroundStatsServerStarted();
|
||||
const result = await deps.ensureBackgroundStatsServerStarted();
|
||||
deps.logInfo(
|
||||
result.runningInCurrentProcess
|
||||
? `Background start: stats server started at ${result.url}.`
|
||||
|
||||
@@ -38,6 +38,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
clearReconnectTimerRef: () => {},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getImmersionTracker: () => null,
|
||||
stopStatsServer: () => {},
|
||||
clearImmersionTracker: () => {},
|
||||
getAnkiIntegration: () => null,
|
||||
getAnilistSetupWindow: () => null,
|
||||
|
||||
@@ -8,13 +8,10 @@ import {
|
||||
createBuildRestoreWindowsOnActivateMainDepsHandler,
|
||||
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
|
||||
} from '../app-lifecycle-main-activate';
|
||||
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from '../protocol-url-handlers-main-deps';
|
||||
import { registerProtocolUrlHandlers } from '../protocol-url-handlers';
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
type RegisterProtocolUrlHandlersMainDeps = Parameters<
|
||||
typeof createBuildRegisterProtocolUrlHandlersMainDepsHandler
|
||||
>[0];
|
||||
type RegisterProtocolUrlHandlersMainDeps = Parameters<typeof registerProtocolUrlHandlers>[0];
|
||||
type OnWillQuitCleanupDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
|
||||
type ShouldRestoreWindowsOnActivateMainDeps = Parameters<
|
||||
typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler
|
||||
@@ -40,10 +37,6 @@ export type StartupLifecycleComposerResult = ComposerOutputs<{
|
||||
export function composeStartupLifecycleHandlers(
|
||||
options: StartupLifecycleComposerOptions,
|
||||
): StartupLifecycleComposerResult {
|
||||
const registerProtocolUrlHandlersMainDeps = createBuildRegisterProtocolUrlHandlersMainDepsHandler(
|
||||
options.registerProtocolUrlHandlersMainDeps,
|
||||
)();
|
||||
|
||||
const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler(
|
||||
createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(),
|
||||
);
|
||||
@@ -58,9 +51,9 @@ export function composeStartupLifecycleHandlers(
|
||||
|
||||
return {
|
||||
registerProtocolUrlHandlers: () =>
|
||||
registerProtocolUrlHandlers(registerProtocolUrlHandlersMainDeps),
|
||||
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
|
||||
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
|
||||
registerProtocolUrlHandlers(options.registerProtocolUrlHandlersMainDeps),
|
||||
onWillQuitCleanup: onWillQuitCleanupHandler,
|
||||
shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler,
|
||||
restoreWindowsOnActivate: restoreWindowsOnActivateHandler,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,4 +13,3 @@ export * from '../anilist-state';
|
||||
export * from '../anilist-token-refresh';
|
||||
export * from '../anilist-token-refresh-main-deps';
|
||||
export * from '../protocol-url-handlers';
|
||||
export * from '../protocol-url-handlers-main-deps';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { test } from 'node:test';
|
||||
import { createLinuxOverlayModeRuntime } from './linux-overlay-mode-runtime';
|
||||
|
||||
class TestWindow extends EventEmitter {
|
||||
destroyed = false;
|
||||
hidden = false;
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
hide() {
|
||||
this.hidden = true;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
finishClose() {
|
||||
this.emit('closed');
|
||||
}
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const initial = new TestWindow();
|
||||
const state: { window: TestWindow | null; visible: boolean; creates: number; refreshes: number } =
|
||||
{
|
||||
window: initial,
|
||||
visible: true,
|
||||
creates: 0,
|
||||
refreshes: 0,
|
||||
};
|
||||
const runtime = createLinuxOverlayModeRuntime({
|
||||
isEnabled: () => true,
|
||||
isVisible: () => state.visible,
|
||||
getWindow: () => state.window,
|
||||
clearWindow: () => {
|
||||
state.window = null;
|
||||
},
|
||||
createWindow: () => {
|
||||
state.creates += 1;
|
||||
state.window = new TestWindow();
|
||||
},
|
||||
refreshWindow: () => {
|
||||
state.refreshes += 1;
|
||||
},
|
||||
now: () => 42,
|
||||
logDebug: () => {},
|
||||
});
|
||||
return { initial, state, runtime };
|
||||
}
|
||||
|
||||
test('Linux mode transition waits for close before replacing and refreshing the window', () => {
|
||||
const { initial, state, runtime } = fixture();
|
||||
runtime.ownerBindingKey = 'old-owner';
|
||||
runtime.sync(true);
|
||||
assert.equal(runtime.mode, 'fullscreen-override');
|
||||
assert.equal(runtime.fullscreenChangedAtMs, 42);
|
||||
assert.equal(runtime.ownerBindingKey, null);
|
||||
assert.equal(initial.hidden, true);
|
||||
assert.equal(state.creates, 0);
|
||||
initial.finishClose();
|
||||
assert.equal(state.creates, 1);
|
||||
assert.equal(state.refreshes, 1);
|
||||
runtime.sync(true);
|
||||
assert.equal(state.creates, 1);
|
||||
});
|
||||
|
||||
test('an older close callback cannot clear or replace a newer overlay', () => {
|
||||
const { initial, state, runtime } = fixture();
|
||||
runtime.sync(true);
|
||||
runtime.sync(false);
|
||||
const replacement = state.window;
|
||||
assert.equal(state.creates, 1);
|
||||
initial.finishClose();
|
||||
assert.equal(state.window, replacement);
|
||||
assert.equal(state.creates, 1);
|
||||
assert.equal(runtime.mode, 'managed');
|
||||
});
|
||||
|
||||
test('hiding or cancelling a transition prevents delayed window creation', () => {
|
||||
for (const cancel of [false, true]) {
|
||||
const { initial, state, runtime } = fixture();
|
||||
runtime.sync(true);
|
||||
if (cancel) runtime.cancelPendingTransition();
|
||||
else state.visible = false;
|
||||
initial.finishClose();
|
||||
assert.equal(state.creates, 0);
|
||||
assert.equal(state.window, null);
|
||||
state.visible = true;
|
||||
runtime.sync(true);
|
||||
assert.equal(state.creates, 1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import {
|
||||
resolveLinuxVisibleOverlayWindowModeAction,
|
||||
type LinuxVisibleOverlayWindowMode,
|
||||
} from './linux-visible-overlay-window-mode';
|
||||
|
||||
type OverlayWindow = Pick<BrowserWindow, 'isDestroyed' | 'hide' | 'destroy'> & {
|
||||
once: (event: 'closed', listener: () => void) => unknown;
|
||||
};
|
||||
|
||||
export function createLinuxOverlayModeRuntime<Window extends OverlayWindow>(deps: {
|
||||
isEnabled: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
getWindow: () => Window | null;
|
||||
clearWindow: () => void;
|
||||
createWindow: () => void;
|
||||
refreshWindow: () => void;
|
||||
now: () => number;
|
||||
logDebug: (message: string) => void;
|
||||
}) {
|
||||
let mode: LinuxVisibleOverlayWindowMode = 'managed';
|
||||
let fullscreen = false;
|
||||
let fullscreenChangedAtMs = 0;
|
||||
let ownerBindingKey: string | null = null;
|
||||
let generation = 0;
|
||||
|
||||
function createWindowForMode(token: number, nextFullscreen: boolean): void {
|
||||
if (token !== generation || !deps.isVisible()) return;
|
||||
const existing = deps.getWindow();
|
||||
if (existing && !existing.isDestroyed()) return;
|
||||
deps.createWindow();
|
||||
deps.refreshWindow();
|
||||
deps.logDebug(
|
||||
`Switched Linux visible overlay window mode to ${mode} for mpv fullscreen=${nextFullscreen}`,
|
||||
);
|
||||
}
|
||||
|
||||
function sync(nextFullscreen: boolean): void {
|
||||
if (!deps.isEnabled()) return;
|
||||
if (fullscreen !== nextFullscreen) fullscreenChangedAtMs = deps.now();
|
||||
fullscreen = nextFullscreen;
|
||||
const current = deps.getWindow();
|
||||
const action = resolveLinuxVisibleOverlayWindowModeAction({
|
||||
currentMode: mode,
|
||||
fullscreen,
|
||||
hasLiveWindow: Boolean(current && !current.isDestroyed()),
|
||||
visibleOverlayVisible: deps.isVisible(),
|
||||
});
|
||||
mode = action.nextMode;
|
||||
ownerBindingKey = null;
|
||||
const token = ++generation;
|
||||
if (!action.shouldCreateWindow && !action.shouldDestroyCurrentWindow) return;
|
||||
|
||||
if (action.shouldDestroyCurrentWindow && current && !current.isDestroyed()) {
|
||||
current.once('closed', () => {
|
||||
if (deps.getWindow() === current) deps.clearWindow();
|
||||
if (action.createWindowTiming === 'after-current-destroyed') {
|
||||
createWindowForMode(token, nextFullscreen);
|
||||
}
|
||||
});
|
||||
current.hide();
|
||||
current.destroy();
|
||||
}
|
||||
if (!action.shouldCreateWindow) {
|
||||
deps.logDebug(
|
||||
`Recorded Linux visible overlay window mode ${action.nextMode} for hidden mpv fullscreen=${fullscreen}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action.createWindowTiming === 'now') createWindowForMode(token, nextFullscreen);
|
||||
}
|
||||
|
||||
return {
|
||||
get mode() {
|
||||
return mode;
|
||||
},
|
||||
get fullscreen() {
|
||||
return fullscreen;
|
||||
},
|
||||
get fullscreenChangedAtMs() {
|
||||
return fullscreenChangedAtMs;
|
||||
},
|
||||
get ownerBindingKey() {
|
||||
return ownerBindingKey;
|
||||
},
|
||||
set ownerBindingKey(key: string | null) {
|
||||
ownerBindingKey = key;
|
||||
},
|
||||
sync,
|
||||
cancelPendingTransition: () => {
|
||||
generation += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -20,11 +20,13 @@ export async function openMediaTimingReviewModal(
|
||||
logWarn: (message: string) => void;
|
||||
},
|
||||
payload: MediaTimingReviewOpenPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
|
||||
{
|
||||
modal: MODAL,
|
||||
signal,
|
||||
// The review renderer regularly needs more than the 1.5 s the other modals allow; a
|
||||
// premature retry re-sends the payload and reloads the waveform for nothing.
|
||||
timeoutMs: 4_000,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RemoteMediaWindowSource,
|
||||
} from '../../core/services/remote-media-window-cache';
|
||||
import type { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
|
||||
import { openMediaTimingReviewModal } from './media-timing-review-open';
|
||||
|
||||
type MediaTimingPreviewSessionLike = Pick<MediaTimingPreviewSession, 'start'>;
|
||||
import {
|
||||
@@ -16,6 +17,20 @@ import {
|
||||
createMediaTimingReviewRuntime,
|
||||
} from './media-timing-review';
|
||||
|
||||
function createDeferred<T>() {
|
||||
let settle: ((value: T) => void) | null = null;
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve(value: T): void {
|
||||
if (!settle) throw new Error('deferred promise is unavailable');
|
||||
settle(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildMediaTimingReviewPayload', () => {
|
||||
test('starts from the padded range and leaves two seconds to drag on each side', () => {
|
||||
const payload = buildMediaTimingReviewPayload(
|
||||
@@ -764,6 +779,206 @@ test('disposing an open review settles it with original timing and restores play
|
||||
]);
|
||||
});
|
||||
|
||||
for (const pendingSetup of ['properties', 'video-source'] as const) {
|
||||
test(`disposing pending ${pendingSetup} cancels side effects and permits a fresh review`, async () => {
|
||||
const setupGate = createDeferred<void>();
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let blockSetup = true;
|
||||
let modalOpenCalls = 0;
|
||||
let previewCreateCalls = 0;
|
||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => {
|
||||
if (blockSetup && pendingSetup === 'properties') await setupGate.promise;
|
||||
return name === 'pause' ? false : name === 'duration' ? 100 : null;
|
||||
},
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
resolveVideoSource: async () => {
|
||||
if (blockSetup && pendingSetup === 'video-source') await setupGate.promise;
|
||||
return { path: '/video/show.mkv' };
|
||||
},
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => {
|
||||
previewCreateCalls += 1;
|
||||
return {
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
};
|
||||
},
|
||||
openModal: async (payload) => {
|
||||
modalOpenCalls += 1;
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
const request = {
|
||||
kind: 'word' as const,
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
screenshotEnabled: true,
|
||||
};
|
||||
|
||||
const pending = runtime.requestReview(request);
|
||||
let pendingSettled = false;
|
||||
void pending.finally(() => {
|
||||
pendingSettled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
await runtime.dispose();
|
||||
|
||||
assert.equal(pendingSettled, true);
|
||||
assert.deepEqual(await pending, { action: 'use-original' });
|
||||
assert.deepEqual(commands, []);
|
||||
assert.equal(modalOpenCalls, 0);
|
||||
assert.equal(previewCreateCalls, 0);
|
||||
|
||||
setupGate.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
blockSetup = false;
|
||||
assert.deepEqual(await runtime.requestReview(request), { action: 'use-original' });
|
||||
assert.equal(modalOpenCalls, 1);
|
||||
assert.equal(previewCreateCalls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('disposing during modal acknowledgement prevents the real opener from retrying', async () => {
|
||||
const waiting = createDeferred<void>();
|
||||
const acknowledgement = createDeferred<boolean>();
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let sendCalls = 0;
|
||||
let previewDisposeCalls = 0;
|
||||
let opening: Promise<boolean> | undefined;
|
||||
const runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'pause' ? false : null),
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => {
|
||||
previewDisposeCalls += 1;
|
||||
},
|
||||
}),
|
||||
openModal: (payload, signal) => {
|
||||
opening = openMediaTimingReviewModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: () => {
|
||||
sendCalls += 1;
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: () => {
|
||||
waiting.resolve();
|
||||
return acknowledgement.promise;
|
||||
},
|
||||
logWarn: () => {},
|
||||
},
|
||||
payload,
|
||||
signal,
|
||||
);
|
||||
return opening;
|
||||
},
|
||||
showStatus: () => {},
|
||||
});
|
||||
const pending = runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
await waiting.promise;
|
||||
await runtime.dispose();
|
||||
assert.deepEqual(await pending, { action: 'use-original' });
|
||||
|
||||
acknowledgement.resolve(false);
|
||||
assert.equal(await opening, false);
|
||||
assert.equal(sendCalls, 1);
|
||||
assert.equal(previewDisposeCalls, 1);
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('disposing owns a preview session whose startup is still pending', async () => {
|
||||
const openedPayload = createDeferred<MediaTimingReviewOpenPayload>();
|
||||
const previewStarted = createDeferred<void>();
|
||||
const previewStartGate = createDeferred<void>();
|
||||
let previewDisposeCalls = 0;
|
||||
const runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'duration' ? 100 : null),
|
||||
send: () => undefined,
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => {
|
||||
previewStarted.resolve();
|
||||
await previewStartGate.promise;
|
||||
},
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => {
|
||||
previewDisposeCalls += 1;
|
||||
},
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
openedPayload.resolve(payload);
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
const pending = runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
await openedPayload.promise;
|
||||
await previewStarted.promise;
|
||||
|
||||
await runtime.dispose();
|
||||
assert.deepEqual(await pending, { action: 'use-original' });
|
||||
assert.equal(previewDisposeCalls, 0);
|
||||
|
||||
previewStartGate.resolve();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(previewDisposeCalls, 1);
|
||||
});
|
||||
|
||||
test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
|
||||
const endedReviewIds: string[] = [];
|
||||
const playback: { ended?: () => void } = {};
|
||||
|
||||
@@ -78,6 +78,15 @@ interface ActiveReview {
|
||||
resolve: (decision: MediaTimingReviewDecision) => void;
|
||||
}
|
||||
|
||||
interface ReviewRequestLifecycle {
|
||||
signal: AbortSignal;
|
||||
cancelled: Promise<void>;
|
||||
settled: Promise<void>;
|
||||
isCancelled(): boolean;
|
||||
cancel(): void;
|
||||
markSettled(): void;
|
||||
}
|
||||
|
||||
export interface MediaTimingReviewRuntimeDeps {
|
||||
getMpvClient: () => ReviewMpvClient | null;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
@@ -101,7 +110,7 @@ export interface MediaTimingReviewRuntimeDeps {
|
||||
next: MediaTimingReviewContextLine[];
|
||||
};
|
||||
decisionTimeoutMs?: number;
|
||||
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
|
||||
openModal: (payload: MediaTimingReviewOpenPayload, signal: AbortSignal) => Promise<boolean>;
|
||||
/** Tells the modal that the hidden player finished the previewed clip. */
|
||||
onPreviewEnded?: (reviewId: string) => void;
|
||||
showStatus: (message: string) => void;
|
||||
@@ -118,6 +127,33 @@ function booleanProperty(value: unknown): boolean | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function createReviewRequestLifecycle(): ReviewRequestLifecycle {
|
||||
const controller = new AbortController();
|
||||
let resolveCancellation: (() => void) | null = null;
|
||||
let resolveSettled: (() => void) | null = null;
|
||||
const cancellation = new Promise<void>((resolve) => {
|
||||
resolveCancellation = resolve;
|
||||
});
|
||||
const settled = new Promise<void>((resolve) => {
|
||||
resolveSettled = resolve;
|
||||
});
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cancelled: cancellation,
|
||||
settled,
|
||||
isCancelled: () => controller.signal.aborted,
|
||||
cancel: () => {
|
||||
if (controller.signal.aborted) return;
|
||||
controller.abort();
|
||||
resolveCancellation?.();
|
||||
},
|
||||
markSettled: () => {
|
||||
resolveSettled?.();
|
||||
resolveSettled = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the subtitle lines adjacent to the mined range that the review modal can pull
|
||||
* onto the card. Parsed cues cover both directions; when none are loaded (e.g. the
|
||||
@@ -245,7 +281,7 @@ export function buildMediaTimingReviewPayload(
|
||||
|
||||
export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) {
|
||||
let active: ActiveReview | null = null;
|
||||
let reviewInProgress = false;
|
||||
let currentRequest: ReviewRequestLifecycle | null = null;
|
||||
let pendingPauseRestore: ReviewMpvClient | null = null;
|
||||
|
||||
function restorePendingPlayback(): void {
|
||||
@@ -311,14 +347,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
|
||||
const previous = review.preview;
|
||||
const session = deps.createPreviewSession();
|
||||
session.onPlaybackEnded(() => {
|
||||
if (active === review && review.preview?.session === started) {
|
||||
deps.onPreviewEnded?.(review.payload.reviewId);
|
||||
}
|
||||
});
|
||||
const { audioTrackId, ...previewOptions } = review.previewOptions;
|
||||
const started = session
|
||||
.start({
|
||||
const startSession = async (): Promise<PreviewSession> => {
|
||||
if (active !== review) {
|
||||
throw new Error('This timing review is no longer active.');
|
||||
}
|
||||
await session.start({
|
||||
mediaPath,
|
||||
...previewOptions,
|
||||
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
|
||||
@@ -327,19 +361,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
: audioTrackId !== undefined
|
||||
? { audioTrackId }
|
||||
: {}),
|
||||
})
|
||||
.then(() => session)
|
||||
.catch((error) => {
|
||||
});
|
||||
return session;
|
||||
};
|
||||
const started = Promise.resolve()
|
||||
.then(startSession)
|
||||
.catch((error: unknown) => {
|
||||
session.dispose();
|
||||
throw error;
|
||||
});
|
||||
review.preview = { path: mediaPath, session: started };
|
||||
session.onPlaybackEnded(() => {
|
||||
if (active === review && review.preview?.session === started) {
|
||||
deps.onPreviewEnded?.(review.payload.reviewId);
|
||||
}
|
||||
});
|
||||
void started.catch(() => {});
|
||||
if (previous) void previous.session.then((old) => old.dispose()).catch(() => {});
|
||||
return started;
|
||||
}
|
||||
|
||||
async function runReview(request: MediaTimingReviewRequest): Promise<MediaTimingReviewDecision> {
|
||||
async function runReview(
|
||||
request: MediaTimingReviewRequest,
|
||||
lifecycle: ReviewRequestLifecycle,
|
||||
): Promise<MediaTimingReviewDecision> {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
const mediaPath =
|
||||
deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || '';
|
||||
@@ -348,18 +393,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
|
||||
const setupPromise = Promise.all([
|
||||
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
|
||||
deps.resolveMediaSource?.().catch(() => null) ?? null,
|
||||
request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null,
|
||||
]);
|
||||
const setup = await Promise.race([
|
||||
setupPromise.then((values) => ({ kind: 'ready' as const, values })),
|
||||
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
|
||||
]);
|
||||
if (setup.kind === 'cancelled' || lifecycle.isCancelled()) {
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
|
||||
await Promise.all([
|
||||
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
|
||||
deps.resolveMediaSource?.().catch(() => null) ?? null,
|
||||
request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null,
|
||||
]);
|
||||
setup.values;
|
||||
const pauseState = booleanProperty(pauseRaw);
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
|
||||
pendingPauseRestore = pauseState === false ? mpvClient : null;
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
|
||||
if (lifecycle.isCancelled()) {
|
||||
restorePendingPlayback();
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
|
||||
let contextLines: ReturnType<NonNullable<typeof deps.getSubtitleContextLines>> | undefined;
|
||||
try {
|
||||
@@ -423,9 +480,22 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
endTime: payload.timelineEndTime,
|
||||
}).catch(() => {});
|
||||
|
||||
const opened = await deps.openModal(payload).catch(() => false);
|
||||
if (lifecycle.isCancelled() || active !== review) {
|
||||
await cleanupActiveReview(review);
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
const openModal = deps.openModal(payload, lifecycle.signal).catch(() => false);
|
||||
const openResult = await Promise.race([
|
||||
openModal.then((opened) => ({ kind: 'opened' as const, opened })),
|
||||
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
|
||||
]);
|
||||
if (openResult.kind === 'cancelled' || lifecycle.isCancelled() || active !== review) {
|
||||
await cleanupActiveReview(review);
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
const { opened } = openResult;
|
||||
if (!opened) {
|
||||
await cleanupActiveReview();
|
||||
await cleanupActiveReview(review);
|
||||
deps.showStatus('Timing review could not open. Using the original subtitle timing.');
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
@@ -434,33 +504,43 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
() => resolveDecision({ action: 'use-original' }),
|
||||
Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS),
|
||||
);
|
||||
let decision: MediaTimingReviewDecision;
|
||||
let decision: MediaTimingReviewDecision = { action: 'use-original' };
|
||||
try {
|
||||
decision = await decisionPromise;
|
||||
const decisionResult = await Promise.race([
|
||||
decisionPromise.then((value) => ({ kind: 'decided' as const, value })),
|
||||
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
|
||||
]);
|
||||
if (decisionResult.kind === 'decided') {
|
||||
decision = decisionResult.value;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(decisionWatchdog);
|
||||
}
|
||||
await cleanupActiveReview();
|
||||
await cleanupActiveReview(review);
|
||||
return decision;
|
||||
}
|
||||
|
||||
async function requestReview(
|
||||
request: MediaTimingReviewRequest,
|
||||
): Promise<MediaTimingReviewDecision> {
|
||||
if (active || reviewInProgress) {
|
||||
if (active || currentRequest) {
|
||||
deps.showStatus('Finish the current timing review before mining another card.');
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
reviewInProgress = true;
|
||||
const lifecycle = createReviewRequestLifecycle();
|
||||
currentRequest = lifecycle;
|
||||
try {
|
||||
return await runReview(request);
|
||||
return await runReview(request, lifecycle);
|
||||
} catch {
|
||||
await cleanupActiveReview();
|
||||
restorePendingPlayback();
|
||||
deps.showStatus('Timing review failed. Using the original subtitle timing.');
|
||||
return { action: 'use-original' };
|
||||
} finally {
|
||||
reviewInProgress = false;
|
||||
if (currentRequest === lifecycle) {
|
||||
currentRequest = null;
|
||||
}
|
||||
lifecycle.markSettled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,9 +683,18 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
}
|
||||
try {
|
||||
const previewSession = current.preview ? await current.preview.session : null;
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
await previewSession?.stop();
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -641,8 +730,9 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function cleanupActiveReview(): Promise<void> {
|
||||
async function cleanupActiveReview(expected?: ActiveReview): Promise<void> {
|
||||
const current = active;
|
||||
if (expected && current !== expected) return;
|
||||
active = null;
|
||||
if (!current) return;
|
||||
deps.clearFrameCache?.();
|
||||
@@ -653,9 +743,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
}
|
||||
|
||||
async function dispose(): Promise<void> {
|
||||
const request = currentRequest;
|
||||
request?.cancel();
|
||||
active?.resolve({ action: 'use-original' });
|
||||
await cleanupActiveReview();
|
||||
restorePendingPlayback();
|
||||
await request?.settled;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { openOverlayHostedModal } from './overlay-hosted-modal-open';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
test('retryOverlayModalOpen skips the first send when already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const unexpectedCall = () => assert.fail('aborted open must not send or wait');
|
||||
assert.equal(
|
||||
await retryOverlayModalOpen(
|
||||
{ waitForModalOpen: unexpectedCall, logWarn: unexpectedCall },
|
||||
{
|
||||
modal: 'media-timing-review',
|
||||
timeoutMs: 4_000,
|
||||
retryWarning: 'retry',
|
||||
sendOpen: unexpectedCall,
|
||||
signal: controller.signal,
|
||||
},
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
for (const abortOnWait of [1, 2]) {
|
||||
test(`retryOverlayModalOpen rejects an acknowledgement aborted during wait ${abortOnWait}`, async () => {
|
||||
const controller = new AbortController();
|
||||
let waitCalls = 0;
|
||||
let sendCalls = 0;
|
||||
const opened = await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: async () => {
|
||||
waitCalls += 1;
|
||||
if (waitCalls === abortOnWait) {
|
||||
controller.abort();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
logWarn: () => {},
|
||||
},
|
||||
{
|
||||
modal: 'media-timing-review',
|
||||
timeoutMs: 4_000,
|
||||
retryWarning: 'retry',
|
||||
sendOpen: () => {
|
||||
sendCalls += 1;
|
||||
return true;
|
||||
},
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
assert.equal(opened, false);
|
||||
assert.equal(sendCalls, abortOnWait);
|
||||
assert.equal(waitCalls, abortOnWait);
|
||||
});
|
||||
}
|
||||
|
||||
test('retryOverlayModalOpen still retries other modals without a signal', async () => {
|
||||
let sendCalls = 0;
|
||||
const opened = await retryOverlayModalOpen(
|
||||
{ waitForModalOpen: async () => sendCalls === 2, logWarn: () => {} },
|
||||
{
|
||||
modal: 'runtime-options',
|
||||
timeoutMs: 1_500,
|
||||
retryWarning: 'retry',
|
||||
sendOpen: () => {
|
||||
sendCalls += 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(opened, true);
|
||||
assert.equal(sendCalls, 2);
|
||||
});
|
||||
|
||||
test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -38,20 +38,24 @@ export async function retryOverlayModalOpen(
|
||||
timeoutMs: number;
|
||||
retryWarning: string;
|
||||
sendOpen: () => boolean;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
if (!input.sendOpen()) {
|
||||
if (input.signal?.aborted || !input.sendOpen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await deps.waitForModalOpen(input.modal, input.timeoutMs)) {
|
||||
const opened = await deps.waitForModalOpen(input.modal, input.timeoutMs);
|
||||
if (input.signal?.aborted) return false;
|
||||
if (opened) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.logWarn(input.retryWarning);
|
||||
if (!input.sendOpen()) {
|
||||
if (input.signal?.aborted || !input.sendOpen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await deps.waitForModalOpen(input.modal, input.timeoutMs);
|
||||
const retryOpened = await deps.waitForModalOpen(input.modal, input.timeoutMs);
|
||||
return !input.signal?.aborted && retryOpened;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from './protocol-url-handlers-main-deps';
|
||||
|
||||
test('protocol url handlers main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildRegisterProtocolUrlHandlersMainDepsHandler({
|
||||
registerOpenUrl: () => calls.push('open-register'),
|
||||
registerSecondInstance: () => calls.push('second-register'),
|
||||
handleAnilistSetupProtocolUrl: () => true,
|
||||
findAnilistSetupDeepLinkArgvUrl: () => 'subminer://anilist-setup',
|
||||
logUnhandledOpenUrl: (rawUrl) => calls.push(`open:${rawUrl}`),
|
||||
logUnhandledSecondInstanceUrl: (rawUrl) => calls.push(`second:${rawUrl}`),
|
||||
})();
|
||||
|
||||
deps.registerOpenUrl(() => {});
|
||||
deps.registerSecondInstance(() => {});
|
||||
assert.equal(deps.handleAnilistSetupProtocolUrl('subminer://anilist-setup'), true);
|
||||
assert.equal(deps.findAnilistSetupDeepLinkArgvUrl(['x']), 'subminer://anilist-setup');
|
||||
deps.logUnhandledOpenUrl('subminer://noop');
|
||||
deps.logUnhandledSecondInstanceUrl('subminer://noop');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'open-register',
|
||||
'second-register',
|
||||
'open:subminer://noop',
|
||||
'second:subminer://noop',
|
||||
]);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { registerProtocolUrlHandlers } from './protocol-url-handlers';
|
||||
|
||||
type RegisterProtocolUrlHandlersMainDeps = Parameters<typeof registerProtocolUrlHandlers>[0];
|
||||
|
||||
export function createBuildRegisterProtocolUrlHandlersMainDepsHandler(
|
||||
deps: RegisterProtocolUrlHandlersMainDeps,
|
||||
) {
|
||||
return (): RegisterProtocolUrlHandlersMainDeps => ({
|
||||
registerOpenUrl: (listener) => deps.registerOpenUrl(listener),
|
||||
registerSecondInstance: (listener) => deps.registerSecondInstance(listener),
|
||||
handleAnilistSetupProtocolUrl: (rawUrl: string) => deps.handleAnilistSetupProtocolUrl(rawUrl),
|
||||
findAnilistSetupDeepLinkArgvUrl: (argv: string[]) => deps.findAnilistSetupDeepLinkArgvUrl(argv),
|
||||
logUnhandledOpenUrl: (rawUrl: string) => deps.logUnhandledOpenUrl(rawUrl),
|
||||
logUnhandledSecondInstanceUrl: (rawUrl: string) => deps.logUnhandledSecondInstanceUrl(rawUrl),
|
||||
});
|
||||
}
|
||||
@@ -57,8 +57,10 @@ export function createRunStatsCliCommandHandler(deps: {
|
||||
}) => Promise<DuplicateSubtitleLineCleanupSummary>;
|
||||
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
|
||||
} | null;
|
||||
ensureStatsServerStarted: () => string;
|
||||
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
|
||||
ensureStatsServerStarted: () => Promise<string> | string;
|
||||
ensureBackgroundStatsServerStarted: () =>
|
||||
| Promise<BackgroundStatsStartResult>
|
||||
| BackgroundStatsStartResult;
|
||||
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
|
||||
@@ -115,7 +117,7 @@ export function createRunStatsCliCommandHandler(deps: {
|
||||
}
|
||||
|
||||
if (args.statsBackground) {
|
||||
const result = deps.ensureBackgroundStatsServerStarted();
|
||||
const result = await deps.ensureBackgroundStatsServerStarted();
|
||||
deps.logInfo(`Stats dashboard available at ${result.url}`);
|
||||
writeResponseSafe(args.statsResponsePath, { ok: true, url: result.url });
|
||||
if (!result.runningInCurrentProcess && source === 'initial') {
|
||||
@@ -183,7 +185,7 @@ export function createRunStatsCliCommandHandler(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = deps.ensureStatsServerStarted();
|
||||
const url = await deps.ensureStatsServerStarted();
|
||||
if (config.stats.autoOpenBrowser !== false) {
|
||||
await deps.openExternal(url);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function createHarness(options?: {
|
||||
return options?.processAlive ?? true;
|
||||
},
|
||||
hasLocalStatsServer: () => localServerStarted,
|
||||
startLocalStatsServer: () => {
|
||||
startLocalStatsServer: async () => {
|
||||
calls.push('startLocalStatsServer');
|
||||
localServerStarted = true;
|
||||
},
|
||||
@@ -36,23 +36,23 @@ function createHarness(options?: {
|
||||
};
|
||||
}
|
||||
|
||||
test('stats server routing defers to a live background daemon from another process', () => {
|
||||
test('stats server routing defers to a live background daemon from another process', async () => {
|
||||
const { calls, handler } = createHarness({
|
||||
state: { pid: 200, port: 7979, startedAtMs: 1 },
|
||||
processAlive: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(handler(), { url: 'http://127.0.0.1:7979', source: 'background' });
|
||||
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:7979', source: 'background' });
|
||||
assert.deepEqual(calls, ['readBackgroundState', 'isProcessAlive']);
|
||||
});
|
||||
|
||||
test('stats server routing clears dead daemon state and starts local server', () => {
|
||||
test('stats server routing clears dead daemon state and starts local server', async () => {
|
||||
const { calls, handler } = createHarness({
|
||||
state: { pid: 200, port: 7979, startedAtMs: 1 },
|
||||
processAlive: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
||||
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
||||
assert.deepEqual(calls, [
|
||||
'readBackgroundState',
|
||||
'isProcessAlive',
|
||||
@@ -61,13 +61,13 @@ test('stats server routing clears dead daemon state and starts local server', ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats server routing clears self-owned stale state and starts local server', () => {
|
||||
test('stats server routing clears self-owned stale state and starts local server', async () => {
|
||||
const { calls, handler } = createHarness({
|
||||
state: { pid: 100, port: 7979, startedAtMs: 1 },
|
||||
processAlive: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
||||
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
||||
assert.deepEqual(calls, [
|
||||
'readBackgroundState',
|
||||
'removeBackgroundState',
|
||||
@@ -75,12 +75,12 @@ test('stats server routing clears self-owned stale state and starts local server
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats server routing reuses a started local stats server', () => {
|
||||
test('stats server routing reuses a started local stats server', async () => {
|
||||
const { calls, handler } = createHarness({
|
||||
state: null,
|
||||
localServerStarted: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
||||
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
|
||||
assert.deepEqual(calls, ['readBackgroundState', 'removeBackgroundState']);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ type EnsureStatsServerUrlDeps = {
|
||||
removeBackgroundState: () => void;
|
||||
isProcessAlive: (pid: number) => boolean;
|
||||
hasLocalStatsServer: () => boolean;
|
||||
startLocalStatsServer: () => void;
|
||||
startLocalStatsServer: () => Promise<void>;
|
||||
getConfiguredPort: () => number;
|
||||
};
|
||||
|
||||
@@ -18,8 +18,8 @@ export type EnsureStatsServerUrlResult = { url: string; source: 'background' | '
|
||||
|
||||
export function createEnsureStatsServerUrlHandler(
|
||||
deps: EnsureStatsServerUrlDeps,
|
||||
): () => EnsureStatsServerUrlResult {
|
||||
return () => {
|
||||
): () => Promise<EnsureStatsServerUrlResult> {
|
||||
return async () => {
|
||||
const state = deps.readBackgroundState();
|
||||
if (!state) {
|
||||
deps.removeBackgroundState();
|
||||
@@ -32,7 +32,7 @@ export function createEnsureStatsServerUrlHandler(
|
||||
}
|
||||
|
||||
if (!deps.hasLocalStatsServer()) {
|
||||
deps.startLocalStatsServer();
|
||||
await deps.startLocalStatsServer();
|
||||
}
|
||||
return { url: formatStatsServerUrl(deps.getConfiguredPort()), source: 'local' };
|
||||
};
|
||||
|
||||
@@ -1,10 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import test, { after } from 'node:test';
|
||||
import { DEFAULT_CONFIG } from '../../config';
|
||||
import { ImmersionTrackerService } from '../../core/services/immersion-tracker-service';
|
||||
import { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
|
||||
import {
|
||||
createStatsServerRuntime,
|
||||
isSelfOwnedBackgroundStatsDaemonState,
|
||||
shouldClearAppStateStatsServerOnStop,
|
||||
type StatsServerRuntimeDeps,
|
||||
} from './stats-server-runtime';
|
||||
import type { StatsServer } from '../../core/services/stats-server';
|
||||
import type { BackgroundStatsServerState } from './stats-daemon';
|
||||
|
||||
function createDeferred<T>() {
|
||||
let settle: ((value: T) => void) | null = null;
|
||||
let fail: ((error: unknown) => void) | null = null;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
settle = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve(value: T): void {
|
||||
if (!settle) throw new Error('deferred promise is unavailable');
|
||||
settle(value);
|
||||
},
|
||||
reject(error: unknown): void {
|
||||
if (!fail) throw new Error('deferred promise is unavailable');
|
||||
fail(error);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeHarness(
|
||||
startServer: NonNullable<StatsServerRuntimeDeps['startServer']>,
|
||||
backgroundState: BackgroundStatsServerState | null = null,
|
||||
) {
|
||||
const appStateValues: Array<StatsServer | null> = [];
|
||||
const tracker = new ImmersionTrackerService({ dbPath: ':memory:' });
|
||||
after(() => tracker.destroy());
|
||||
const runtime = createStatsServerRuntime({
|
||||
userDataPath: '/tmp/subminer-stats-runtime-test',
|
||||
statsDistPath: '/tmp/stats-dist',
|
||||
getResolvedConfig: () => ({
|
||||
...DEFAULT_CONFIG,
|
||||
stats: { ...DEFAULT_CONFIG.stats, serverPort: 5175 },
|
||||
}),
|
||||
getImmersionTracker: () => tracker,
|
||||
setAppStateStatsServer: (server) => {
|
||||
appStateValues.push(server);
|
||||
},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
getYomitanExt: () => null,
|
||||
getYomitanSession: () => null,
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
getYomitanParserReadyPromise: () => null,
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
getYomitanParserInitPromise: () => null,
|
||||
setYomitanParserInitPromise: () => {},
|
||||
getYomitanAnkiDeckName: async () => 'Mining',
|
||||
getAnilistRateLimiter: () => createAnilistRateLimiter(),
|
||||
resolveAnkiNoteId: (noteId) => noteId,
|
||||
trackDuplicateNoteIdsForNote: () => {},
|
||||
resolveSentenceSearchHeadwords: async () => [],
|
||||
ensureImmersionTrackerStarted: () => {},
|
||||
setStatsStartupInProgress: () => {},
|
||||
readBackgroundStatsServerState: () => backgroundState,
|
||||
removeBackgroundStatsServerState: () => {},
|
||||
isBackgroundStatsServerProcessAlive: () => false,
|
||||
startServer,
|
||||
});
|
||||
return { runtime, appStateValues };
|
||||
}
|
||||
|
||||
test('detects self-owned background stats daemon state', () => {
|
||||
assert.equal(
|
||||
@@ -13,10 +80,6 @@ test('detects self-owned background stats daemon state', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('stats server app-state reference should be cleared after private server stop', () => {
|
||||
assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true);
|
||||
});
|
||||
|
||||
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
|
||||
const calls: string[] = [];
|
||||
const runtime = createStatsServerRuntime({
|
||||
@@ -57,3 +120,157 @@ test('stopBackgroundStatsServer clears stale state when daemon identity mismatch
|
||||
assert.deepEqual(result, { ok: true, stale: true });
|
||||
assert.deepEqual(calls, ['removeBackgroundStatsServerState']);
|
||||
});
|
||||
|
||||
test('concurrent stats startup requests share one pending server', async () => {
|
||||
const deferred = createDeferred<StatsServer>();
|
||||
let startCalls = 0;
|
||||
const server: StatsServer = { close: async () => {} };
|
||||
const { runtime, appStateValues } = createRuntimeHarness(() => {
|
||||
startCalls += 1;
|
||||
return deferred.promise;
|
||||
});
|
||||
|
||||
const first = runtime.ensureStatsServerStarted();
|
||||
const second = runtime.ensureStatsServerStarted();
|
||||
assert.equal(startCalls, 1);
|
||||
assert.deepEqual(appStateValues, []);
|
||||
|
||||
deferred.resolve(server);
|
||||
assert.deepEqual(await Promise.all([first, second]), [
|
||||
{ url: 'http://127.0.0.1:5175', source: 'local' },
|
||||
{ url: 'http://127.0.0.1:5175', source: 'local' },
|
||||
]);
|
||||
assert.deepEqual(appStateValues, [server]);
|
||||
});
|
||||
|
||||
test('failed stats startup remains recoverable on the next request', async () => {
|
||||
const first = createDeferred<StatsServer>();
|
||||
const second = createDeferred<StatsServer>();
|
||||
const attempts = [first, second];
|
||||
let startCalls = 0;
|
||||
const server: StatsServer = { close: async () => {} };
|
||||
const { runtime, appStateValues } = createRuntimeHarness(() => {
|
||||
const attempt = attempts[startCalls];
|
||||
startCalls += 1;
|
||||
if (!attempt) throw new Error('unexpected startup attempt');
|
||||
return attempt.promise;
|
||||
});
|
||||
|
||||
const failedStartup = runtime.ensureStatsServerStarted();
|
||||
first.reject(Object.assign(new Error('address in use'), { code: 'EADDRINUSE' }));
|
||||
await assert.rejects(failedStartup, /address in use/);
|
||||
|
||||
const retry = runtime.ensureStatsServerStarted();
|
||||
second.resolve(server);
|
||||
assert.deepEqual(await retry, { url: 'http://127.0.0.1:5175', source: 'local' });
|
||||
assert.equal(startCalls, 2);
|
||||
assert.deepEqual(appStateValues, [null, server]);
|
||||
});
|
||||
|
||||
test('shutdown cancels pending startup and closes the late server', async () => {
|
||||
const deferred = createDeferred<StatsServer>();
|
||||
let closeCalls = 0;
|
||||
const server: StatsServer = {
|
||||
close: async () => {
|
||||
closeCalls += 1;
|
||||
},
|
||||
};
|
||||
const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise);
|
||||
|
||||
const startup = runtime.ensureStatsServerStarted();
|
||||
const shutdown = runtime.stopStatsServer();
|
||||
deferred.resolve(server);
|
||||
|
||||
await assert.rejects(startup, /startup was cancelled/);
|
||||
await shutdown;
|
||||
assert.equal(closeCalls, 1);
|
||||
assert.deepEqual(appStateValues, [null, null]);
|
||||
});
|
||||
|
||||
test('stopping a self-owned background server closes its local handle', async () => {
|
||||
let closeCalls = 0;
|
||||
const server: StatsServer = {
|
||||
close: async () => {
|
||||
closeCalls += 1;
|
||||
},
|
||||
};
|
||||
const { runtime } = createRuntimeHarness(async () => server, {
|
||||
pid: process.pid,
|
||||
port: 5175,
|
||||
startedAtMs: 1,
|
||||
});
|
||||
await runtime.ensureStatsServerStarted();
|
||||
|
||||
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: false });
|
||||
assert.equal(closeCalls, 1);
|
||||
});
|
||||
|
||||
test('background stop leaves a foreground-only server available', async () => {
|
||||
let closeCalls = 0;
|
||||
let startCalls = 0;
|
||||
const { runtime } = createRuntimeHarness(async () => {
|
||||
startCalls += 1;
|
||||
return {
|
||||
close: async () => {
|
||||
closeCalls += 1;
|
||||
},
|
||||
};
|
||||
});
|
||||
const foreground = await runtime.ensureStatsServerStarted();
|
||||
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true });
|
||||
assert.equal(closeCalls, 0);
|
||||
assert.deepEqual(await runtime.ensureStatsServerStarted(), foreground);
|
||||
assert.equal(startCalls, 1);
|
||||
await runtime.stopStatsServer();
|
||||
});
|
||||
|
||||
test('background stop leaves a pending foreground-only startup alone', async () => {
|
||||
const deferred = createDeferred<StatsServer>();
|
||||
const { runtime } = createRuntimeHarness(() => deferred.promise);
|
||||
const startup = runtime.ensureStatsServerStarted();
|
||||
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true });
|
||||
deferred.resolve({ close: async () => {} });
|
||||
assert.deepEqual(await startup, { url: 'http://127.0.0.1:5175', source: 'local' });
|
||||
await runtime.stopStatsServer();
|
||||
});
|
||||
|
||||
test('a startup requested during shutdown waits and then restarts', async () => {
|
||||
const closeDeferred = createDeferred<void>();
|
||||
const firstServer: StatsServer = { close: () => closeDeferred.promise };
|
||||
const secondServer: StatsServer = { close: async () => {} };
|
||||
const servers = [firstServer, secondServer];
|
||||
let startCalls = 0;
|
||||
const { runtime } = createRuntimeHarness(async () => {
|
||||
const server = servers[startCalls];
|
||||
startCalls += 1;
|
||||
if (!server) throw new Error('unexpected startup attempt');
|
||||
return server;
|
||||
});
|
||||
await runtime.ensureStatsServerStarted();
|
||||
|
||||
const shutdown = runtime.stopStatsServer();
|
||||
const restart = runtime.ensureStatsServerStarted();
|
||||
assert.equal(startCalls, 1);
|
||||
|
||||
closeDeferred.resolve();
|
||||
await shutdown;
|
||||
assert.deepEqual(await restart, { url: 'http://127.0.0.1:5175', source: 'local' });
|
||||
assert.equal(startCalls, 2);
|
||||
});
|
||||
|
||||
test('background stop cancels startup before daemon ownership is published', async () => {
|
||||
const deferred = createDeferred<StatsServer>();
|
||||
let closeCalls = 0;
|
||||
const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise);
|
||||
const startup = runtime.ensureBackgroundStatsServerStarted();
|
||||
const shutdown = runtime.stopBackgroundStatsServer();
|
||||
deferred.resolve({
|
||||
close: async () => {
|
||||
closeCalls += 1;
|
||||
},
|
||||
});
|
||||
await assert.rejects(startup, /startup was cancelled/);
|
||||
assert.deepEqual(await shutdown, { ok: true, stale: false });
|
||||
assert.equal(closeCalls, 1);
|
||||
assert.equal(appStateValues.at(-1), null);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
addYomitanNoteViaSearch,
|
||||
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
||||
} from '../../core/services';
|
||||
import { startStatsServer } from '../../core/services/stats-server';
|
||||
import { startStatsServer, type StatsServer } from '../../core/services/stats-server';
|
||||
import { createTmdbClient, createTmdbApiKeyResolver } from '../../core/services/tmdb/tmdb-client';
|
||||
import { createLogger } from '../../logger';
|
||||
import type { ResolvedConfig } from '../../types/config';
|
||||
import type { AppState } from '../state';
|
||||
@@ -27,12 +28,6 @@ export function isSelfOwnedBackgroundStatsDaemonState(state: {
|
||||
return state.pid === process.pid;
|
||||
}
|
||||
|
||||
export function shouldClearAppStateStatsServerOnStop(options: {
|
||||
hadStatsServer: boolean;
|
||||
}): boolean {
|
||||
return options.hadStatsServer;
|
||||
}
|
||||
|
||||
export interface StatsServerRuntimeDeps {
|
||||
userDataPath: string;
|
||||
statsDistPath: string;
|
||||
@@ -52,6 +47,8 @@ export interface StatsServerRuntimeDeps {
|
||||
getAnilistRateLimiter: () => NonNullable<
|
||||
Parameters<typeof startStatsServer>[0]['anilistRateLimiter']
|
||||
>;
|
||||
/** Project TMDB key staged into release builds; null for source builds. */
|
||||
getBundledTmdbApiKey?: () => string | null;
|
||||
resolveAnkiNoteId: (noteId: number) => number;
|
||||
trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
|
||||
@@ -62,19 +59,28 @@ export interface StatsServerRuntimeDeps {
|
||||
isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive;
|
||||
verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity;
|
||||
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
|
||||
startServer?: typeof startStatsServer;
|
||||
}
|
||||
|
||||
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
stopStatsServer: () => void;
|
||||
stopStatsServer: () => Promise<void>;
|
||||
ensureStatsServerStarted: ReturnType<typeof createEnsureStatsServerUrlHandler>;
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
ensureBackgroundStatsServerStarted: () => Promise<{
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
};
|
||||
}>;
|
||||
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
|
||||
} {
|
||||
let statsServer: ReturnType<typeof startStatsServer> | null = null;
|
||||
type LocalStatsServerState =
|
||||
| { kind: 'stopped' }
|
||||
| { kind: 'starting'; token: symbol; promise: Promise<void> }
|
||||
| { kind: 'running'; server: StatsServer }
|
||||
| { kind: 'stopping'; token: symbol; promise: Promise<void> };
|
||||
|
||||
let localStatsServerState: LocalStatsServerState = { kind: 'stopped' };
|
||||
const pendingBackgroundStarts = new Set<symbol>();
|
||||
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
|
||||
const startServer = deps.startServer ?? startStatsServer;
|
||||
const readDaemonState =
|
||||
deps.readBackgroundStatsServerState ??
|
||||
((statePath: string) => defaultReadBackgroundStatsServerState(statePath));
|
||||
@@ -100,7 +106,7 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
removeDaemonState(statsDaemonStatePath);
|
||||
return null;
|
||||
}
|
||||
if (state.pid === process.pid && !statsServer) {
|
||||
if (state.pid === process.pid && localStatsServerState.kind !== 'running') {
|
||||
removeDaemonState(statsDaemonStatePath);
|
||||
return null;
|
||||
}
|
||||
@@ -118,74 +124,140 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
}
|
||||
}
|
||||
|
||||
function stopStatsServer(): void {
|
||||
if (!statsServer) {
|
||||
return;
|
||||
}
|
||||
statsServer.close();
|
||||
statsServer = null;
|
||||
if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) {
|
||||
deps.setAppStateStatsServer(null);
|
||||
}
|
||||
clearOwnedBackgroundStatsDaemonState();
|
||||
}
|
||||
|
||||
const startLocalStatsServer = (): void => {
|
||||
const buildStatsServerConfig = (): Parameters<typeof startStatsServer>[0] => {
|
||||
const tracker = deps.getImmersionTracker();
|
||||
if (!tracker) {
|
||||
throw new Error('Immersion tracker failed to initialize.');
|
||||
}
|
||||
if (!statsServer) {
|
||||
const yomitanDeps = {
|
||||
getYomitanExt: () => deps.getYomitanExt(),
|
||||
getYomitanSession: () => deps.getYomitanSession(),
|
||||
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
||||
setYomitanParserWindow: (w: BrowserWindow | null) => {
|
||||
deps.setYomitanParserWindow(w);
|
||||
},
|
||||
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
|
||||
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
|
||||
deps.setYomitanParserReadyPromise(p);
|
||||
},
|
||||
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
|
||||
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
|
||||
deps.setYomitanParserInitPromise(p);
|
||||
},
|
||||
};
|
||||
const yomitanLogger = createLogger('main:yomitan-stats');
|
||||
statsServer = startStatsServer({
|
||||
port: deps.getResolvedConfig().stats.serverPort,
|
||||
staticDir: deps.statsDistPath,
|
||||
tracker,
|
||||
knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'),
|
||||
mpvSocketPath: deps.getMpvSocketPath(),
|
||||
getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect,
|
||||
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
|
||||
getSecondarySubtitleLanguages: () =>
|
||||
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
|
||||
anilistRateLimiter: deps.getAnilistRateLimiter(),
|
||||
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
||||
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
||||
addYomitanNote: async (word: string) => {
|
||||
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||
deck: ankiConnectConfig.deck,
|
||||
});
|
||||
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||
deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds);
|
||||
}
|
||||
return result.noteId;
|
||||
},
|
||||
});
|
||||
deps.setAppStateStatsServer(statsServer);
|
||||
}
|
||||
deps.setAppStateStatsServer(statsServer);
|
||||
const yomitanDeps = {
|
||||
getYomitanExt: () => deps.getYomitanExt(),
|
||||
getYomitanSession: () => deps.getYomitanSession(),
|
||||
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
||||
setYomitanParserWindow: (w: BrowserWindow | null) => {
|
||||
deps.setYomitanParserWindow(w);
|
||||
},
|
||||
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
|
||||
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
|
||||
deps.setYomitanParserReadyPromise(p);
|
||||
},
|
||||
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
|
||||
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
|
||||
deps.setYomitanParserInitPromise(p);
|
||||
},
|
||||
};
|
||||
const yomitanLogger = createLogger('main:yomitan-stats');
|
||||
return {
|
||||
port: deps.getResolvedConfig().stats.serverPort,
|
||||
staticDir: deps.statsDistPath,
|
||||
tracker,
|
||||
knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'),
|
||||
mpvSocketPath: deps.getMpvSocketPath(),
|
||||
getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect,
|
||||
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
|
||||
getSecondarySubtitleLanguages: () =>
|
||||
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
|
||||
anilistRateLimiter: deps.getAnilistRateLimiter(),
|
||||
tmdbClient: createTmdbClient({
|
||||
resolveApiKey: createTmdbApiKeyResolver(
|
||||
() => deps.getResolvedConfig().tmdb,
|
||||
() => deps.getBundledTmdbApiKey?.() ?? null,
|
||||
),
|
||||
}),
|
||||
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
||||
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
||||
addYomitanNote: async (word: string) => {
|
||||
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||
deck: ankiConnectConfig.deck,
|
||||
});
|
||||
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||
deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds);
|
||||
}
|
||||
return result.noteId;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const beginLocalStatsServerStartup = (): Promise<void> => {
|
||||
const token = Symbol('stats-server-startup');
|
||||
const promise = startServer(buildStatsServerConfig())
|
||||
.then(async (server) => {
|
||||
const state = localStatsServerState;
|
||||
if (state.kind !== 'starting' || state.token !== token) {
|
||||
await server.close();
|
||||
throw new Error('Stats server startup was cancelled.');
|
||||
}
|
||||
localStatsServerState = { kind: 'running', server };
|
||||
deps.setAppStateStatsServer(server);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const state = localStatsServerState;
|
||||
if (state.kind === 'starting' && state.token === token) {
|
||||
localStatsServerState = { kind: 'stopped' };
|
||||
deps.setAppStateStatsServer(null);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
localStatsServerState = { kind: 'starting', token, promise };
|
||||
return promise;
|
||||
};
|
||||
|
||||
const startLocalStatsServer = async (): Promise<void> => {
|
||||
while (localStatsServerState.kind === 'stopping') {
|
||||
await localStatsServerState.promise;
|
||||
}
|
||||
if (localStatsServerState.kind === 'running') {
|
||||
deps.setAppStateStatsServer(localStatsServerState.server);
|
||||
return;
|
||||
}
|
||||
if (localStatsServerState.kind === 'starting') {
|
||||
await localStatsServerState.promise;
|
||||
return;
|
||||
}
|
||||
await beginLocalStatsServerStartup();
|
||||
};
|
||||
|
||||
function stopStatsServer(): Promise<void> {
|
||||
const state = localStatsServerState;
|
||||
if (state.kind === 'stopped') {
|
||||
deps.setAppStateStatsServer(null);
|
||||
clearOwnedBackgroundStatsDaemonState();
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (state.kind === 'stopping') {
|
||||
return state.promise;
|
||||
}
|
||||
|
||||
const token = Symbol('stats-server-shutdown');
|
||||
const promise = Promise.resolve()
|
||||
.then(async () => {
|
||||
if (state.kind === 'starting') {
|
||||
try {
|
||||
await state.promise;
|
||||
} catch {
|
||||
// Startup owns cleanup of a server that finishes binding after cancellation.
|
||||
}
|
||||
return;
|
||||
}
|
||||
await state.server.close();
|
||||
})
|
||||
.finally(() => {
|
||||
const current = localStatsServerState;
|
||||
if (current.kind === 'stopping' && current.token === token) {
|
||||
localStatsServerState = { kind: 'stopped' };
|
||||
}
|
||||
deps.setAppStateStatsServer(null);
|
||||
clearOwnedBackgroundStatsDaemonState();
|
||||
});
|
||||
localStatsServerState = { kind: 'stopping', token, promise };
|
||||
deps.setAppStateStatsServer(null);
|
||||
return promise;
|
||||
}
|
||||
|
||||
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
|
||||
currentPid: process.pid,
|
||||
readBackgroundState: () => readDaemonState(statsDaemonStatePath),
|
||||
@@ -193,15 +265,15 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
removeDaemonState(statsDaemonStatePath);
|
||||
},
|
||||
isProcessAlive: (pid) => isDaemonAlive(pid),
|
||||
hasLocalStatsServer: () => statsServer !== null,
|
||||
hasLocalStatsServer: () => localStatsServerState.kind === 'running',
|
||||
startLocalStatsServer,
|
||||
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
|
||||
});
|
||||
|
||||
const ensureBackgroundStatsServerStarted = (): {
|
||||
const ensureBackgroundStatsServerStarted = async (): Promise<{
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
} => {
|
||||
}> => {
|
||||
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
||||
if (liveDaemon && liveDaemon.pid !== process.pid) {
|
||||
return {
|
||||
@@ -217,27 +289,40 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
deps.setStatsStartupInProgress(false);
|
||||
}
|
||||
|
||||
const port = deps.getResolvedConfig().stats.serverPort;
|
||||
const result = ensureStatsServerStarted();
|
||||
if (result.source === 'local') {
|
||||
writeBackgroundStatsServerState(statsDaemonStatePath, {
|
||||
pid: process.pid,
|
||||
port,
|
||||
startedAtMs: Date.now(),
|
||||
});
|
||||
const request = Symbol('background-stats-startup');
|
||||
pendingBackgroundStarts.add(request);
|
||||
try {
|
||||
const port = deps.getResolvedConfig().stats.serverPort;
|
||||
const result = await ensureStatsServerStarted();
|
||||
if (result.source === 'local') {
|
||||
if (localStatsServerState.kind !== 'running') {
|
||||
throw new Error('Stats server startup was cancelled.');
|
||||
}
|
||||
writeBackgroundStatsServerState(statsDaemonStatePath, {
|
||||
pid: process.pid,
|
||||
port,
|
||||
startedAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
|
||||
} finally {
|
||||
pendingBackgroundStarts.delete(request);
|
||||
}
|
||||
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
|
||||
};
|
||||
|
||||
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
||||
const state = readDaemonState(statsDaemonStatePath);
|
||||
if (!state) {
|
||||
if (pendingBackgroundStarts.size > 0) {
|
||||
await stopStatsServer();
|
||||
return { ok: true, stale: false };
|
||||
}
|
||||
removeDaemonState(statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
}
|
||||
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
|
||||
removeDaemonState(statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
await stopStatsServer();
|
||||
return { ok: true, stale: false };
|
||||
}
|
||||
if (!isDaemonAlive(state.pid)) {
|
||||
removeDaemonState(statsDaemonStatePath);
|
||||
|
||||
Reference in New Issue
Block a user