From 192672c0511876b1677b89cbe19b75cb567ba708 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 27 Feb 2026 21:20:35 -0800 Subject: [PATCH] fix: lazy initialize immersion tracker --- src/core/services/app-ready.test.ts | 25 ++--- src/core/services/startup.ts | 9 +- src/main.ts | 99 ++++++++++++++----- .../runtime/composers/app-ready-composer.ts | 12 ++- .../composers/mpv-runtime-composer.test.ts | 1 + .../runtime/mpv-main-event-main-deps.test.ts | 2 + src/main/runtime/mpv-main-event-main-deps.ts | 23 +++-- 7 files changed, 108 insertions(+), 63 deletions(-) diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index eee41cb..c56bdff 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -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 () => { diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index 29eb249..a9e4098 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -203,14 +203,9 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { + 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 { + 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, diff --git a/src/main/runtime/composers/app-ready-composer.ts b/src/main/runtime/composers/app-ready-composer.ts index 7de9b8a..832989e 100644 --- a/src/main/runtime/composers/app-ready-composer.ts +++ b/src/main/runtime/composers/app-ready-composer.ts @@ -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, })(), ); diff --git a/src/main/runtime/composers/mpv-runtime-composer.test.ts b/src/main/runtime/composers/mpv-runtime-composer.test.ts index 6309051..1f7fdb9 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.test.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.test.ts @@ -75,6 +75,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject broadcastToOverlayWindows: () => {}, onSubtitleChange: () => {}, refreshDiscordPresence: () => {}, + ensureImmersionTrackerInitialized: () => {}, updateCurrentMediaPath: () => {}, restoreMpvSubVisibility: () => {}, getCurrentAnilistMediaKey: () => null, diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index a9e0dc5..71e3a10 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -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')); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 4129000..e399280 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -38,6 +38,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; updateSubtitleRenderMetrics: (patch: Record) => 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) =>