fix: lazy initialize immersion tracker

This commit is contained in:
2026-02-27 21:20:35 -08:00
parent 1e645f961b
commit 30a76d7767
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); await runAppReadyRuntime(deps);
assert.ok(calls.includes('startSubtitleWebsocket:9001')); assert.ok(calls.includes('startSubtitleWebsocket:9001'));
assert.ok(calls.includes('initializeOverlayRuntime')); assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('createImmersionTracker'));
assert.ok(calls.includes('startBackgroundWarmups')); 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 () => { 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, createImmersionTracker: undefined,
}); });
await runAppReadyRuntime(deps); await runAppReadyRuntime(deps);
assert.ok(calls.includes('log:Runtime ready: createImmersionTracker dependency is missing.')); assert.ok(calls.includes('log:Runtime ready: immersion tracker 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'));
}); });
test('runAppReadyRuntime logs defer message when overlay not auto-started', async () => { 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(); deps.createSubtitleTimingTracker();
if (deps.createImmersionTracker) { if (deps.createImmersionTracker) {
deps.log('Runtime ready: invoking createImmersionTracker.'); deps.log('Runtime ready: immersion tracker startup deferred until first media activity.');
try {
deps.createImmersionTracker();
} catch (error) {
deps.log(`Runtime ready: createImmersionTracker failed: ${(error as Error).message}`);
}
} else { } else {
deps.log('Runtime ready: createImmersionTracker dependency is missing.'); deps.log('Runtime ready: immersion tracker dependency is missing.');
} }
if (deps.texthookerOnlyMode) { if (deps.texthookerOnlyMode) {

View File

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

View File

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

View File

@@ -75,6 +75,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
broadcastToOverlayWindows: () => {}, broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {}, onSubtitleChange: () => {},
refreshDiscordPresence: () => {}, refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {}, updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {}, restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null, 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) => broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`), calls.push(`broadcast:${channel}:${String(payload)}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`), onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`), updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => 'media-key', 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('remote-stopped'));
assert.ok(calls.includes('sync-overlay-mpv-sub')); assert.ok(calls.includes('sync-overlay-mpv-sub'));
assert.ok(calls.includes('anilist-post-watch')); assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('ensure-immersion'));
assert.ok(calls.includes('sync-immersion')); assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('metrics')); assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('presence-refresh'));

View File

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