fix: lazy initialize immersion tracker

This commit is contained in:
2026-02-27 21:20:35 -08:00
parent 1d67b12028
commit 192672c051
7 changed files with 108 additions and 63 deletions

View File

@@ -58,9 +58,12 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
await runAppReadyRuntime(deps);
assert.ok(calls.includes('startSubtitleWebsocket:9001'));
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('createImmersionTracker'));
assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok(calls.includes('log:Runtime ready: invoking createImmersionTracker.'));
assert.ok(
calls.includes(
'log:Runtime ready: immersion tracker startup deferred until first media activity.',
),
);
});
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
@@ -86,23 +89,7 @@ test('runAppReadyRuntime logs when createImmersionTracker dependency is missing'
createImmersionTracker: undefined,
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('log:Runtime ready: createImmersionTracker dependency is missing.'));
});
test('runAppReadyRuntime logs and continues when createImmersionTracker throws', async () => {
const { deps, calls } = makeDeps({
createImmersionTracker: () => {
calls.push('createImmersionTracker');
throw new Error('immersion init failed');
},
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('createImmersionTracker'));
assert.ok(
calls.includes('log:Runtime ready: createImmersionTracker failed: immersion init failed'),
);
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('handleInitialArgs'));
assert.ok(calls.includes('log:Runtime ready: immersion tracker dependency is missing.'));
});
test('runAppReadyRuntime logs defer message when overlay not auto-started', async () => {

View File

@@ -203,14 +203,9 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.createSubtitleTimingTracker();
if (deps.createImmersionTracker) {
deps.log('Runtime ready: invoking createImmersionTracker.');
try {
deps.createImmersionTracker();
} catch (error) {
deps.log(`Runtime ready: createImmersionTracker failed: ${(error as Error).message}`);
}
deps.log('Runtime ready: immersion tracker startup deferred until first media activity.');
} else {
deps.log('Runtime ready: createImmersionTracker dependency is missing.');
deps.log('Runtime ready: immersion tracker dependency is missing.');
}
if (deps.texthookerOnlyMode) {

View File

@@ -365,6 +365,8 @@ import {
triggerFieldGrouping as triggerFieldGroupingCore,
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
} from './core/services';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
import {
guessAnilistMediaInfo,
@@ -437,7 +439,9 @@ import { resolveConfigDir } from './config/path-resolution';
if (process.platform === 'linux') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
const passwordStore = normalizePasswordStoreArg(getPasswordStoreArg(process.argv) ?? getDefaultPasswordStore());
const passwordStore = normalizePasswordStoreArg(
getPasswordStoreArg(process.argv) ?? getDefaultPasswordStore(),
);
app.commandLine.appendSwitch('password-store', passwordStore);
console.debug(`[main] Applied --password-store ${passwordStore}`);
}
@@ -683,8 +687,13 @@ function refreshDiscordPresenceMediaDuration(): void {
}
function publishDiscordPresence(): void {
const discordPresenceService = appState.discordPresenceService;
if (!discordPresenceService || getResolvedConfig().discordPresence.enabled !== true) {
return;
}
refreshDiscordPresenceMediaDuration();
appState.discordPresenceService?.publish({
discordPresenceService.publish({
mediaTitle: appState.currentMediaTitle,
mediaPath: appState.currentMediaPath,
subtitleText: appState.currentSubText,
@@ -718,6 +727,11 @@ function createDiscordRpcClient() {
}
async function initializeDiscordPresenceService(): Promise<void> {
if (getResolvedConfig().discordPresence.enabled !== true) {
appState.discordPresenceService = null;
return;
}
appState.discordPresenceService = createDiscordPresenceService({
config: getResolvedConfig().discordPresence,
createClient: () => createDiscordRpcClient(),
@@ -1802,6 +1816,15 @@ const {
},
});
function refreshAnilistClientSecretStateIfEnabled(options?: {
force?: boolean;
}): Promise<string | null> {
if (!isAnilistTrackingEnabled(getResolvedConfig())) {
return Promise.resolve(null);
}
return refreshAnilistClientSecretState(options);
}
const rememberAnilistAttemptedUpdate = (key: string): void => {
rememberAnilistAttemptedUpdateKey(
anilistAttemptedUpdateKeys,
@@ -1930,6 +1953,35 @@ const {
});
registerProtocolUrlHandlersHandler();
const immersionTrackerStartupMainDeps: Parameters<
typeof createBuildImmersionTrackerStartupMainDepsHandler
>[0] = {
getResolvedConfig: () => getResolvedConfig(),
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
createTrackerService: (params) => new ImmersionTrackerService(params),
setTracker: (tracker) => {
appState.immersionTracker = tracker as ImmersionTrackerService | null;
},
getMpvClient: () => appState.mpvClient,
seedTrackerFromCurrentMedia: () => {
void immersionMediaRuntime.seedFromCurrentMedia();
},
logInfo: (message) => logger.info(message),
logDebug: (message) => logger.debug(message),
logWarn: (message, details) => logger.warn(message, details),
};
const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(
createBuildImmersionTrackerStartupMainDepsHandler(immersionTrackerStartupMainDeps)(),
);
let hasAttemptedImmersionTrackerStartup = false;
const ensureImmersionTrackerStarted = (): void => {
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
return;
}
hasAttemptedImmersionTrackerStartup = true;
createImmersionTrackerStartup();
};
const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(),
@@ -1937,7 +1989,7 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR
logWarning: (message) => appLogger.logWarning(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
startConfigHotReload: () => configHotReloadRuntime.start(),
refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretState(options),
refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretStateIfEnabled(options),
failHandlers: {
logError: (details) => logger.error(details),
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
@@ -2016,26 +2068,15 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR
: configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
createImmersionTracker: () => {
ensureImmersionTrackerStarted();
},
logDebug: (message: string) => {
logger.debug(message);
},
now: () => Date.now(),
},
immersionTrackerStartupMainDeps: {
getResolvedConfig: () => getResolvedConfig(),
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
createTrackerService: (params) => new ImmersionTrackerService(params),
setTracker: (tracker) => {
appState.immersionTracker = tracker as ImmersionTrackerService | null;
},
getMpvClient: () => appState.mpvClient,
seedTrackerFromCurrentMedia: () => {
void immersionMediaRuntime.seedFromCurrentMedia();
},
logInfo: (message) => logger.info(message),
logDebug: (message) => logger.debug(message),
logWarn: (message, details) => logger.warn(message, details),
},
immersionTrackerStartupMainDeps,
});
const { appLifecycleRuntimeRunner, runAndApplyStartupState } =
@@ -2099,8 +2140,10 @@ const { appLifecycleRuntimeRunner, runAndApplyStartupState } =
});
runAndApplyStartupState();
void refreshAnilistClientSecretState({ force: true });
anilistStateRuntime.refreshRetryQueueState();
if (isAnilistTrackingEnabled(getResolvedConfig())) {
void refreshAnilistClientSecretStateIfEnabled({ force: true });
anilistStateRuntime.refreshRetryQueueState();
}
void initializeDiscordPresenceService();
const handleCliCommand = createCliCommandRuntimeHandler({
@@ -2167,7 +2210,13 @@ const {
refreshDiscordPresence: () => {
publishDiscordPresence();
},
ensureImmersionTrackerInitialized: () => {
ensureImmersionTrackerStarted();
},
updateCurrentMediaPath: (path) => {
if (path) {
ensureImmersionTrackerStarted();
}
mediaRuntime.updateCurrentMediaPath(path);
},
restoreMpvSubVisibility: () => {
@@ -2241,6 +2290,7 @@ const {
},
isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)),
recordLookup: (hit) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordLookup(hit);
},
getKnownWordMatchMode: () =>
@@ -2662,6 +2712,7 @@ const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDeps
showMpvOsd: (text) => showMpvOsd(text),
mineSentenceCardCore,
recordCardsMined: (count) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count);
},
});
@@ -2697,6 +2748,7 @@ const buildHandleMineSentenceDigitMainDepsHandler =
logger.error(message, err);
},
onCardsMined: (cards) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(cards);
},
handleMineSentenceDigitCore,
@@ -2910,9 +2962,7 @@ const {
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) =>
windowKind === 'visible'
? overlayManager.getVisibleOverlayVisible()
: false,
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
onWindowClosed: (windowKind) => {
@@ -3013,7 +3063,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
},
createMainWindow: () => createMainWindow(),
registerGlobalShortcuts: () => registerGlobalShortcuts(),
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
updateVisibleOverlayBounds(geometry),
getOverlayWindows: () => getOverlayWindows(),
getResolvedConfig: () => getResolvedConfig(),
showDesktopNotification,

View File

@@ -42,11 +42,13 @@ export function composeAppReadyRuntime(options: AppReadyComposerOptions): AppRea
createBuildAppReadyRuntimeMainDepsHandler({
...options.appReadyRuntimeMainDeps,
reloadConfig,
createImmersionTracker: createImmersionTrackerStartupHandler(
createBuildImmersionTrackerStartupMainDepsHandler(
options.immersionTrackerStartupMainDeps,
)(),
),
createImmersionTracker:
options.appReadyRuntimeMainDeps.createImmersionTracker ??
createImmersionTrackerStartupHandler(
createBuildImmersionTrackerStartupMainDepsHandler(
options.immersionTrackerStartupMainDeps,
)(),
),
onCriticalConfigErrors: criticalConfigError,
})(),
);

View File

@@ -75,6 +75,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,

View File

@@ -40,6 +40,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => 'media-key',
@@ -97,6 +98,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('remote-stopped'));
assert.ok(calls.includes('sync-overlay-mpv-sub'));
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('ensure-immersion'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh'));

View File

@@ -38,6 +38,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void;
ensureImmersionTrackerInitialized: () => void;
}) {
return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
@@ -48,8 +49,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
quitApp: () => deps.quitApp(),
recordImmersionSubtitleLine: (text: string, start: number, end: number) =>
deps.appState.immersionTracker?.recordSubtitleLine?.(text, start, end),
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordSubtitleLine?.(text, start, end);
},
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) =>
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
@@ -71,8 +74,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
broadcastSecondarySubtitle: (text: string) =>
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
restoreMpvSubVisibility: () =>
deps.restoreMpvSubVisibility(),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey: string | null) =>
deps.resetAnilistMediaTracking(mediaKey),
@@ -81,14 +83,19 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title: string) =>
deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title),
recordPlaybackPosition: (time: number) =>
deps.appState.immersionTracker?.recordPlaybackPosition?.(time),
notifyImmersionTitleUpdate: (title: string) => {
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title);
},
recordPlaybackPosition: (time: number) => {
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordPlaybackPosition?.(time);
},
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
recordPauseState: (paused: boolean) => {
deps.appState.playbackPaused = paused;
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordPauseState?.(paused);
},
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>