mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-22 12:11:27 -07:00
feat(stats): add v1 immersion stats dashboard (#19)
This commit is contained in:
@@ -51,10 +51,13 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
|
||||
setVisibleOverlayVisible: AppReadyRuntimeDeps['setVisibleOverlayVisible'];
|
||||
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
|
||||
runHeadlessInitialCommand?: AppReadyRuntimeDeps['runHeadlessInitialCommand'];
|
||||
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
|
||||
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
|
||||
logDebug?: AppReadyRuntimeDeps['logDebug'];
|
||||
now?: AppReadyRuntimeDeps['now'];
|
||||
shouldRunHeadlessInitialCommand?: AppReadyRuntimeDeps['shouldRunHeadlessInitialCommand'];
|
||||
shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup'];
|
||||
shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
|
||||
}
|
||||
|
||||
@@ -114,10 +117,13 @@ export function createAppReadyRuntimeDeps(
|
||||
params.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
setVisibleOverlayVisible: params.setVisibleOverlayVisible,
|
||||
initializeOverlayRuntime: params.initializeOverlayRuntime,
|
||||
runHeadlessInitialCommand: params.runHeadlessInitialCommand,
|
||||
handleInitialArgs: params.handleInitialArgs,
|
||||
onCriticalConfigErrors: params.onCriticalConfigErrors,
|
||||
logDebug: params.logDebug,
|
||||
now: params.now,
|
||||
shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand,
|
||||
shouldUseMinimalStartup: params.shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -361,6 +362,7 @@ test('generateForCurrentMedia applies configured open states to character dictio
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -518,6 +520,7 @@ test('generateForCurrentMedia reapplies collapsible open states when using cache
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -533,6 +536,7 @@ test('generateForCurrentMedia reapplies collapsible open states when using cache
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -661,6 +665,7 @@ test('generateForCurrentMedia adds kana aliases for romanized names when native
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'Konosuba',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -783,6 +788,7 @@ test('generateForCurrentMedia indexes kanji family and given names using AniList
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
season: null,
|
||||
episode: 1,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -904,6 +910,7 @@ test('generateForCurrentMedia indexes AniList alternative character names for al
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -1028,6 +1035,7 @@ test('generateForCurrentMedia skips AniList characters without a native name whe
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -1148,6 +1156,7 @@ test('generateForCurrentMedia uses AniList first and last name hints to build ka
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'Konosuba',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -1265,6 +1274,7 @@ test('generateForCurrentMedia includes AniList gender age birthday and blood typ
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -1408,6 +1418,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -1548,6 +1559,7 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -1703,6 +1715,7 @@ test('getOrCreateCurrentSnapshot rebuilds snapshots written with an older format
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -1842,6 +1855,7 @@ test('generateForCurrentMedia logs progress while resolving and rebuilding snaps
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -2014,6 +2028,7 @@ test('generateForCurrentMedia downloads shared voice actor images once per AniLi
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -2194,6 +2209,7 @@ test('buildMergedDictionary combines stored snapshots into one stable dictionary
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: current.title,
|
||||
season: null,
|
||||
episode: current.episode,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -2481,6 +2497,7 @@ test('buildMergedDictionary reapplies collapsible open states from current confi
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: current.title,
|
||||
season: null,
|
||||
episode: current.episode,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -2500,6 +2517,7 @@ test('buildMergedDictionary reapplies collapsible open states from current confi
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: current.title,
|
||||
season: null,
|
||||
episode: current.episode,
|
||||
source: 'fallback',
|
||||
}),
|
||||
@@ -2663,6 +2681,7 @@ test('generateForCurrentMedia paces AniList requests and character image downloa
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate'];
|
||||
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
||||
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -101,6 +102,7 @@ function createCliCommandDepsFromContext(
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: context.openJellyfinSetup,
|
||||
runStatsCommand: context.runStatsCommand,
|
||||
runCommand: context.runJellyfinCommand,
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
|
||||
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
|
||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
|
||||
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
||||
@@ -89,6 +91,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
|
||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||
getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker'];
|
||||
}
|
||||
|
||||
export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
||||
@@ -159,6 +162,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
};
|
||||
jellyfin: {
|
||||
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
|
||||
runStatsCommand: CliCommandDepsRuntimeOptions['jellyfin']['runStatsCommand'];
|
||||
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
|
||||
};
|
||||
ui: {
|
||||
@@ -216,6 +220,8 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
handleMpvCommand: params.handleMpvCommand,
|
||||
getKeybindings: params.getKeybindings,
|
||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||
getStatsToggleKey: params.getStatsToggleKey,
|
||||
getMarkWatchedKey: params.getMarkWatchedKey,
|
||||
getControllerConfig: params.getControllerConfig,
|
||||
saveControllerConfig: params.saveControllerConfig,
|
||||
saveControllerPreference: params.saveControllerPreference,
|
||||
@@ -234,6 +240,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getAnilistQueueStatus: params.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: params.retryAnilistQueueNow,
|
||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||
getImmersionTracker: params.getImmersionTracker,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -312,6 +319,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: params.jellyfin.openSetup,
|
||||
runStatsCommand: params.jellyfin.runStatsCommand,
|
||||
runCommand: params.jellyfin.runCommand,
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
requestSingleInstanceLockEarly,
|
||||
resetEarlySingleInstanceStateForTests,
|
||||
} from './early-single-instance';
|
||||
import * as earlySingleInstance from './early-single-instance';
|
||||
|
||||
function createFakeApp(lockValue = true) {
|
||||
let requestCalls = 0;
|
||||
@@ -54,3 +55,16 @@ test('registerSecondInstanceHandlerEarly replays queued argv and forwards new ev
|
||||
['SubMiner.exe', '--start', '--show-visible-overlay'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats daemon args bypass the normal single-instance lock path', () => {
|
||||
const shouldBypass = (
|
||||
earlySingleInstance as typeof earlySingleInstance & {
|
||||
shouldBypassSingleInstanceLockForArgv?: (argv: string[]) => boolean;
|
||||
}
|
||||
).shouldBypassSingleInstanceLockForArgv;
|
||||
|
||||
assert.equal(typeof shouldBypass, 'function');
|
||||
assert.equal(shouldBypass?.(['SubMiner', '--stats', '--stats-background']), true);
|
||||
assert.equal(shouldBypass?.(['SubMiner', '--stats', '--stats-stop']), true);
|
||||
assert.equal(shouldBypass?.(['SubMiner', '--stats']), false);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,10 @@ interface ElectronSecondInstanceAppLike {
|
||||
on: (event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => unknown;
|
||||
}
|
||||
|
||||
export function shouldBypassSingleInstanceLockForArgv(argv: readonly string[]): boolean {
|
||||
return argv.includes('--stats-background') || argv.includes('--stats-stop');
|
||||
}
|
||||
|
||||
let cachedSingleInstanceLock: boolean | null = null;
|
||||
let secondInstanceListenerAttached = false;
|
||||
const secondInstanceArgvHistory: string[][] = [];
|
||||
|
||||
@@ -4,9 +4,12 @@ import type { BaseWindowTracker } from '../window-trackers';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import { updateVisibleOverlayVisibility } from '../core/services';
|
||||
|
||||
const OVERLAY_LOADING_OSD_COOLDOWN_MS = 30_000;
|
||||
|
||||
export interface OverlayVisibilityRuntimeDeps {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
getTrackerNotReadyWarningShown: () => boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
@@ -28,10 +31,13 @@ export interface OverlayVisibilityRuntimeService {
|
||||
export function createOverlayVisibilityRuntimeService(
|
||||
deps: OverlayVisibilityRuntimeDeps,
|
||||
): OverlayVisibilityRuntimeService {
|
||||
let lastOverlayLoadingOsdAtMs: number | null = null;
|
||||
|
||||
return {
|
||||
updateVisibleOverlayVisibility(): void {
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
||||
forceMousePassthrough: deps.getForceMousePassthrough(),
|
||||
mainWindow: deps.getMainWindow(),
|
||||
windowTracker: deps.getWindowTracker(),
|
||||
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
||||
@@ -48,6 +54,15 @@ export function createOverlayVisibilityRuntimeService(
|
||||
isMacOSPlatform: deps.isMacOSPlatform(),
|
||||
isWindowsPlatform: deps.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
shouldShowOverlayLoadingOsd: () =>
|
||||
lastOverlayLoadingOsdAtMs === null ||
|
||||
Date.now() - lastOverlayLoadingOsdAtMs >= OVERLAY_LOADING_OSD_COOLDOWN_MS,
|
||||
markOverlayLoadingOsdShown: () => {
|
||||
lastOverlayLoadingOsdAtMs = Date.now();
|
||||
},
|
||||
resetOverlayLoadingOsdSuppression: () => {
|
||||
lastOverlayLoadingOsdAtMs = null;
|
||||
},
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -55,7 +55,7 @@ test('ensure anilist media guess main deps builder maps callbacks', async () =>
|
||||
getCurrentMediaTitle: () => 'title',
|
||||
guessAnilistMediaInfo: async () => {
|
||||
calls.push('guess');
|
||||
return { title: 'title', episode: 1, source: 'fallback' };
|
||||
return { title: 'title', season: null, episode: 1, source: 'fallback' };
|
||||
},
|
||||
})();
|
||||
|
||||
@@ -64,6 +64,7 @@ test('ensure anilist media guess main deps builder maps callbacks', async () =>
|
||||
assert.equal(deps.resolveMediaPathForJimaku('/tmp/video.mkv'), '/tmp/video.mkv');
|
||||
assert.deepEqual(await deps.guessAnilistMediaInfo('/tmp/video.mkv', 'title'), {
|
||||
title: 'title',
|
||||
season: null,
|
||||
episode: 1,
|
||||
source: 'fallback',
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
|
||||
getCurrentMediaTitle: () => 'Episode 1',
|
||||
guessAnilistMediaInfo: async () => {
|
||||
calls += 1;
|
||||
return { title: 'Show', episode: 1, source: 'guessit' };
|
||||
return { title: 'Show', season: null, episode: 1, source: 'guessit' };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,9 +57,14 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
|
||||
ensureGuess('/tmp/video.mkv'),
|
||||
ensureGuess('/tmp/video.mkv'),
|
||||
]);
|
||||
assert.deepEqual(first, { title: 'Show', episode: 1, source: 'guessit' });
|
||||
assert.deepEqual(second, { title: 'Show', episode: 1, source: 'guessit' });
|
||||
assert.deepEqual(first, { title: 'Show', season: null, episode: 1, source: 'guessit' });
|
||||
assert.deepEqual(second, { title: 'Show', season: null, episode: 1, source: 'guessit' });
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(state.mediaGuess, { title: 'Show', episode: 1, source: 'guessit' });
|
||||
assert.deepEqual(state.mediaGuess, {
|
||||
title: 'Show',
|
||||
season: null,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
});
|
||||
assert.equal(state.mediaGuessPromise, null);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
test('process next anilist retry update main deps builder maps callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildProcessNextAnilistRetryUpdateMainDepsHandler({
|
||||
nextReady: () => ({ key: 'k', title: 't', episode: 1 }),
|
||||
nextReady: () => ({ key: 'k', title: 't', season: null, episode: 1 }),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
setLastAttemptAt: () => calls.push('attempt'),
|
||||
setLastError: () => calls.push('error'),
|
||||
@@ -59,7 +59,7 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
||||
resetTrackedMedia: () => calls.push('reset'),
|
||||
getWatchedSeconds: () => 100,
|
||||
maybeProbeAnilistDuration: async () => 120,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'x', episode: 1 }),
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
@@ -85,7 +85,11 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
||||
deps.resetTrackedMedia('media');
|
||||
assert.equal(deps.getWatchedSeconds(), 100);
|
||||
assert.equal(await deps.maybeProbeAnilistDuration('media'), 120);
|
||||
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { title: 'x', episode: 1 });
|
||||
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), {
|
||||
title: 'x',
|
||||
season: null,
|
||||
episode: 1,
|
||||
});
|
||||
assert.equal(deps.hasAttemptedUpdateKey('k'), false);
|
||||
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
|
||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||
|
||||
@@ -20,7 +20,7 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
|
||||
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createProcessNextAnilistRetryUpdateHandler({
|
||||
nextReady: () => ({ key: 'k1', title: 'Show', episode: 1 }),
|
||||
nextReady: () => ({ key: 'k1', title: 'Show', season: null, episode: 1 }),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
setLastAttemptAt: () => calls.push('attempt'),
|
||||
setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
|
||||
@@ -52,7 +52,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 1 }),
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => null,
|
||||
|
||||
@@ -78,7 +78,7 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
mpvClient: TMpv;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number) => void;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
@@ -89,6 +89,6 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
mpvClient: TMpv;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => deps.mineSentenceCardCore(options),
|
||||
recordCardsMined: (count: number) => deps.recordCardsMined(count),
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
|
||||
mpvClient: TMpv;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number) => void;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const created = await deps.mineSentenceCardCore({
|
||||
|
||||
@@ -34,10 +34,13 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: deps.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
|
||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||
runHeadlessInitialCommand: deps.runHeadlessInitialCommand,
|
||||
handleInitialArgs: deps.handleInitialArgs,
|
||||
onCriticalConfigErrors: deps.onCriticalConfigErrors,
|
||||
logDebug: deps.logDebug,
|
||||
now: deps.now,
|
||||
shouldRunHeadlessInitialCommand: deps.shouldRunHeadlessInitialCommand,
|
||||
shouldUseMinimalStartup: deps.shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { handleCharacterDictionaryAutoSyncComplete } from './character-dictionary-auto-sync-completion';
|
||||
|
||||
test('character dictionary sync completion skips expensive subtitle refresh when dictionary is unchanged', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
handleCharacterDictionaryAutoSyncComplete(
|
||||
{
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Frieren',
|
||||
changed: false,
|
||||
},
|
||||
{
|
||||
hasParserWindow: () => true,
|
||||
clearParserCaches: () => calls.push('clear-parser'),
|
||||
invalidateTokenizationCache: () => calls.push('invalidate'),
|
||||
refreshSubtitlePrefetch: () => calls.push('prefetch'),
|
||||
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'log:[dictionary:auto-sync] refreshed current subtitle after sync (AniList 1, changed=no, title=Frieren)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('character dictionary sync completion refreshes subtitle state when dictionary changed', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
handleCharacterDictionaryAutoSyncComplete(
|
||||
{
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Frieren',
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
hasParserWindow: () => true,
|
||||
clearParserCaches: () => calls.push('clear-parser'),
|
||||
invalidateTokenizationCache: () => calls.push('invalidate'),
|
||||
refreshSubtitlePrefetch: () => calls.push('prefetch'),
|
||||
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'clear-parser',
|
||||
'invalidate',
|
||||
'prefetch',
|
||||
'refresh-subtitle',
|
||||
'log:[dictionary:auto-sync] refreshed current subtitle after sync (AniList 1, changed=yes, title=Frieren)',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
export function handleCharacterDictionaryAutoSyncComplete(
|
||||
completion: {
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
changed: boolean;
|
||||
},
|
||||
deps: {
|
||||
hasParserWindow: () => boolean;
|
||||
clearParserCaches: () => void;
|
||||
invalidateTokenizationCache: () => void;
|
||||
refreshSubtitlePrefetch: () => void;
|
||||
refreshCurrentSubtitle: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
},
|
||||
): void {
|
||||
if (completion.changed) {
|
||||
if (deps.hasParserWindow()) {
|
||||
deps.clearParserCaches();
|
||||
}
|
||||
deps.invalidateTokenizationCache();
|
||||
deps.refreshSubtitlePrefetch();
|
||||
deps.refreshCurrentSubtitle();
|
||||
}
|
||||
deps.logInfo(
|
||||
`[dictionary:auto-sync] refreshed current subtitle after sync (AniList ${completion.mediaId}, changed=${completion.changed ? 'yes' : 'no'}, title=${completion.mediaTitle})`,
|
||||
);
|
||||
}
|
||||
@@ -83,16 +83,16 @@ test('auto sync imports merged dictionary and persists MRU state', async () => {
|
||||
|
||||
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
|
||||
activeMediaIds: number[];
|
||||
activeMediaIds: string[];
|
||||
mergedRevision: string | null;
|
||||
mergedDictionaryTitle: string | null;
|
||||
};
|
||||
assert.deepEqual(state.activeMediaIds, [130298]);
|
||||
assert.deepEqual(state.activeMediaIds, ['130298 - The Eminence in Shadow']);
|
||||
assert.equal(state.mergedRevision, 'rev-1');
|
||||
assert.equal(state.mergedDictionaryTitle, 'SubMiner Character Dictionary');
|
||||
assert.deepEqual(logs, [
|
||||
'[dictionary:auto-sync] syncing current anime snapshot',
|
||||
'[dictionary:auto-sync] active AniList media set: 130298',
|
||||
'[dictionary:auto-sync] active AniList media set: 130298 - The Eminence in Shadow',
|
||||
'[dictionary:auto-sync] rebuilding merged dictionary for active anime set',
|
||||
'[dictionary:auto-sync] importing merged dictionary: /tmp/subminer-character-dictionary.zip',
|
||||
'[dictionary:auto-sync] applying Yomitan settings for SubMiner Character Dictionary',
|
||||
@@ -150,6 +150,59 @@ test('auto sync skips rebuild/import on unchanged revisit when merged dictionary
|
||||
assert.deepEqual(imports, ['/tmp/merged.zip']);
|
||||
});
|
||||
|
||||
test('auto sync does not emit updating progress for unchanged revisit when merged dictionary is current', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
let importedRevision: string | null = null;
|
||||
let currentRun: string[] = [];
|
||||
const phaseHistory: string[][] = [];
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 7,
|
||||
mediaTitle: 'Frieren',
|
||||
entryCount: 100,
|
||||
fromCache: true,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-7',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 100,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async () => {
|
||||
importedRevision = 'rev-7';
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => false,
|
||||
now: () => 1000,
|
||||
onSyncStatus: (event) => {
|
||||
currentRun.push(event.phase);
|
||||
},
|
||||
});
|
||||
|
||||
currentRun = [];
|
||||
await runtime.runSyncNow();
|
||||
phaseHistory.push([...currentRun]);
|
||||
currentRun = [];
|
||||
await runtime.runSyncNow();
|
||||
phaseHistory.push([...currentRun]);
|
||||
|
||||
assert.deepEqual(phaseHistory[0], ['building', 'importing', 'ready']);
|
||||
assert.deepEqual(phaseHistory[1], ['ready']);
|
||||
});
|
||||
|
||||
test('auto sync updates MRU order without rebuilding merged dictionary when membership is unchanged', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const sequence = [1, 2, 1];
|
||||
@@ -212,9 +265,66 @@ test('auto sync updates MRU order without rebuilding merged dictionary when memb
|
||||
|
||||
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
|
||||
activeMediaIds: number[];
|
||||
activeMediaIds: string[];
|
||||
};
|
||||
assert.deepEqual(state.activeMediaIds, [1, 2]);
|
||||
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '2 - Title 2']);
|
||||
});
|
||||
|
||||
test('auto sync reimports existing merged zip without rebuilding on unchanged revisit', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(dictionariesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dictionariesDir, 'merged.zip'), 'cached-zip', 'utf8');
|
||||
const mergedBuilds: number[][] = [];
|
||||
const imports: string[] = [];
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 7,
|
||||
mediaTitle: 'Frieren',
|
||||
entryCount: 100,
|
||||
fromCache: true,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async (mediaIds) => {
|
||||
mergedBuilds.push([...mediaIds]);
|
||||
return {
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-7',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 100,
|
||||
};
|
||||
},
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
imports.push(zipPath);
|
||||
importedRevision = 'rev-7';
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
importedRevision = null;
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(mergedBuilds, [[7]]);
|
||||
assert.deepEqual(imports, [
|
||||
'/tmp/merged.zip',
|
||||
path.join(userDataPath, 'character-dictionaries', 'merged.zip'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto sync evicts least recently used media from merged set', async () => {
|
||||
@@ -277,9 +387,9 @@ test('auto sync evicts least recently used media from merged set', async () => {
|
||||
|
||||
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
|
||||
activeMediaIds: number[];
|
||||
activeMediaIds: string[];
|
||||
};
|
||||
assert.deepEqual(state.activeMediaIds, [4, 3, 2]);
|
||||
assert.deepEqual(state.activeMediaIds, ['4 - Title 4', '3 - Title 3', '2 - Title 2']);
|
||||
});
|
||||
|
||||
test('auto sync keeps revisited media retained when a new title is added afterward', async () => {
|
||||
@@ -344,9 +454,9 @@ test('auto sync keeps revisited media retained when a new title is added afterwa
|
||||
|
||||
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
|
||||
activeMediaIds: number[];
|
||||
activeMediaIds: string[];
|
||||
};
|
||||
assert.deepEqual(state.activeMediaIds, [1, 4, 3]);
|
||||
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '4 - Title 4', '3 - Title 3']);
|
||||
});
|
||||
|
||||
test('auto sync persists rebuilt MRU state even if Yomitan import fails afterward', async () => {
|
||||
@@ -404,11 +514,11 @@ test('auto sync persists rebuilt MRU state even if Yomitan import fails afterwar
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(dictionariesDir, 'auto-sync-state.json'), 'utf8'),
|
||||
) as {
|
||||
activeMediaIds: number[];
|
||||
activeMediaIds: string[];
|
||||
mergedRevision: string | null;
|
||||
mergedDictionaryTitle: string | null;
|
||||
};
|
||||
assert.deepEqual(state.activeMediaIds, [1, 2, 3]);
|
||||
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '2', '3']);
|
||||
assert.equal(state.mergedRevision, 'rev-1-2-3');
|
||||
assert.equal(state.mergedDictionaryTitle, 'SubMiner Character Dictionary');
|
||||
});
|
||||
@@ -537,12 +647,6 @@ test('auto sync emits progress events for start import and completion', async ()
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
message: 'Generating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||
},
|
||||
{
|
||||
phase: 'syncing',
|
||||
mediaId: 101291,
|
||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
message: 'Updating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||
},
|
||||
{
|
||||
phase: 'building',
|
||||
mediaId: 101291,
|
||||
|
||||
@@ -7,8 +7,13 @@ import type {
|
||||
MergedCharacterDictionaryBuildResult,
|
||||
} from '../character-dictionary-runtime';
|
||||
|
||||
type AutoSyncMediaEntry = {
|
||||
mediaId: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type AutoSyncState = {
|
||||
activeMediaIds: number[];
|
||||
activeMediaIds: AutoSyncMediaEntry[];
|
||||
mergedRevision: string | null;
|
||||
mergedDictionaryTitle: string | null;
|
||||
};
|
||||
@@ -64,16 +69,66 @@ function ensureDir(dirPath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMediaId(rawMediaId: number): number | null {
|
||||
const mediaId = Math.max(1, Math.floor(rawMediaId));
|
||||
return Number.isFinite(mediaId) ? mediaId : null;
|
||||
}
|
||||
|
||||
function parseActiveMediaEntry(rawEntry: unknown): AutoSyncMediaEntry | null {
|
||||
if (typeof rawEntry === 'number') {
|
||||
const mediaId = normalizeMediaId(rawEntry);
|
||||
if (mediaId === null) {
|
||||
return null;
|
||||
}
|
||||
return { mediaId, label: String(mediaId) };
|
||||
}
|
||||
|
||||
if (typeof rawEntry !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = rawEntry.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rawId, ...rawTitleParts] = trimmed.split(' - ');
|
||||
if (!rawId || !/^\d+$/.test(rawId)) {
|
||||
return null;
|
||||
}
|
||||
const mediaId = normalizeMediaId(Number.parseInt(rawId ?? '', 10));
|
||||
if (mediaId === null || mediaId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawLabel = rawTitleParts.length > 0 ? rawTitleParts.join(' - ').trim() : '';
|
||||
return { mediaId, label: rawLabel ? `${mediaId} - ${rawLabel}` : String(mediaId) };
|
||||
}
|
||||
|
||||
function buildActiveMediaLabel(mediaId: number, mediaTitle: string | null | undefined): string {
|
||||
const normalizedId = normalizeMediaId(mediaId);
|
||||
const trimmedTitle = typeof mediaTitle === 'string' ? mediaTitle.trim() : '';
|
||||
if (normalizedId === null) {
|
||||
return trimmedTitle;
|
||||
}
|
||||
return trimmedTitle.length > 0 ? `${normalizedId} - ${trimmedTitle}` : String(normalizedId);
|
||||
}
|
||||
|
||||
function readAutoSyncState(statePath: string): AutoSyncState {
|
||||
try {
|
||||
const raw = fs.readFileSync(statePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<AutoSyncState>;
|
||||
const activeMediaIds = Array.isArray(parsed.activeMediaIds)
|
||||
? parsed.activeMediaIds
|
||||
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||||
.map((value) => Math.max(1, Math.floor(value)))
|
||||
.filter((value, index, all) => all.indexOf(value) === index)
|
||||
: [];
|
||||
const activeMediaIds: AutoSyncMediaEntry[] = [];
|
||||
const activeMediaIdSet = new Set<number>();
|
||||
if (Array.isArray(parsed.activeMediaIds)) {
|
||||
for (const value of parsed.activeMediaIds) {
|
||||
const entry = parseActiveMediaEntry(value);
|
||||
if (entry && !activeMediaIdSet.has(entry.mediaId)) {
|
||||
activeMediaIdSet.add(entry.mediaId);
|
||||
activeMediaIds.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
activeMediaIds,
|
||||
mergedRevision:
|
||||
@@ -96,7 +151,12 @@ function readAutoSyncState(statePath: string): AutoSyncState {
|
||||
|
||||
function writeAutoSyncState(statePath: string, state: AutoSyncState): void {
|
||||
ensureDir(path.dirname(statePath));
|
||||
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
|
||||
const persistedState = {
|
||||
activeMediaIds: state.activeMediaIds.map((entry) => entry.label),
|
||||
mergedRevision: state.mergedRevision,
|
||||
mergedDictionaryTitle: state.mergedDictionaryTitle,
|
||||
};
|
||||
fs.writeFileSync(statePath, JSON.stringify(persistedState, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function arraysEqual(left: number[], right: number[]): boolean {
|
||||
@@ -215,23 +275,24 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
});
|
||||
currentMediaId = snapshot.mediaId;
|
||||
currentMediaTitle = snapshot.mediaTitle;
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'syncing',
|
||||
mediaId: snapshot.mediaId,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
message: buildSyncingMessage(snapshot.mediaTitle),
|
||||
});
|
||||
const state = readAutoSyncState(statePath);
|
||||
const nextActiveMediaIds = [
|
||||
snapshot.mediaId,
|
||||
...state.activeMediaIds.filter((mediaId) => mediaId !== snapshot.mediaId),
|
||||
{
|
||||
mediaId: snapshot.mediaId,
|
||||
label: buildActiveMediaLabel(snapshot.mediaId, snapshot.mediaTitle),
|
||||
},
|
||||
...state.activeMediaIds.filter((entry) => entry.mediaId !== snapshot.mediaId),
|
||||
].slice(0, Math.max(1, Math.floor(config.maxLoaded)));
|
||||
const nextActiveMediaIdValues = nextActiveMediaIds.map((entry) => entry.mediaId);
|
||||
deps.logInfo?.(
|
||||
`[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds.join(', ')}`,
|
||||
`[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds
|
||||
.map((entry) => entry.label)
|
||||
.join(', ')}`,
|
||||
);
|
||||
|
||||
const retainedOrderChanged = !arraysEqual(nextActiveMediaIds, state.activeMediaIds);
|
||||
const retainedMembershipChanged = !sameMembership(nextActiveMediaIds, state.activeMediaIds);
|
||||
const stateMediaIds = state.activeMediaIds.map((entry) => entry.mediaId);
|
||||
const retainedOrderChanged = !arraysEqual(nextActiveMediaIdValues, stateMediaIds);
|
||||
const retainedMembershipChanged = !sameMembership(nextActiveMediaIdValues, stateMediaIds);
|
||||
let merged: MergedCharacterDictionaryBuildResult | null = null;
|
||||
if (
|
||||
retainedMembershipChanged ||
|
||||
@@ -246,7 +307,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
message: buildBuildingMessage(snapshot.mediaTitle),
|
||||
});
|
||||
deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set');
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIdValues);
|
||||
}
|
||||
|
||||
const dictionaryTitle = merged?.dictionaryTitle ?? state.mergedDictionaryTitle;
|
||||
@@ -293,7 +354,17 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
);
|
||||
}
|
||||
if (merged === null) {
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
|
||||
const existingMergedZipPath = path.join(dictionariesDir, 'merged.zip');
|
||||
if (fs.existsSync(existingMergedZipPath)) {
|
||||
merged = {
|
||||
zipPath: existingMergedZipPath,
|
||||
revision,
|
||||
dictionaryTitle,
|
||||
entryCount: snapshot.entryCount,
|
||||
};
|
||||
} else {
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIdValues);
|
||||
}
|
||||
}
|
||||
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
|
||||
const imported = await withOperationTimeout(
|
||||
|
||||
@@ -54,6 +54,9 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 10,
|
||||
}),
|
||||
runStatsCommand: async () => {
|
||||
calls.push('run-stats');
|
||||
},
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -80,6 +81,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||
runStatsCommand: deps.runStatsCommand,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||
@@ -61,6 +61,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 10,
|
||||
}),
|
||||
runStatsCommand: async () => {},
|
||||
runJellyfinCommand: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
|
||||
@@ -78,6 +78,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 10,
|
||||
}),
|
||||
runStatsCommand: async () => {
|
||||
calls.push('run-stats');
|
||||
},
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
||||
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
@@ -92,6 +93,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
|
||||
generateCharacterDictionary: (targetPath?: string) =>
|
||||
deps.generateCharacterDictionary(targetPath),
|
||||
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
|
||||
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
|
||||
@@ -48,6 +48,7 @@ function createDeps() {
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 1,
|
||||
}),
|
||||
runStatsCommand: async () => {},
|
||||
runJellyfinCommand: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
|
||||
@@ -39,6 +39,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -92,6 +93,7 @@ export function createCliCommandContext(
|
||||
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||
runStatsCommand: deps.runStatsCommand,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||
@@ -8,6 +8,7 @@ test('cli prechecks main deps builder maps transition handlers', () => {
|
||||
isTexthookerOnlyMode: () => true,
|
||||
setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`),
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
})();
|
||||
@@ -15,7 +16,8 @@ test('cli prechecks main deps builder maps transition handlers', () => {
|
||||
assert.equal(deps.isTexthookerOnlyMode(), true);
|
||||
assert.equal(deps.commandNeedsOverlayRuntime({} as never), true);
|
||||
deps.setTexthookerOnlyMode(false);
|
||||
deps.ensureOverlayStartupPrereqs();
|
||||
deps.startBackgroundWarmups();
|
||||
deps.logInfo('x');
|
||||
assert.deepEqual(calls, ['set:false', 'warmups', 'info:x']);
|
||||
assert.deepEqual(calls, ['set:false', 'prereqs', 'warmups', 'info:x']);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(dep
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
setTexthookerOnlyMode: (enabled: boolean) => void;
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
@@ -11,6 +12,7 @@ export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(dep
|
||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||
setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled),
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
|
||||
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
|
||||
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ test('texthooker precheck no-ops when mode is disabled', () => {
|
||||
isTexthookerOnlyMode: () => false,
|
||||
setTexthookerOnlyMode: () => {},
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
startBackgroundWarmups: () => {
|
||||
warmups += 1;
|
||||
},
|
||||
@@ -22,12 +23,16 @@ test('texthooker precheck disables mode and warms up on start command', () => {
|
||||
let mode = true;
|
||||
let warmups = 0;
|
||||
let logs = 0;
|
||||
let prereqs = 0;
|
||||
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
|
||||
isTexthookerOnlyMode: () => mode,
|
||||
setTexthookerOnlyMode: (enabled) => {
|
||||
mode = enabled;
|
||||
},
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
prereqs += 1;
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
warmups += 1;
|
||||
},
|
||||
@@ -38,6 +43,7 @@ test('texthooker precheck disables mode and warms up on start command', () => {
|
||||
|
||||
handlePrecheck({ start: true, texthooker: false } as never);
|
||||
assert.equal(mode, false);
|
||||
assert.equal(prereqs, 1);
|
||||
assert.equal(warmups, 1);
|
||||
assert.equal(logs, 1);
|
||||
});
|
||||
@@ -50,6 +56,7 @@ test('texthooker precheck no-ops for texthooker command', () => {
|
||||
mode = enabled;
|
||||
},
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
setTexthookerOnlyMode: (enabled: boolean) => void;
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
@@ -13,6 +14,7 @@ export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
|
||||
!args.texthooker &&
|
||||
(args.start || deps.commandNeedsOverlayRuntime(args))
|
||||
) {
|
||||
deps.ensureOverlayStartupPrereqs();
|
||||
deps.setTexthookerOnlyMode(false);
|
||||
deps.logInfo('Disabling texthooker-only mode after overlay/start command.');
|
||||
deps.startBackgroundWarmups();
|
||||
|
||||
@@ -9,6 +9,7 @@ test('cli command runtime handler applies precheck and forwards command with con
|
||||
isTexthookerOnlyMode: () => true,
|
||||
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
},
|
||||
@@ -24,6 +25,7 @@ test('cli command runtime handler applies precheck and forwards command with con
|
||||
handler({ start: true } as never);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'prereqs',
|
||||
'set-mode',
|
||||
'log:Disabling texthooker-only mode after overlay/start command.',
|
||||
'warmups',
|
||||
|
||||
@@ -131,11 +131,11 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
|
||||
getCurrentMediaTitle: () => 'Episode title',
|
||||
guessAnilistMediaInfo: async () => {
|
||||
guessAnilistMediaInfoCalls += 1;
|
||||
return { title: 'Episode title', episode: 7, source: 'guessit' };
|
||||
return { title: 'Episode title', season: null, episode: 7, source: 'guessit' };
|
||||
},
|
||||
},
|
||||
processNextRetryUpdateMainDeps: {
|
||||
nextReady: () => ({ key: 'retry-key', title: 'Retry title', episode: 1 }),
|
||||
nextReady: () => ({ key: 'retry-key', title: 'Retry title', season: null, episode: 1 }),
|
||||
refreshRetryQueueState: () => {},
|
||||
setLastAttemptAt: () => {},
|
||||
setLastError: () => {},
|
||||
@@ -163,6 +163,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
|
||||
maybeProbeAnilistDuration: async () => 600,
|
||||
ensureAnilistMediaGuess: async () => ({
|
||||
title: 'Episode title',
|
||||
season: null,
|
||||
episode: 2,
|
||||
source: 'guessit',
|
||||
}),
|
||||
@@ -209,7 +210,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
|
||||
composed.setAnilistMediaGuessRuntimeState({
|
||||
mediaKey: 'media-key',
|
||||
mediaDurationSec: 90,
|
||||
mediaGuess: { title: 'Known', episode: 3, source: 'fallback' },
|
||||
mediaGuess: { title: 'Known', season: null, episode: 3, source: 'fallback' },
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 11,
|
||||
});
|
||||
|
||||
@@ -51,6 +51,8 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
getMecabTokenizer: () => null,
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}) as never,
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getControllerConfig: () => ({}) as never,
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
|
||||
@@ -25,7 +25,7 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
||||
return {
|
||||
...config.subtitleStyle,
|
||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
||||
knownWordColor: config.ankiConnect.nPlusOne.knownWord,
|
||||
knownWordColor: config.ankiConnect.knownWords.color,
|
||||
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
||||
enableJlpt: config.subtitleStyle.enableJlpt,
|
||||
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
|
||||
|
||||
@@ -40,3 +40,19 @@ test('current media tokenization gate returns immediately for ready media', asyn
|
||||
|
||||
await gate.waitUntilReady('/tmp/video-1.mkv');
|
||||
});
|
||||
|
||||
test('current media tokenization gate stays ready for later media after first warmup', async () => {
|
||||
const gate = createCurrentMediaTokenizationGate();
|
||||
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
|
||||
gate.markReady('/tmp/video-1.mkv');
|
||||
gate.updateCurrentMediaPath('/tmp/video-2.mkv');
|
||||
|
||||
let resolved = false;
|
||||
const waitPromise = gate.waitUntilReady('/tmp/video-2.mkv').then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(resolved, true);
|
||||
await waitPromise;
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ export function createCurrentMediaTokenizationGate(): {
|
||||
} {
|
||||
let currentMediaPath: string | null = null;
|
||||
let readyMediaPath: string | null = null;
|
||||
let warmupCompleted = false;
|
||||
let pendingMediaPath: string | null = null;
|
||||
let pendingPromise: Promise<void> | null = null;
|
||||
let resolvePending: (() => void) | null = null;
|
||||
@@ -43,6 +44,11 @@ export function createCurrentMediaTokenizationGate(): {
|
||||
return;
|
||||
}
|
||||
currentMediaPath = normalizedPath;
|
||||
if (warmupCompleted) {
|
||||
readyMediaPath = normalizedPath;
|
||||
resolvePendingWaiter();
|
||||
return;
|
||||
}
|
||||
readyMediaPath = null;
|
||||
resolvePendingWaiter();
|
||||
if (normalizedPath) {
|
||||
@@ -54,6 +60,7 @@ export function createCurrentMediaTokenizationGate(): {
|
||||
if (!normalizedPath) {
|
||||
return;
|
||||
}
|
||||
warmupCompleted = true;
|
||||
readyMediaPath = normalizedPath;
|
||||
if (pendingMediaPath === normalizedPath) {
|
||||
resolvePendingWaiter();
|
||||
@@ -61,7 +68,7 @@ export function createCurrentMediaTokenizationGate(): {
|
||||
},
|
||||
waitUntilReady: async (mediaPath) => {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath) ?? currentMediaPath;
|
||||
if (!normalizedPath || readyMediaPath === normalizedPath) {
|
||||
if (warmupCompleted || !normalizedPath || readyMediaPath === normalizedPath) {
|
||||
return;
|
||||
}
|
||||
await ensurePendingPromise(normalizedPath);
|
||||
|
||||
@@ -48,6 +48,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
|
||||
@@ -14,6 +14,7 @@ function makeConfig() {
|
||||
retention: {
|
||||
eventsDays: 14,
|
||||
telemetryDays: 30,
|
||||
sessionsDays: 45,
|
||||
dailyRollupsDays: 180,
|
||||
monthlyRollupsDays: 730,
|
||||
vacuumIntervalDays: 7,
|
||||
@@ -97,6 +98,7 @@ test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv
|
||||
retention: {
|
||||
eventsDays: 14,
|
||||
telemetryDays: 30,
|
||||
sessionsDays: 45,
|
||||
dailyRollupsDays: 180,
|
||||
monthlyRollupsDays: 730,
|
||||
vacuumIntervalDays: 7,
|
||||
@@ -135,3 +137,28 @@ test('createImmersionTrackerStartupHandler disables tracker on failure', () => {
|
||||
calls.includes('warn:Immersion tracker startup failed; disabling tracking.:db unavailable'),
|
||||
);
|
||||
});
|
||||
|
||||
test('createImmersionTrackerStartupHandler skips mpv auto-connect when disabled by caller', () => {
|
||||
let connectCalls = 0;
|
||||
const handler = createImmersionTrackerStartupHandler({
|
||||
getResolvedConfig: () => makeConfig(),
|
||||
getConfiguredDbPath: () => '/tmp/subminer.db',
|
||||
createTrackerService: () => ({}),
|
||||
setTracker: () => {},
|
||||
getMpvClient: () => ({
|
||||
connected: false,
|
||||
connect: () => {
|
||||
connectCalls += 1;
|
||||
},
|
||||
}),
|
||||
shouldAutoConnectMpv: () => false,
|
||||
seedTrackerFromCurrentMedia: () => {},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
handler();
|
||||
|
||||
assert.equal(connectCalls, 0);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
type ImmersionRetentionPolicy = {
|
||||
eventsDays: number;
|
||||
telemetryDays: number;
|
||||
sessionsDays: number;
|
||||
dailyRollupsDays: number;
|
||||
monthlyRollupsDays: number;
|
||||
vacuumIntervalDays: number;
|
||||
@@ -38,6 +39,7 @@ export type ImmersionTrackerStartupDeps = {
|
||||
createTrackerService: (params: ImmersionTrackerServiceParams) => unknown;
|
||||
setTracker: (tracker: unknown | null) => void;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
shouldAutoConnectMpv?: () => boolean;
|
||||
seedTrackerFromCurrentMedia: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
@@ -76,6 +78,7 @@ export function createImmersionTrackerStartupHandler(
|
||||
retention: {
|
||||
eventsDays: policy.retention.eventsDays,
|
||||
telemetryDays: policy.retention.telemetryDays,
|
||||
sessionsDays: policy.retention.sessionsDays,
|
||||
dailyRollupsDays: policy.retention.dailyRollupsDays,
|
||||
monthlyRollupsDays: policy.retention.monthlyRollupsDays,
|
||||
vacuumIntervalDays: policy.retention.vacuumIntervalDays,
|
||||
@@ -86,7 +89,7 @@ export function createImmersionTrackerStartupHandler(
|
||||
deps.logDebug('Immersion tracker initialized successfully.');
|
||||
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (mpvClient && !mpvClient.connected) {
|
||||
if ((deps.shouldAutoConnectMpv?.() ?? true) && mpvClient && !mpvClient.connected) {
|
||||
deps.logInfo('Auto-connecting MPV client for immersion tracking');
|
||||
mpvClient.connect();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ test('initial args handler no-ops without initial args', () => {
|
||||
getInitialArgs: () => null,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => false,
|
||||
@@ -28,6 +29,7 @@ test('initial args handler ensures tray in background mode', () => {
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {
|
||||
ensuredTray = true;
|
||||
},
|
||||
@@ -49,6 +51,7 @@ test('initial args handler auto-connects mpv when needed', () => {
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
@@ -75,6 +78,7 @@ test('initial args handler forwards args to cli handler', () => {
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => false,
|
||||
@@ -95,6 +99,7 @@ test('initial args handler can ensure tray outside background mode when requeste
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => true,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {
|
||||
ensuredTray = true;
|
||||
},
|
||||
@@ -108,3 +113,31 @@ test('initial args handler can ensure tray outside background mode when requeste
|
||||
handleInitialArgs();
|
||||
assert.equal(ensuredTray, true);
|
||||
});
|
||||
|
||||
test('initial args handler skips tray and mpv auto-connect for headless refresh', () => {
|
||||
let ensuredTray = false;
|
||||
let connectCalls = 0;
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => ({ refreshKnownWords: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => true,
|
||||
shouldRunHeadlessInitialCommand: () => true,
|
||||
ensureTray: () => {
|
||||
ensuredTray = true;
|
||||
},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
getMpvClient: () => ({
|
||||
connected: false,
|
||||
connect: () => {
|
||||
connectCalls += 1;
|
||||
},
|
||||
}),
|
||||
logInfo: () => {},
|
||||
handleCliCommand: () => {},
|
||||
});
|
||||
|
||||
handleInitialArgs();
|
||||
assert.equal(ensuredTray, false);
|
||||
assert.equal(connectCalls, 0);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export function createHandleInitialArgsHandler(deps: {
|
||||
getInitialArgs: () => CliArgs | null;
|
||||
isBackgroundMode: () => boolean;
|
||||
shouldEnsureTrayOnStartup: () => boolean;
|
||||
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
|
||||
ensureTray: () => void;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
hasImmersionTracker: () => boolean;
|
||||
@@ -19,14 +20,17 @@ export function createHandleInitialArgsHandler(deps: {
|
||||
return (): void => {
|
||||
const initialArgs = deps.getInitialArgs();
|
||||
if (!initialArgs) return;
|
||||
const runHeadless = deps.shouldRunHeadlessInitialCommand(initialArgs);
|
||||
|
||||
if (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup()) {
|
||||
if (!runHeadless && (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup())) {
|
||||
deps.ensureTray();
|
||||
}
|
||||
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (
|
||||
!runHeadless &&
|
||||
!deps.isTexthookerOnlyMode() &&
|
||||
!initialArgs.stats &&
|
||||
deps.hasImmersionTracker() &&
|
||||
mpvClient &&
|
||||
!mpvClient.connected
|
||||
|
||||
@@ -10,6 +10,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
||||
getInitialArgs: () => args,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => calls.push('ensure-tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
@@ -21,6 +22,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
||||
assert.equal(deps.getInitialArgs(), args);
|
||||
assert.equal(deps.isBackgroundMode(), true);
|
||||
assert.equal(deps.shouldEnsureTrayOnStartup(), false);
|
||||
assert.equal(deps.shouldRunHeadlessInitialCommand(args), false);
|
||||
assert.equal(deps.isTexthookerOnlyMode(), false);
|
||||
assert.equal(deps.hasImmersionTracker(), true);
|
||||
assert.equal(deps.getMpvClient(), mpvClient);
|
||||
|
||||
@@ -4,6 +4,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
||||
getInitialArgs: () => CliArgs | null;
|
||||
isBackgroundMode: () => boolean;
|
||||
shouldEnsureTrayOnStartup: () => boolean;
|
||||
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
|
||||
ensureTray: () => void;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
hasImmersionTracker: () => boolean;
|
||||
@@ -15,6 +16,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
||||
getInitialArgs: () => deps.getInitialArgs(),
|
||||
isBackgroundMode: () => deps.isBackgroundMode(),
|
||||
shouldEnsureTrayOnStartup: () => deps.shouldEnsureTrayOnStartup(),
|
||||
shouldRunHeadlessInitialCommand: (args: CliArgs) => deps.shouldRunHeadlessInitialCommand(args),
|
||||
ensureTray: () => deps.ensureTray(),
|
||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||
hasImmersionTracker: () => deps.hasImmersionTracker(),
|
||||
|
||||
@@ -8,6 +8,7 @@ test('initial args runtime handler composes main deps and runs initial command f
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => calls.push('tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
@@ -28,3 +29,49 @@ test('initial args runtime handler composes main deps and runs initial command f
|
||||
'cli:initial',
|
||||
]);
|
||||
});
|
||||
|
||||
test('initial args runtime handler skips mpv auto-connect for stats mode', () => {
|
||||
const calls: string[] = [];
|
||||
const handleInitialArgs = createInitialArgsRuntimeHandler({
|
||||
getInitialArgs: () => ({ stats: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => calls.push('tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
getMpvClient: () => ({
|
||||
connected: false,
|
||||
connect: () => calls.push('connect'),
|
||||
}),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
|
||||
});
|
||||
|
||||
handleInitialArgs();
|
||||
|
||||
assert.deepEqual(calls, ['cli:initial']);
|
||||
});
|
||||
|
||||
test('initial args runtime handler skips tray and mpv auto-connect for headless refresh', () => {
|
||||
const calls: string[] = [];
|
||||
const handleInitialArgs = createInitialArgsRuntimeHandler({
|
||||
getInitialArgs: () => ({ refreshKnownWords: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => true,
|
||||
shouldRunHeadlessInitialCommand: () => true,
|
||||
ensureTray: () => calls.push('tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
getMpvClient: () => ({
|
||||
connected: false,
|
||||
connect: () => calls.push('connect'),
|
||||
}),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
|
||||
});
|
||||
|
||||
handleInitialArgs();
|
||||
|
||||
assert.deepEqual(calls, ['cli:initial']);
|
||||
});
|
||||
|
||||
@@ -29,10 +29,13 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
|
||||
|
||||
test('mpv connection handler syncs overlay subtitle suppression on connect', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvConnectionChangeHandler({
|
||||
const deps: Parameters<typeof createHandleMpvConnectionChangeHandler>[0] & {
|
||||
scheduleCharacterDictionarySync: () => void;
|
||||
} = {
|
||||
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
hasInitialJellyfinPlayArg: () => true,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => true,
|
||||
@@ -41,7 +44,8 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
|
||||
},
|
||||
isMpvConnected: () => false,
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
};
|
||||
const handler = createHandleMpvConnectionChangeHandler(deps);
|
||||
|
||||
handler({ connected: true });
|
||||
|
||||
@@ -71,10 +75,13 @@ test('mpv event bindings register all expected events', () => {
|
||||
onSubtitleChange: () => {},
|
||||
onSubtitleAssChange: () => {},
|
||||
onSecondarySubtitleChange: () => {},
|
||||
onSubtitleTrackChange: () => {},
|
||||
onSubtitleTrackListChange: () => {},
|
||||
onSubtitleTiming: () => {},
|
||||
onMediaPathChange: () => {},
|
||||
onMediaTitleChange: () => {},
|
||||
onTimePosChange: () => {},
|
||||
onDurationChange: () => {},
|
||||
onPauseChange: () => {},
|
||||
onSubtitleMetricsChange: () => {},
|
||||
onSecondarySubtitleVisibility: () => {},
|
||||
@@ -91,10 +98,13 @@ test('mpv event bindings register all expected events', () => {
|
||||
'subtitle-change',
|
||||
'subtitle-ass-change',
|
||||
'secondary-subtitle-change',
|
||||
'subtitle-track-change',
|
||||
'subtitle-track-list-change',
|
||||
'subtitle-timing',
|
||||
'media-path-change',
|
||||
'media-title-change',
|
||||
'time-pos-change',
|
||||
'duration-change',
|
||||
'pause-change',
|
||||
'subtitle-metrics-change',
|
||||
'secondary-subtitle-visibility',
|
||||
|
||||
@@ -3,10 +3,13 @@ type MpvBindingEventName =
|
||||
| 'subtitle-change'
|
||||
| 'subtitle-ass-change'
|
||||
| 'secondary-subtitle-change'
|
||||
| 'subtitle-track-change'
|
||||
| 'subtitle-track-list-change'
|
||||
| 'subtitle-timing'
|
||||
| 'media-path-change'
|
||||
| 'media-title-change'
|
||||
| 'time-pos-change'
|
||||
| 'duration-change'
|
||||
| 'pause-change'
|
||||
| 'subtitle-metrics-change'
|
||||
| 'secondary-subtitle-visibility';
|
||||
@@ -19,7 +22,6 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
hasInitialJellyfinPlayArg: () => boolean;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
isQuitOnDisconnectArmed: () => boolean;
|
||||
@@ -31,7 +33,6 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
deps.refreshDiscordPresence();
|
||||
if (connected) {
|
||||
deps.syncOverlayMpvSubtitleSuppression();
|
||||
deps.scheduleCharacterDictionarySync?.();
|
||||
return;
|
||||
}
|
||||
deps.reportJellyfinRemoteStopped();
|
||||
@@ -68,10 +69,13 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
onSubtitleChange: (payload: { text: string }) => void;
|
||||
onSubtitleAssChange: (payload: { text: string }) => void;
|
||||
onSecondarySubtitleChange: (payload: { text: string }) => void;
|
||||
onSubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
onSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
|
||||
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||
onMediaPathChange: (payload: { path: string | null }) => void;
|
||||
onMediaTitleChange: (payload: { title: string | null }) => void;
|
||||
onTimePosChange: (payload: { time: number }) => void;
|
||||
onDurationChange: (payload: { duration: number }) => void;
|
||||
onPauseChange: (payload: { paused: boolean }) => void;
|
||||
onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void;
|
||||
onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
|
||||
@@ -81,10 +85,13 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
mpvClient.on('subtitle-change', deps.onSubtitleChange);
|
||||
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
|
||||
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
|
||||
mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange);
|
||||
mpvClient.on('subtitle-track-list-change', deps.onSubtitleTrackListChange);
|
||||
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
|
||||
mpvClient.on('media-path-change', deps.onMediaPathChange);
|
||||
mpvClient.on('media-title-change', deps.onMediaTitleChange);
|
||||
mpvClient.on('time-pos-change', deps.onTimePosChange);
|
||||
mpvClient.on('duration-change', deps.onDurationChange);
|
||||
mpvClient.on('pause-change', deps.onPauseChange);
|
||||
mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange);
|
||||
mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility);
|
||||
|
||||
@@ -16,6 +16,7 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getImmediateSubtitlePayload: () => null,
|
||||
broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`),
|
||||
onSubtitleChange: (text) => calls.push(`process:${text}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
@@ -25,6 +26,35 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => {
|
||||
assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line', 'presence']);
|
||||
});
|
||||
|
||||
test('subtitle change handler broadcasts cached annotated payload immediately when available', () => {
|
||||
const payloads: Array<{ text: string; tokens: unknown[] | null }> = [];
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getImmediateSubtitlePayload: (text) => {
|
||||
calls.push(`lookup:${text}`);
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
broadcastSubtitle: (payload) => {
|
||||
payloads.push(payload);
|
||||
calls.push(`broadcast:${payload.tokens === null ? 'plain' : 'annotated'}`);
|
||||
},
|
||||
onSubtitleChange: (text) => calls.push(`process:${text}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
handler({ text: 'line' });
|
||||
|
||||
assert.deepEqual(payloads, [{ text: 'line', tokens: [] }]);
|
||||
assert.deepEqual(calls, [
|
||||
'set:line',
|
||||
'lookup:line',
|
||||
'broadcast:annotated',
|
||||
'process:line',
|
||||
'presence',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle ass change handler updates state and broadcasts', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleAssChangeHandler({
|
||||
@@ -57,6 +87,7 @@ test('media path change handler reports stop for empty path and probes media key
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
@@ -64,6 +95,7 @@ test('media path change handler reports stop for empty path and probes media key
|
||||
|
||||
handler({ path: '' });
|
||||
assert.deepEqual(calls, [
|
||||
'flush-playback',
|
||||
'path:',
|
||||
'stopped',
|
||||
'restore-mpv-sub',
|
||||
@@ -86,6 +118,7 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
@@ -103,16 +136,48 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
|
||||
]);
|
||||
});
|
||||
|
||||
test('media title change handler clears guess state and syncs immersion', () => {
|
||||
test('media path change handler ignores playback flush for non-empty path', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvMediaTitleChangeHandler({
|
||||
const handler = createHandleMpvMediaPathChangeHandler({
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
handler({ path: '/tmp/video.mkv' });
|
||||
assert.ok(!calls.includes('flush-playback'));
|
||||
assert.deepEqual(calls, [
|
||||
'path:/tmp/video.mkv',
|
||||
'reset:null',
|
||||
'sync',
|
||||
'dict-sync',
|
||||
'autoplay:/tmp/video.mkv',
|
||||
'presence',
|
||||
]);
|
||||
});
|
||||
|
||||
test('media title change handler clears guess state without re-scheduling character dictionary sync', () => {
|
||||
const calls: string[] = [];
|
||||
const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & {
|
||||
scheduleCharacterDictionarySync: () => void;
|
||||
} = {
|
||||
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
|
||||
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
|
||||
notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
};
|
||||
const handler = createHandleMpvMediaTitleChangeHandler(deps);
|
||||
|
||||
handler({ title: 'Episode 1' });
|
||||
assert.deepEqual(calls, [
|
||||
@@ -120,7 +185,6 @@ test('media title change handler clears guess state and syncs immersion', () =>
|
||||
'reset-guess',
|
||||
'notify:Episode 1',
|
||||
'sync',
|
||||
'dict-sync',
|
||||
'presence',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
setCurrentSubText: (text: string) => void;
|
||||
broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
}) {
|
||||
return ({ text }: { text: string }): void => {
|
||||
deps.setCurrentSubText(text);
|
||||
deps.broadcastSubtitle({ text, tokens: null });
|
||||
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
|
||||
if (immediatePayload) {
|
||||
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
|
||||
} else {
|
||||
deps.broadcastSubtitle({
|
||||
text,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
deps.onSubtitleChange(text);
|
||||
deps.refreshDiscordPresence();
|
||||
};
|
||||
@@ -41,10 +53,14 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
syncImmersionMediaState: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
}) {
|
||||
return ({ path }: { path: string | null }): void => {
|
||||
const normalizedPath = typeof path === 'string' ? path : '';
|
||||
if (!normalizedPath) {
|
||||
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
|
||||
}
|
||||
deps.updateCurrentMediaPath(normalizedPath);
|
||||
if (!normalizedPath) {
|
||||
deps.reportJellyfinRemoteStopped();
|
||||
@@ -70,7 +86,6 @@ export function createHandleMpvMediaTitleChangeHandler(deps: {
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
notifyImmersionTitleUpdate: (title: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
}) {
|
||||
return ({ title }: { title: string | null }): void => {
|
||||
@@ -79,9 +94,6 @@ export function createHandleMpvMediaTitleChangeHandler(deps: {
|
||||
deps.resetAnilistMediaGuessState();
|
||||
deps.notifyImmersionTitleUpdate(normalizedTitle);
|
||||
deps.syncImmersionMediaState();
|
||||
if (normalizedTitle.trim().length > 0) {
|
||||
deps.scheduleCharacterDictionarySync?.();
|
||||
}
|
||||
deps.refreshDiscordPresence();
|
||||
};
|
||||
}
|
||||
@@ -90,11 +102,13 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
||||
recordPlaybackPosition: (time: number) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
}) {
|
||||
return ({ time }: { time: number }): void => {
|
||||
deps.recordPlaybackPosition(time);
|
||||
deps.reportJellyfinRemoteProgress(false);
|
||||
deps.refreshDiscordPresence();
|
||||
deps.onTimePosUpdate?.(time);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
|
||||
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
|
||||
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
|
||||
onSubtitleTrackChange: () => calls.push('subtitle-track-change'),
|
||||
onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'),
|
||||
|
||||
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
@@ -42,12 +44,14 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync-immersion'),
|
||||
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
|
||||
|
||||
updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`),
|
||||
resetAnilistMediaGuessState: () => calls.push('reset-guess-state'),
|
||||
notifyImmersionTitleUpdate: (title) => calls.push(`notify-title:${title}`),
|
||||
|
||||
recordPlaybackPosition: (time) => calls.push(`time-pos:${time}`),
|
||||
recordMediaDuration: (duration) => calls.push(`duration:${duration}`),
|
||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
|
||||
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
|
||||
@@ -64,6 +68,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
});
|
||||
|
||||
handlers.get('subtitle-change')?.({ text: 'line' });
|
||||
handlers.get('subtitle-track-change')?.({ sid: 3 });
|
||||
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
||||
handlers.get('media-path-change')?.({ path: '' });
|
||||
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
||||
handlers.get('time-pos-change')?.({ time: 2.5 });
|
||||
@@ -72,6 +78,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
assert.ok(calls.includes('set-sub:line'));
|
||||
assert.ok(calls.includes('broadcast-sub:line'));
|
||||
assert.ok(calls.includes('subtitle-change:line'));
|
||||
assert.ok(calls.includes('subtitle-track-change'));
|
||||
assert.ok(calls.includes('subtitle-track-list-change'));
|
||||
assert.ok(calls.includes('media-title:Episode 1'));
|
||||
assert.ok(calls.includes('restore-mpv-sub'));
|
||||
assert.ok(calls.includes('reset-guess-state'));
|
||||
@@ -79,4 +87,6 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
assert.ok(calls.includes('progress:normal'));
|
||||
assert.ok(calls.includes('progress:force'));
|
||||
assert.ok(calls.includes('presence-refresh'));
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('flush-playback'));
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
import {
|
||||
createBindMpvClientEventHandlers,
|
||||
createHandleMpvConnectionChangeHandler,
|
||||
@@ -35,13 +36,17 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
|
||||
setCurrentSubText: (text: string) => void;
|
||||
broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
|
||||
setCurrentSubAssText: (text: string) => void;
|
||||
broadcastSubtitleAss: (text: string) => void;
|
||||
broadcastSecondarySubtitle: (text: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
@@ -51,13 +56,16 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
notifyImmersionTitleUpdate: (title: string) => void;
|
||||
|
||||
recordPlaybackPosition: (time: number) => void;
|
||||
recordMediaDuration: (durationSec: number) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
recordPauseState: (paused: boolean) => void;
|
||||
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
||||
@@ -68,7 +76,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
|
||||
@@ -86,6 +93,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
});
|
||||
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => deps.setCurrentSubText(text),
|
||||
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
|
||||
emitImmediateSubtitle: (payload) => deps.emitImmediateSubtitle?.(payload),
|
||||
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
|
||||
onSubtitleChange: (text) => deps.onSubtitleChange(text),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
@@ -106,6 +115,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
|
||||
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
||||
flushPlaybackPositionOnMediaPathClear: (mediaPath) =>
|
||||
deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath),
|
||||
signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path),
|
||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
@@ -115,7 +126,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
|
||||
notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title),
|
||||
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
});
|
||||
const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({
|
||||
@@ -123,6 +133,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
|
||||
});
|
||||
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
|
||||
recordPauseState: (paused) => deps.recordPauseState(paused),
|
||||
@@ -143,10 +154,13 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
onSubtitleChange: handleMpvSubtitleChange,
|
||||
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
||||
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
||||
onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid),
|
||||
onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList),
|
||||
onSubtitleTiming: handleMpvSubtitleTiming,
|
||||
onMediaPathChange: handleMpvMediaPathChange,
|
||||
onMediaTitleChange: handleMpvMediaTitleChange,
|
||||
onTimePosChange: handleMpvTimePosChange,
|
||||
onDurationChange: ({ duration }) => deps.recordMediaDuration(duration),
|
||||
onPauseChange: handleMpvPauseChange,
|
||||
onSubtitleMetricsChange: handleMpvSubtitleMetricsChange,
|
||||
onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility,
|
||||
|
||||
@@ -7,7 +7,11 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
const appState = {
|
||||
initialArgs: { jellyfinPlay: true },
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: { connected: true },
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentTimePos: 12.25,
|
||||
requestProperty: async () => 18.75,
|
||||
},
|
||||
immersionTracker: {
|
||||
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
|
||||
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
|
||||
@@ -92,6 +96,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
deps.recordPauseState(true);
|
||||
deps.updateSubtitleRenderMetrics({});
|
||||
deps.setPreviousSecondarySubVisibility(true);
|
||||
deps.flushPlaybackPositionOnMediaPathClear?.('');
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(appState.currentSubText, 'sub');
|
||||
assert.equal(appState.currentSubAssText, 'ass');
|
||||
@@ -106,4 +112,6 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
assert.ok(calls.includes('metrics'));
|
||||
assert.ok(calls.includes('presence-refresh'));
|
||||
assert.ok(calls.includes('restore-mpv-sub'));
|
||||
assert.ok(calls.includes('immersion-time:12.25'));
|
||||
assert.ok(calls.includes('immersion-time:18.75'));
|
||||
});
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import type { MergedToken, SubtitleData } from '../../types';
|
||||
|
||||
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
appState: {
|
||||
initialArgs?: { jellyfinPlay?: unknown } | null;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
mpvClient: { connected?: boolean } | null;
|
||||
mpvClient:
|
||||
| {
|
||||
connected?: boolean;
|
||||
currentSecondarySubText?: string;
|
||||
currentTimePos?: number;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
}
|
||||
| null;
|
||||
immersionTracker: {
|
||||
recordSubtitleLine?: (text: string, start: number, end: number) => void;
|
||||
recordSubtitleLine?: (
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
tokens?: MergedToken[] | null,
|
||||
secondaryText?: string | null,
|
||||
) => void;
|
||||
handleMediaTitleUpdate?: (title: string) => void;
|
||||
recordPlaybackPosition?: (time: number) => void;
|
||||
recordMediaDuration?: (durationSec: number) => void;
|
||||
recordPauseState?: (paused: boolean) => void;
|
||||
} | null;
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle?: (text: string, start: number, end: number) => void;
|
||||
} | null;
|
||||
currentMediaPath?: string | null;
|
||||
currentSubText: string;
|
||||
currentSubAssText: string;
|
||||
currentSubtitleData?: SubtitleData | null;
|
||||
playbackPaused: boolean | null;
|
||||
previousSecondarySubVisibility: boolean | null;
|
||||
};
|
||||
@@ -25,7 +43,11 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
getCurrentAnilistMediaKey: () => string | null;
|
||||
@@ -38,10 +60,21 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
ensureImmersionTrackerInitialized: () => void;
|
||||
tokenizeSubtitleForImmersion?: (text: string) => Promise<SubtitleData | null>;
|
||||
}) {
|
||||
const writePlaybackPositionFromMpv = (timeSec: unknown): void => {
|
||||
const normalizedTimeSec = Number(timeSec);
|
||||
if (!Number.isFinite(normalizedTimeSec)) {
|
||||
return;
|
||||
}
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
deps.appState.immersionTracker?.recordPlaybackPosition?.(normalizedTimeSec);
|
||||
};
|
||||
|
||||
return () => ({
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
@@ -53,7 +86,31 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
quitApp: () => deps.quitApp(),
|
||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
deps.appState.immersionTracker?.recordSubtitleLine?.(text, start, end);
|
||||
const tracker = deps.appState.immersionTracker;
|
||||
if (!tracker?.recordSubtitleLine) {
|
||||
return;
|
||||
}
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
|
||||
const cachedTokens =
|
||||
deps.appState.currentSubtitleData?.text === text
|
||||
? deps.appState.currentSubtitleData.tokens
|
||||
: null;
|
||||
if (cachedTokens) {
|
||||
tracker.recordSubtitleLine(text, start, end, cachedTokens, secondaryText);
|
||||
return;
|
||||
}
|
||||
if (!deps.tokenizeSubtitleForImmersion) {
|
||||
tracker.recordSubtitleLine(text, start, end, null, secondaryText);
|
||||
return;
|
||||
}
|
||||
void deps
|
||||
.tokenizeSubtitleForImmersion(text)
|
||||
.then((payload) => {
|
||||
tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null, secondaryText);
|
||||
})
|
||||
.catch(() => {
|
||||
tracker.recordSubtitleLine?.(text, start, end, null, secondaryText);
|
||||
});
|
||||
},
|
||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
||||
@@ -64,9 +121,21 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
setCurrentSubText: (text: string) => {
|
||||
deps.appState.currentSubText = text;
|
||||
},
|
||||
broadcastSubtitle: (payload: { text: string; tokens: null }) =>
|
||||
getImmediateSubtitlePayload: deps.getImmediateSubtitlePayload
|
||||
? (text: string) => deps.getImmediateSubtitlePayload!(text)
|
||||
: undefined,
|
||||
emitImmediateSubtitle: deps.emitImmediateSubtitle
|
||||
? (payload: SubtitleData) => deps.emitImmediateSubtitle!(payload)
|
||||
: undefined,
|
||||
broadcastSubtitle: (payload: SubtitleData) =>
|
||||
deps.broadcastToOverlayWindows('subtitle:set', payload),
|
||||
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
|
||||
onSubtitleTrackChange: deps.onSubtitleTrackChange
|
||||
? (sid: number | null) => deps.onSubtitleTrackChange!(sid)
|
||||
: undefined,
|
||||
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
||||
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
|
||||
: undefined,
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
setCurrentSubAssText: (text: string) => {
|
||||
deps.appState.currentSubAssText = text;
|
||||
@@ -95,13 +164,39 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
deps.appState.immersionTracker?.recordPlaybackPosition?.(time);
|
||||
},
|
||||
recordMediaDuration: (durationSec: number) => {
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
|
||||
},
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
onTimePosUpdate: deps.onTimePosUpdate
|
||||
? (time: number) => deps.onTimePosUpdate!(time)
|
||||
: undefined,
|
||||
recordPauseState: (paused: boolean) => {
|
||||
deps.appState.playbackPaused = paused;
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
deps.appState.immersionTracker?.recordPauseState?.(paused);
|
||||
},
|
||||
flushPlaybackPositionOnMediaPathClear: (mediaPath: string) => {
|
||||
const mpvClient = deps.appState.mpvClient;
|
||||
const currentKnownTime = Number(mpvClient?.currentTimePos);
|
||||
writePlaybackPositionFromMpv(currentKnownTime);
|
||||
if (!mpvClient?.requestProperty) {
|
||||
return;
|
||||
}
|
||||
void mpvClient.requestProperty('time-pos').then((timePos) => {
|
||||
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
|
||||
if (currentPath.length > 0 && currentPath !== mediaPath) {
|
||||
return;
|
||||
}
|
||||
const resolvedTime = Number(timePos);
|
||||
if (Number.isFinite(currentKnownTime) && Number.isFinite(resolvedTime) && currentKnownTime === resolvedTime) {
|
||||
return;
|
||||
}
|
||||
writePlaybackPositionFromMpv(resolvedTime);
|
||||
});
|
||||
},
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
|
||||
deps.updateSubtitleRenderMetrics(patch),
|
||||
setPreviousSecondarySubVisibility: (visible: boolean) => {
|
||||
|
||||
@@ -43,6 +43,7 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
|
||||
cancelled: true,
|
||||
}) as KikuFieldGroupingChoice,
|
||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||
shouldStartAnkiIntegration: () => true,
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
|
||||
@@ -30,6 +30,7 @@ type InitializeOverlayRuntimeCore = (options: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
}) => void;
|
||||
|
||||
export function createInitializeOverlayRuntimeHandler(deps: {
|
||||
|
||||
@@ -39,6 +39,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
cancelled: true,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
});
|
||||
|
||||
const deps = build();
|
||||
@@ -46,6 +47,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
assert.equal(deps.isVisibleOverlayVisible(), true);
|
||||
assert.equal(deps.getMpvSocketPath(), '/tmp/mpv.sock');
|
||||
assert.equal(deps.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
|
||||
assert.equal(deps.shouldStartAnkiIntegration(), false);
|
||||
|
||||
deps.createMainWindow();
|
||||
deps.registerGlobalShortcuts();
|
||||
|
||||
@@ -33,10 +33,12 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
height: number;
|
||||
}) => void;
|
||||
getOverlayWindows: OverlayRuntimeOptionsMainDeps['getOverlayWindows'];
|
||||
createWindowTracker?: OverlayRuntimeOptionsMainDeps['createWindowTracker'];
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
}) {
|
||||
return (): OverlayRuntimeOptionsMainDeps => ({
|
||||
getBackendOverride: () => deps.appState.backendOverride,
|
||||
@@ -56,6 +58,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
setWindowTracker: (tracker) => {
|
||||
deps.appState.windowTracker = tracker;
|
||||
},
|
||||
createWindowTracker: deps.createWindowTracker,
|
||||
getResolvedConfig: () => deps.getResolvedConfig(),
|
||||
getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker,
|
||||
getMpvClient: () => deps.appState.mpvClient,
|
||||
@@ -67,5 +70,6 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
showDesktopNotification: deps.showDesktopNotification,
|
||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
shouldStartAnkiIntegration: () => true,
|
||||
});
|
||||
|
||||
const options = buildOptions();
|
||||
@@ -35,6 +36,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
assert.equal(options.isVisibleOverlayVisible(), true);
|
||||
assert.equal(options.getMpvSocketPath(), '/tmp/mpv.sock');
|
||||
assert.equal(options.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
|
||||
assert.equal(options.shouldStartAnkiIntegration(), true);
|
||||
options.createMainWindow();
|
||||
options.registerGlobalShortcuts();
|
||||
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
|
||||
@@ -17,6 +17,10 @@ type OverlayRuntimeOptions = {
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
createWindowTracker?: (
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
||||
@@ -30,6 +34,7 @@ type OverlayRuntimeOptions = {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
};
|
||||
|
||||
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
@@ -42,6 +47,10 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
createWindowTracker?: (
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
||||
@@ -55,6 +64,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
}) {
|
||||
return (): OverlayRuntimeOptions => ({
|
||||
backendOverride: deps.getBackendOverride(),
|
||||
@@ -66,6 +76,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
getOverlayWindows: deps.getOverlayWindows,
|
||||
syncOverlayShortcuts: deps.syncOverlayShortcuts,
|
||||
setWindowTracker: deps.setWindowTracker,
|
||||
createWindowTracker: deps.createWindowTracker,
|
||||
getResolvedConfig: deps.getResolvedConfig,
|
||||
getSubtitleTimingTracker: deps.getSubtitleTimingTracker,
|
||||
getMpvClient: deps.getMpvClient,
|
||||
@@ -75,5 +86,6 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
showDesktopNotification: deps.showDesktopNotification,
|
||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({
|
||||
getMainWindow: () => mainWindow,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getForceMousePassthrough: () => true,
|
||||
getWindowTracker: () => tracker,
|
||||
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
|
||||
setTrackerNotReadyWarningShown: (shown) => {
|
||||
@@ -32,6 +33,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
|
||||
assert.equal(deps.getMainWindow(), mainWindow);
|
||||
assert.equal(deps.getVisibleOverlayVisible(), true);
|
||||
assert.equal(deps.getForceMousePassthrough(), true);
|
||||
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
|
||||
deps.setTrackerNotReadyWarningShown(true);
|
||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
|
||||
@@ -8,6 +8,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
return (): OverlayVisibilityRuntimeDeps => ({
|
||||
getMainWindow: () => deps.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||
getWindowTracker: () => deps.getWindowTracker(),
|
||||
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
|
||||
|
||||
32
src/main/runtime/startup-autoplay-release-policy.test.ts
Normal file
32
src/main/runtime/startup-autoplay-release-policy.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
|
||||
resolveAutoplayReadyMaxReleaseAttempts,
|
||||
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS,
|
||||
} from './startup-autoplay-release-policy';
|
||||
|
||||
test('autoplay release keeps the short retry budget for normal playback signals', () => {
|
||||
assert.equal(resolveAutoplayReadyMaxReleaseAttempts(), 3);
|
||||
assert.equal(resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: false }), 3);
|
||||
});
|
||||
|
||||
test('autoplay release uses the full startup timeout window while paused', () => {
|
||||
assert.equal(
|
||||
resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: true }),
|
||||
Math.ceil(
|
||||
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS / DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay release rounds up custom paused retry budgets to cover the timeout window', () => {
|
||||
assert.equal(
|
||||
resolveAutoplayReadyMaxReleaseAttempts({
|
||||
forceWhilePaused: true,
|
||||
retryDelayMs: 300,
|
||||
startupTimeoutMs: 1_000,
|
||||
}),
|
||||
4,
|
||||
);
|
||||
});
|
||||
28
src/main/runtime/startup-autoplay-release-policy.ts
Normal file
28
src/main/runtime/startup-autoplay-release-policy.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS = 200;
|
||||
const STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function resolveAutoplayReadyMaxReleaseAttempts(options?: {
|
||||
forceWhilePaused?: boolean;
|
||||
retryDelayMs?: number;
|
||||
startupTimeoutMs?: number;
|
||||
}): number {
|
||||
if (options?.forceWhilePaused !== true) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
const retryDelayMs = Math.max(
|
||||
1,
|
||||
Math.floor(options.retryDelayMs ?? DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS),
|
||||
);
|
||||
const startupTimeoutMs = Math.max(
|
||||
retryDelayMs,
|
||||
Math.floor(options.startupTimeoutMs ?? STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
return Math.max(3, Math.ceil(startupTimeoutMs / retryDelayMs));
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
|
||||
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS,
|
||||
};
|
||||
@@ -62,7 +62,10 @@ test('startup OSD buffers checking behind annotations and replaces it with later
|
||||
makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'),
|
||||
);
|
||||
|
||||
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Loading subtitle annotations |',
|
||||
'Generating character dictionary for Frieren...',
|
||||
]);
|
||||
|
||||
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||
|
||||
@@ -138,7 +141,7 @@ test('startup OSD shows dictionary failure after annotation loading completes',
|
||||
]);
|
||||
});
|
||||
|
||||
test('startup OSD reset requires the next media to wait for tokenization again', () => {
|
||||
test('startup OSD reset keeps tokenization ready after first warmup', () => {
|
||||
const osdMessages: string[] = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
showOsd: (message) => {
|
||||
@@ -152,8 +155,32 @@ test('startup OSD reset requires the next media to wait for tokenization again',
|
||||
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
|
||||
);
|
||||
|
||||
assert.deepEqual(osdMessages, []);
|
||||
|
||||
sequencer.markTokenizationReady();
|
||||
assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']);
|
||||
});
|
||||
|
||||
test('startup OSD shows later dictionary progress immediately once tokenization is ready', () => {
|
||||
const osdMessages: string[] = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
showOsd: (message) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
sequencer.showAnnotationLoading('Loading subtitle annotations |');
|
||||
sequencer.markTokenizationReady();
|
||||
sequencer.notifyCharacterDictionaryStatus(
|
||||
makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'),
|
||||
);
|
||||
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Loading subtitle annotations |',
|
||||
'Generating character dictionary for Frieren...',
|
||||
]);
|
||||
|
||||
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Loading subtitle annotations |',
|
||||
'Generating character dictionary for Frieren...',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
||||
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
|
||||
} {
|
||||
let tokenizationReady = false;
|
||||
let tokenizationWarmupCompleted = false;
|
||||
let annotationLoadingMessage: string | null = null;
|
||||
let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
||||
let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
||||
@@ -24,6 +25,9 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
||||
return false;
|
||||
}
|
||||
if (pendingDictionaryProgress) {
|
||||
if (dictionaryProgressShown) {
|
||||
return true;
|
||||
}
|
||||
deps.showOsd(pendingDictionaryProgress.message);
|
||||
dictionaryProgressShown = true;
|
||||
return true;
|
||||
@@ -39,13 +43,14 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
||||
|
||||
return {
|
||||
reset: () => {
|
||||
tokenizationReady = false;
|
||||
tokenizationReady = tokenizationWarmupCompleted;
|
||||
annotationLoadingMessage = null;
|
||||
pendingDictionaryProgress = null;
|
||||
pendingDictionaryFailure = null;
|
||||
dictionaryProgressShown = false;
|
||||
},
|
||||
markTokenizationReady: () => {
|
||||
tokenizationWarmupCompleted = true;
|
||||
tokenizationReady = true;
|
||||
if (annotationLoadingMessage !== null) {
|
||||
deps.showOsd(annotationLoadingMessage);
|
||||
@@ -82,6 +87,9 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
||||
if (canShowDictionaryStatus()) {
|
||||
deps.showOsd(event.message);
|
||||
dictionaryProgressShown = true;
|
||||
} else if (tokenizationReady) {
|
||||
deps.showOsd(event.message);
|
||||
dictionaryProgressShown = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
471
src/main/runtime/stats-cli-command.test.ts
Normal file
471
src/main/runtime/stats-cli-command.test.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRunStatsCliCommandHandler } from './stats-cli-command';
|
||||
|
||||
function makeHandler(
|
||||
overrides: Partial<Parameters<typeof createRunStatsCliCommandHandler>[0]> = {},
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
const responses: Array<{
|
||||
responsePath: string;
|
||||
payload: { ok: boolean; url?: string; error?: string };
|
||||
}> = [];
|
||||
|
||||
const handler = createRunStatsCliCommandHandler({
|
||||
getResolvedConfig: () => ({
|
||||
immersionTracking: { enabled: true },
|
||||
stats: { serverPort: 6969 },
|
||||
}),
|
||||
ensureImmersionTrackerStarted: () => {
|
||||
calls.push('ensureImmersionTrackerStarted');
|
||||
},
|
||||
getImmersionTracker: () => ({ cleanupVocabularyStats: undefined }),
|
||||
ensureStatsServerStarted: () => {
|
||||
calls.push('ensureStatsServerStarted');
|
||||
return 'http://127.0.0.1:6969';
|
||||
},
|
||||
ensureBackgroundStatsServerStarted: () => ({
|
||||
url: 'http://127.0.0.1:6969',
|
||||
runningInCurrentProcess: true,
|
||||
}),
|
||||
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
|
||||
openExternal: async (url) => {
|
||||
calls.push(`openExternal:${url}`);
|
||||
},
|
||||
writeResponse: (responsePath, payload) => {
|
||||
responses.push({ responsePath, payload });
|
||||
},
|
||||
exitAppWithCode: (code) => {
|
||||
calls.push(`exitAppWithCode:${code}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
calls.push(`info:${message}`);
|
||||
},
|
||||
logWarn: (message) => {
|
||||
calls.push(`warn:${message}`);
|
||||
},
|
||||
logError: (message, error) => {
|
||||
calls.push(`error:${message}:${error instanceof Error ? error.message : String(error)}`);
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return { handler, calls, responses };
|
||||
}
|
||||
|
||||
test('stats cli command starts tracker, server, browser, and writes success response', async () => {
|
||||
const { handler, calls, responses } = makeHandler();
|
||||
|
||||
await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureImmersionTrackerStarted',
|
||||
'ensureStatsServerStarted',
|
||||
'openExternal:http://127.0.0.1:6969',
|
||||
'info:Stats dashboard available at http://127.0.0.1:6969',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true, url: 'http://127.0.0.1:6969' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command respects stats.autoOpenBrowser=false', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
getResolvedConfig: () => ({
|
||||
immersionTracking: { enabled: true },
|
||||
stats: { serverPort: 6969, autoOpenBrowser: false },
|
||||
}),
|
||||
});
|
||||
|
||||
await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureImmersionTrackerStarted',
|
||||
'ensureStatsServerStarted',
|
||||
'info:Stats dashboard available at http://127.0.0.1:6969',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true, url: 'http://127.0.0.1:6969' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command starts background daemon without opening browser', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
calls.push('ensureBackgroundStatsServerStarted');
|
||||
return { url: 'http://127.0.0.1:6969', runningInCurrentProcess: true };
|
||||
},
|
||||
} as never);
|
||||
|
||||
await handler(
|
||||
{
|
||||
statsResponsePath: '/tmp/subminer-stats-response.json',
|
||||
statsBackground: true,
|
||||
} as never,
|
||||
'initial',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureBackgroundStatsServerStarted',
|
||||
'info:Stats dashboard available at http://127.0.0.1:6969',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true, url: 'http://127.0.0.1:6969' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command exits helper app when background daemon is already running elsewhere', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
calls.push('ensureBackgroundStatsServerStarted');
|
||||
return { url: 'http://127.0.0.1:6969', runningInCurrentProcess: false };
|
||||
},
|
||||
} as never);
|
||||
|
||||
await handler(
|
||||
{
|
||||
statsResponsePath: '/tmp/subminer-stats-response.json',
|
||||
statsBackground: true,
|
||||
} as never,
|
||||
'initial',
|
||||
);
|
||||
|
||||
assert.ok(calls.includes('exitAppWithCode:0'));
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true, url: 'http://127.0.0.1:6969' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command stops background daemon and treats stale state as success', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
stopBackgroundStatsServer: async () => {
|
||||
calls.push('stopBackgroundStatsServer');
|
||||
return { ok: true, stale: true };
|
||||
},
|
||||
} as never);
|
||||
|
||||
await handler(
|
||||
{
|
||||
statsResponsePath: '/tmp/subminer-stats-response.json',
|
||||
statsStop: true,
|
||||
} as never,
|
||||
'initial',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'stopBackgroundStatsServer',
|
||||
'info:Background stats server is not running; cleaned stale state.',
|
||||
'exitAppWithCode:0',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command fails when immersion tracking is disabled', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
getResolvedConfig: () => ({
|
||||
immersionTracking: { enabled: false },
|
||||
stats: { serverPort: 6969 },
|
||||
}),
|
||||
});
|
||||
|
||||
await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial');
|
||||
|
||||
assert.equal(calls.includes('ensureImmersionTrackerStarted'), false);
|
||||
assert.ok(calls.includes('exitAppWithCode:1'));
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: false, error: 'Immersion tracking is disabled in config.' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command runs vocab cleanup instead of opening dashboard when cleanup mode is requested', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
getImmersionTracker: () => ({
|
||||
cleanupVocabularyStats: async () => ({ scanned: 3, kept: 1, deleted: 2, repaired: 1 }),
|
||||
}),
|
||||
});
|
||||
|
||||
await handler(
|
||||
{
|
||||
statsResponsePath: '/tmp/subminer-stats-response.json',
|
||||
statsCleanup: true,
|
||||
statsCleanupVocab: true,
|
||||
},
|
||||
'initial',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureImmersionTrackerStarted',
|
||||
'info:Stats vocabulary cleanup complete: scanned=3 kept=1 deleted=2 repaired=1',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command runs lifetime rebuild when cleanup lifetime mode is requested', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
ensureVocabularyCleanupTokenizerReady: async () => {
|
||||
calls.push('ensureVocabularyCleanupTokenizerReady');
|
||||
},
|
||||
getImmersionTracker: () => ({
|
||||
rebuildLifetimeSummaries: async () => ({
|
||||
appliedSessions: 4,
|
||||
rebuiltAtMs: 1_710_000_000_000,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
await handler(
|
||||
{
|
||||
statsResponsePath: '/tmp/subminer-stats-response.json',
|
||||
statsCleanup: true,
|
||||
statsCleanupLifetime: true,
|
||||
},
|
||||
'initial',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureImmersionTrackerStarted',
|
||||
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-runtime-test-'));
|
||||
return path.join(dir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
function cleanupDbPath(dbPath: string): void {
|
||||
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function waitForPendingAnimeMetadata(
|
||||
tracker: import('../../core/services/immersion-tracker-service').ImmersionTrackerService,
|
||||
): Promise<void> {
|
||||
const privateApi = tracker as unknown as {
|
||||
sessionState: { videoId: number } | null;
|
||||
pendingAnimeMetadataUpdates?: Map<number, Promise<void>>;
|
||||
};
|
||||
const videoId = privateApi.sessionState?.videoId;
|
||||
if (!videoId) return;
|
||||
await privateApi.pendingAnimeMetadataUpdates?.get(videoId);
|
||||
}
|
||||
|
||||
test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempotent', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker:
|
||||
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
|
||||
| null = null;
|
||||
let tracker2:
|
||||
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
|
||||
| null = null;
|
||||
let tracker3:
|
||||
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
|
||||
| null = null;
|
||||
const { ImmersionTrackerService } = await import('../../core/services/immersion-tracker-service');
|
||||
const { Database } = await import('../../core/services/immersion-tracker/sqlite');
|
||||
|
||||
try {
|
||||
tracker = new ImmersionTrackerService({ dbPath });
|
||||
tracker.handleMediaChange('/tmp/Frieren S01E01.mkv', 'Episode 1');
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
tracker.recordCardsMined(2);
|
||||
tracker.recordSubtitleLine('first line', 0, 1);
|
||||
tracker.destroy();
|
||||
tracker = null;
|
||||
|
||||
tracker2 = new ImmersionTrackerService({ dbPath });
|
||||
tracker2.handleMediaChange('/tmp/Frieren S01E02.mkv', 'Episode 2');
|
||||
await waitForPendingAnimeMetadata(tracker2);
|
||||
tracker2.recordCardsMined(1);
|
||||
tracker2.recordSubtitleLine('second line', 0, 1);
|
||||
tracker2.destroy();
|
||||
tracker2 = null;
|
||||
|
||||
const beforeDb = new Database(dbPath);
|
||||
const expectedGlobal = beforeDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT total_sessions, total_cards, episodes_started, active_days
|
||||
FROM imm_lifetime_global
|
||||
`,
|
||||
)
|
||||
.get() as {
|
||||
total_sessions: number;
|
||||
total_cards: number;
|
||||
episodes_started: number;
|
||||
active_days: number;
|
||||
} | null;
|
||||
const expectedAnimeRows = (
|
||||
beforeDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime').get() as {
|
||||
total: number;
|
||||
}
|
||||
).total;
|
||||
const expectedMediaRows = (
|
||||
beforeDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media').get() as {
|
||||
total: number;
|
||||
}
|
||||
).total;
|
||||
const expectedAppliedSessions = (
|
||||
beforeDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions').get() as {
|
||||
total: number;
|
||||
}
|
||||
).total;
|
||||
|
||||
beforeDb.exec(`
|
||||
DELETE FROM imm_lifetime_anime;
|
||||
DELETE FROM imm_lifetime_media;
|
||||
DELETE FROM imm_lifetime_applied_sessions;
|
||||
UPDATE imm_lifetime_global
|
||||
SET total_sessions = 999,
|
||||
total_cards = 999,
|
||||
episodes_started = 999,
|
||||
active_days = 999
|
||||
WHERE global_id = 1;
|
||||
`);
|
||||
beforeDb.close();
|
||||
|
||||
tracker3 = new ImmersionTrackerService({ dbPath });
|
||||
const firstRebuild = await tracker3.rebuildLifetimeSummaries();
|
||||
const secondRebuild = await tracker3.rebuildLifetimeSummaries();
|
||||
|
||||
const rebuiltDb = new Database(dbPath);
|
||||
const rebuiltGlobal = rebuiltDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT total_sessions, total_cards, episodes_started, active_days
|
||||
FROM imm_lifetime_global
|
||||
`,
|
||||
)
|
||||
.get() as {
|
||||
total_sessions: number;
|
||||
total_cards: number;
|
||||
episodes_started: number;
|
||||
active_days: number;
|
||||
} | null;
|
||||
const rebuiltAnimeRows = (
|
||||
rebuiltDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime').get() as {
|
||||
total: number;
|
||||
}
|
||||
).total;
|
||||
const rebuiltMediaRows = (
|
||||
rebuiltDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media').get() as {
|
||||
total: number;
|
||||
}
|
||||
).total;
|
||||
const rebuiltAppliedSessions = (
|
||||
rebuiltDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions').get() as {
|
||||
total: number;
|
||||
}
|
||||
).total;
|
||||
rebuiltDb.close();
|
||||
|
||||
assert.ok(rebuiltGlobal);
|
||||
assert.ok(expectedGlobal);
|
||||
assert.equal(rebuiltGlobal?.total_sessions, expectedGlobal?.total_sessions);
|
||||
assert.equal(rebuiltGlobal?.total_cards, expectedGlobal?.total_cards);
|
||||
assert.equal(rebuiltGlobal?.episodes_started, expectedGlobal?.episodes_started);
|
||||
assert.equal(rebuiltGlobal?.active_days, expectedGlobal?.active_days);
|
||||
assert.equal(rebuiltAnimeRows, expectedAnimeRows);
|
||||
assert.equal(rebuiltMediaRows, expectedMediaRows);
|
||||
assert.equal(rebuiltAppliedSessions, expectedAppliedSessions);
|
||||
assert.equal(firstRebuild.appliedSessions, expectedAppliedSessions);
|
||||
assert.equal(secondRebuild.appliedSessions, firstRebuild.appliedSessions);
|
||||
assert.ok(secondRebuild.rebuiltAtMs >= firstRebuild.rebuiltAtMs);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
tracker2?.destroy();
|
||||
tracker3?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('stats cli command runs lifetime rebuild when requested', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
getImmersionTracker: () => ({
|
||||
rebuildLifetimeSummaries: async () => ({
|
||||
appliedSessions: 4,
|
||||
rebuiltAtMs: 1_710_000_000_000,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
await handler(
|
||||
{
|
||||
statsResponsePath: '/tmp/subminer-stats-response.json',
|
||||
statsCleanup: true,
|
||||
statsCleanupLifetime: true,
|
||||
},
|
||||
'initial',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureImmersionTrackerStarted',
|
||||
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command rejects cleanup calls without exactly one cleanup mode', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
getImmersionTracker: () => ({
|
||||
cleanupVocabularyStats: async () => ({ scanned: 1, kept: 1, deleted: 0, repaired: 0 }),
|
||||
rebuildLifetimeSummaries: async () => ({ appliedSessions: 0, rebuiltAtMs: 0 }),
|
||||
}),
|
||||
});
|
||||
|
||||
await handler(
|
||||
{
|
||||
statsResponsePath: '/tmp/subminer-stats-response.json',
|
||||
statsCleanup: true,
|
||||
statsCleanupVocab: true,
|
||||
statsCleanupLifetime: true,
|
||||
},
|
||||
'initial',
|
||||
);
|
||||
|
||||
assert.ok(calls.includes('error:Stats command failed:Choose exactly one stats cleanup mode.'));
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: false, error: 'Choose exactly one stats cleanup mode.' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
167
src/main/runtime/stats-cli-command.ts
Normal file
167
src/main/runtime/stats-cli-command.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { CliArgs, CliCommandSource } from '../../cli/args';
|
||||
import type {
|
||||
LifetimeRebuildSummary,
|
||||
VocabularyCleanupSummary,
|
||||
} from '../../core/services/immersion-tracker/types';
|
||||
|
||||
type StatsCliConfig = {
|
||||
immersionTracking?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
stats: {
|
||||
serverPort: number;
|
||||
autoOpenBrowser?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type StatsCliCommandResponse = {
|
||||
ok: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type BackgroundStatsStartResult = {
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
};
|
||||
|
||||
type BackgroundStatsStopResult = {
|
||||
ok: boolean;
|
||||
stale: boolean;
|
||||
};
|
||||
|
||||
export function writeStatsCliCommandResponse(
|
||||
responsePath: string,
|
||||
payload: StatsCliCommandResponse,
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export function createRunStatsCliCommandHandler(deps: {
|
||||
getResolvedConfig: () => StatsCliConfig;
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
ensureVocabularyCleanupTokenizerReady?: () => Promise<void> | void;
|
||||
getImmersionTracker: () => {
|
||||
cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary>;
|
||||
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
|
||||
} | null;
|
||||
ensureStatsServerStarted: () => string;
|
||||
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
|
||||
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
|
||||
exitAppWithCode: (code: number) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, error: unknown) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
const writeResponseSafe = (
|
||||
responsePath: string | undefined,
|
||||
payload: StatsCliCommandResponse,
|
||||
): void => {
|
||||
if (!responsePath) return;
|
||||
try {
|
||||
deps.writeResponse(responsePath, payload);
|
||||
} catch (error) {
|
||||
deps.logWarn(`Failed to write stats response: ${responsePath}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
return async (
|
||||
args: Pick<
|
||||
CliArgs,
|
||||
| 'statsResponsePath'
|
||||
| 'statsBackground'
|
||||
| 'statsStop'
|
||||
| 'statsCleanup'
|
||||
| 'statsCleanupVocab'
|
||||
| 'statsCleanupLifetime'
|
||||
>,
|
||||
source: CliCommandSource,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (args.statsStop) {
|
||||
const result = await deps.stopBackgroundStatsServer();
|
||||
deps.logInfo(
|
||||
result.stale
|
||||
? 'Background stats server is not running; cleaned stale state.'
|
||||
: 'Background stats server stopped.',
|
||||
);
|
||||
writeResponseSafe(args.statsResponsePath, { ok: true });
|
||||
if (source === 'initial') {
|
||||
deps.exitAppWithCode(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const config = deps.getResolvedConfig();
|
||||
if (config.immersionTracking?.enabled === false) {
|
||||
throw new Error('Immersion tracking is disabled in config.');
|
||||
}
|
||||
|
||||
if (args.statsBackground) {
|
||||
const result = deps.ensureBackgroundStatsServerStarted();
|
||||
deps.logInfo(`Stats dashboard available at ${result.url}`);
|
||||
writeResponseSafe(args.statsResponsePath, { ok: true, url: result.url });
|
||||
if (!result.runningInCurrentProcess && source === 'initial') {
|
||||
deps.exitAppWithCode(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
deps.ensureImmersionTrackerStarted();
|
||||
const tracker = deps.getImmersionTracker();
|
||||
if (!tracker) {
|
||||
throw new Error('Immersion tracker failed to initialize.');
|
||||
}
|
||||
|
||||
if (args.statsCleanup) {
|
||||
const cleanupModes = [
|
||||
args.statsCleanupVocab ? 'vocab' : null,
|
||||
args.statsCleanupLifetime ? 'lifetime' : null,
|
||||
].filter(Boolean);
|
||||
if (cleanupModes.length !== 1) {
|
||||
throw new Error('Choose exactly one stats cleanup mode.');
|
||||
}
|
||||
|
||||
if (args.statsCleanupVocab) {
|
||||
await deps.ensureVocabularyCleanupTokenizerReady?.();
|
||||
}
|
||||
if (args.statsCleanupVocab && tracker.cleanupVocabularyStats) {
|
||||
const result = await tracker.cleanupVocabularyStats();
|
||||
deps.logInfo(
|
||||
`Stats vocabulary cleanup complete: scanned=${result.scanned} kept=${result.kept} deleted=${result.deleted} repaired=${result.repaired}`,
|
||||
);
|
||||
writeResponseSafe(args.statsResponsePath, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (!args.statsCleanupLifetime || !tracker.rebuildLifetimeSummaries) {
|
||||
throw new Error('Stats cleanup mode is not available.');
|
||||
}
|
||||
const result = await tracker.rebuildLifetimeSummaries();
|
||||
deps.logInfo(
|
||||
`Stats lifetime rebuild complete: appliedSessions=${result.appliedSessions} rebuiltAtMs=${result.rebuiltAtMs}`,
|
||||
);
|
||||
writeResponseSafe(args.statsResponsePath, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = deps.ensureStatsServerStarted();
|
||||
if (config.stats.autoOpenBrowser !== false) {
|
||||
await deps.openExternal(url);
|
||||
}
|
||||
deps.logInfo(`Stats dashboard available at ${url}`);
|
||||
writeResponseSafe(args.statsResponsePath, { ok: true, url });
|
||||
} catch (error) {
|
||||
deps.logError('Stats command failed', error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
writeResponseSafe(args.statsResponsePath, { ok: false, error: message });
|
||||
if (source === 'initial') {
|
||||
deps.exitAppWithCode(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
72
src/main/runtime/stats-daemon.ts
Normal file
72
src/main/runtime/stats-daemon.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export type BackgroundStatsServerState = {
|
||||
pid: number;
|
||||
port: number;
|
||||
startedAtMs: number;
|
||||
};
|
||||
|
||||
export function readBackgroundStatsServerState(
|
||||
statePath: string,
|
||||
): BackgroundStatsServerState | null {
|
||||
try {
|
||||
const raw = JSON.parse(
|
||||
fs.readFileSync(statePath, 'utf8'),
|
||||
) as Partial<BackgroundStatsServerState>;
|
||||
const pid = raw.pid;
|
||||
const port = raw.port;
|
||||
const startedAtMs = raw.startedAtMs;
|
||||
if (
|
||||
typeof pid !== 'number' ||
|
||||
!Number.isInteger(pid) ||
|
||||
pid <= 0 ||
|
||||
typeof port !== 'number' ||
|
||||
!Number.isInteger(port) ||
|
||||
port <= 0 ||
|
||||
typeof startedAtMs !== 'number' ||
|
||||
!Number.isInteger(startedAtMs) ||
|
||||
startedAtMs <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pid,
|
||||
port,
|
||||
startedAtMs,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeBackgroundStatsServerState(
|
||||
statePath: string,
|
||||
state: BackgroundStatsServerState,
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
||||
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export function removeBackgroundStatsServerState(statePath: string): void {
|
||||
try {
|
||||
fs.rmSync(statePath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function isBackgroundStatsServerProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBackgroundStatsServerUrl(
|
||||
state: Pick<BackgroundStatsServerState, 'port'>,
|
||||
): string {
|
||||
return `http://127.0.0.1:${state.port}`;
|
||||
}
|
||||
114
src/main/runtime/subtitle-prefetch-init.test.ts
Normal file
114
src/main/runtime/subtitle-prefetch-init.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
|
||||
import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch';
|
||||
import { createSubtitlePrefetchInitController } from './subtitle-prefetch-init';
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test('latest subtitle prefetch init wins over stale async loads', async () => {
|
||||
const loads = new Map<string, ReturnType<typeof createDeferred<string>>>();
|
||||
const started: string[] = [];
|
||||
const stopped: string[] = [];
|
||||
let currentService: SubtitlePrefetchService | null = null;
|
||||
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => currentService,
|
||||
setCurrentService: (service) => {
|
||||
currentService = service;
|
||||
},
|
||||
loadSubtitleSourceText: async (source) => {
|
||||
const deferred = createDeferred<string>();
|
||||
loads.set(source, deferred);
|
||||
return await deferred.promise;
|
||||
},
|
||||
parseSubtitleCues: (_content, filename): SubtitleCue[] => [
|
||||
{ startTime: 0, endTime: 1, text: filename },
|
||||
],
|
||||
createSubtitlePrefetchService: ({ cues }) => ({
|
||||
start: () => {
|
||||
started.push(cues[0]!.text);
|
||||
},
|
||||
stop: () => {
|
||||
stopped.push(cues[0]!.text);
|
||||
},
|
||||
onSeek: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
const firstInit = controller.initSubtitlePrefetch('old.ass', 1);
|
||||
const secondInit = controller.initSubtitlePrefetch('new.ass', 2);
|
||||
|
||||
loads.get('new.ass')!.resolve('new');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(started, ['new.ass']);
|
||||
|
||||
loads.get('old.ass')!.resolve('old');
|
||||
await Promise.all([firstInit, secondInit]);
|
||||
|
||||
assert.deepEqual(started, ['new.ass']);
|
||||
assert.deepEqual(stopped, []);
|
||||
});
|
||||
|
||||
test('cancelPendingInit prevents an in-flight load from attaching a stale service', async () => {
|
||||
const deferred = createDeferred<string>();
|
||||
let currentService: SubtitlePrefetchService | null = null;
|
||||
const started: string[] = [];
|
||||
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => currentService,
|
||||
setCurrentService: (service) => {
|
||||
currentService = service;
|
||||
},
|
||||
loadSubtitleSourceText: async () => await deferred.promise,
|
||||
parseSubtitleCues: (_content, filename): SubtitleCue[] => [
|
||||
{ startTime: 0, endTime: 1, text: filename },
|
||||
],
|
||||
createSubtitlePrefetchService: ({ cues }) => ({
|
||||
start: () => {
|
||||
started.push(cues[0]!.text);
|
||||
},
|
||||
stop: () => {},
|
||||
onSeek: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
const initPromise = controller.initSubtitlePrefetch('stale.ass', 1);
|
||||
controller.cancelPendingInit();
|
||||
deferred.resolve('stale');
|
||||
await initPromise;
|
||||
|
||||
assert.equal(currentService, null);
|
||||
assert.deepEqual(started, []);
|
||||
});
|
||||
83
src/main/runtime/subtitle-prefetch-init.ts
Normal file
83
src/main/runtime/subtitle-prefetch-init.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
|
||||
import type {
|
||||
SubtitlePrefetchService,
|
||||
SubtitlePrefetchServiceDeps,
|
||||
} from '../../core/services/subtitle-prefetch';
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
export interface SubtitlePrefetchInitControllerDeps {
|
||||
getCurrentService: () => SubtitlePrefetchService | null;
|
||||
setCurrentService: (service: SubtitlePrefetchService | null) => void;
|
||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
||||
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
isCacheFull: () => boolean;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface SubtitlePrefetchInitController {
|
||||
cancelPendingInit: () => void;
|
||||
initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createSubtitlePrefetchInitController(
|
||||
deps: SubtitlePrefetchInitControllerDeps,
|
||||
): SubtitlePrefetchInitController {
|
||||
let initRevision = 0;
|
||||
|
||||
const cancelPendingInit = (): void => {
|
||||
initRevision += 1;
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
};
|
||||
|
||||
const initSubtitlePrefetch = async (
|
||||
externalFilename: string,
|
||||
currentTimePos: number,
|
||||
): Promise<void> => {
|
||||
const revision = ++initRevision;
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
|
||||
try {
|
||||
const content = await deps.loadSubtitleSourceText(externalFilename);
|
||||
if (revision !== initRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cues = deps.parseSubtitleCues(content, externalFilename);
|
||||
if (revision !== initRevision || cues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextService = deps.createSubtitlePrefetchService({
|
||||
cues,
|
||||
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
|
||||
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
|
||||
isCacheFull: () => deps.isCacheFull(),
|
||||
});
|
||||
|
||||
if (revision !== initRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.setCurrentService(nextService);
|
||||
nextService.start(currentTimePos);
|
||||
deps.logInfo(
|
||||
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (revision === initRevision) {
|
||||
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cancelPendingInit,
|
||||
initSubtitlePrefetch,
|
||||
};
|
||||
}
|
||||
50
src/main/runtime/subtitle-prefetch-source.test.ts
Normal file
50
src/main/runtime/subtitle-prefetch-source.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getActiveExternalSubtitleSource,
|
||||
resolveSubtitleSourcePath,
|
||||
} from './subtitle-prefetch-source';
|
||||
|
||||
test('getActiveExternalSubtitleSource returns the active external subtitle path', () => {
|
||||
const source = getActiveExternalSubtitleSource(
|
||||
[
|
||||
{ type: 'sub', id: 1, external: false },
|
||||
{ type: 'sub', id: 2, external: true, 'external-filename': ' https://host/subs.ass ' },
|
||||
],
|
||||
'2',
|
||||
);
|
||||
|
||||
assert.equal(source, 'https://host/subs.ass');
|
||||
});
|
||||
|
||||
test('getActiveExternalSubtitleSource returns null when the selected track is not external', () => {
|
||||
const source = getActiveExternalSubtitleSource(
|
||||
[{ type: 'sub', id: 2, external: false, 'external-filename': '/tmp/subs.ass' }],
|
||||
2,
|
||||
);
|
||||
|
||||
assert.equal(source, null);
|
||||
});
|
||||
|
||||
test('resolveSubtitleSourcePath converts file URLs with spaces into filesystem paths', () => {
|
||||
const fileUrl =
|
||||
process.platform === 'win32'
|
||||
? 'file:///C:/Users/test/Sub%20Folder/subs.ass'
|
||||
: 'file:///tmp/Sub%20Folder/subs.ass';
|
||||
|
||||
const resolved = resolveSubtitleSourcePath(fileUrl);
|
||||
|
||||
assert.ok(
|
||||
resolved.endsWith('/Sub Folder/subs.ass') || resolved.endsWith('\\Sub Folder\\subs.ass'),
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => {
|
||||
assert.equal(resolveSubtitleSourcePath('/tmp/subs.ass'), '/tmp/subs.ass');
|
||||
});
|
||||
|
||||
test('resolveSubtitleSourcePath returns the original source for malformed file URLs', () => {
|
||||
const source = 'file://invalid[path';
|
||||
|
||||
assert.equal(resolveSubtitleSourcePath(source), source);
|
||||
});
|
||||
42
src/main/runtime/subtitle-prefetch-source.ts
Normal file
42
src/main/runtime/subtitle-prefetch-source.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export function getActiveExternalSubtitleSource(
|
||||
trackListRaw: unknown,
|
||||
sidRaw: unknown,
|
||||
): string | null {
|
||||
if (!Array.isArray(trackListRaw) || sidRaw == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sid =
|
||||
typeof sidRaw === 'number' ? sidRaw : typeof sidRaw === 'string' ? Number(sidRaw) : null;
|
||||
if (sid == null || !Number.isFinite(sid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTrack = trackListRaw.find((entry: unknown) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const track = entry as Record<string, unknown>;
|
||||
return track.type === 'sub' && track.id === sid && track.external === true;
|
||||
}) as Record<string, unknown> | undefined;
|
||||
|
||||
const externalFilename =
|
||||
typeof activeTrack?.['external-filename'] === 'string'
|
||||
? activeTrack['external-filename'].trim()
|
||||
: '';
|
||||
return externalFilename || null;
|
||||
}
|
||||
|
||||
export function resolveSubtitleSourcePath(source: string): string {
|
||||
if (!source.startsWith('file://')) {
|
||||
return source;
|
||||
}
|
||||
|
||||
try {
|
||||
return fileURLToPath(new URL(source));
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,7 @@ export interface AppState {
|
||||
runtimeOptionsManager: RuntimeOptionsManager | null;
|
||||
trackerNotReadyWarningShown: boolean;
|
||||
overlayDebugVisualizationEnabled: boolean;
|
||||
statsOverlayVisible: boolean;
|
||||
subsyncInProgress: boolean;
|
||||
initialArgs: CliArgs | null;
|
||||
mpvSocketPath: string;
|
||||
@@ -196,6 +197,8 @@ export interface AppState {
|
||||
anilistSetupPageOpened: boolean;
|
||||
anilistRetryQueueState: AnilistRetryQueueState;
|
||||
firstRunSetupCompleted: boolean;
|
||||
statsServer: { close: () => void } | null;
|
||||
statsStartupInProgress: boolean;
|
||||
}
|
||||
|
||||
export interface AppStateInitialValues {
|
||||
@@ -258,6 +261,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
runtimeOptionsManager: null,
|
||||
trackerNotReadyWarningShown: false,
|
||||
overlayDebugVisualizationEnabled: false,
|
||||
statsOverlayVisible: false,
|
||||
shortcutsRegistered: false,
|
||||
overlayRuntimeInitialized: false,
|
||||
fieldGroupingResolver: null,
|
||||
@@ -275,6 +279,8 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
anilistSetupPageOpened: false,
|
||||
anilistRetryQueueState: createInitialAnilistRetryQueueState(),
|
||||
firstRunSetupCompleted: false,
|
||||
statsServer: null,
|
||||
statsStartupInProgress: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user