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:
2026-09-21 00:18:32 -07:00
158 changed files with 6623 additions and 1119 deletions
+57 -2
View File
@@ -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;
+34 -1
View File
@@ -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,
);
});
+12 -7
View File
@@ -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,
};
}
-1
View File
@@ -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 } = {};
+125 -32
View File
@@ -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),
});
}
+6 -4
View File
@@ -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']);
});
+4 -4
View File
@@ -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' };
};
+223 -6
View File
@@ -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);
});
+173 -88
View File
@@ -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);