From 0a58c20ad7d91b2e072eceb6b65ee8929fe5df71 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 7 Jul 2026 00:16:57 -0700 Subject: [PATCH] fix(stats): start stats server on background app launch (#144) --- changes/background-app-stats-server.md | 4 + docs-site/immersion-tracking.md | 2 +- src/core/services/cli-command.test.ts | 40 ++++++++++ src/core/services/cli-command.ts | 7 ++ src/main.ts | 10 +++ src/main/cli-runtime.ts | 2 + src/main/dependencies.ts | 2 + .../runtime/background-stats-startup.test.ts | 78 +++++++++++++++++++ src/main/runtime/background-stats-startup.ts | 35 +++++++++ src/main/runtime/cli-command-context-deps.ts | 2 + .../runtime/cli-command-context-main-deps.ts | 2 + src/main/runtime/cli-command-context.ts | 2 + 12 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 changes/background-app-stats-server.md create mode 100644 src/main/runtime/background-stats-startup.test.ts create mode 100644 src/main/runtime/background-stats-startup.ts diff --git a/changes/background-app-stats-server.md b/changes/background-app-stats-server.md new file mode 100644 index 00000000..61c05d00 --- /dev/null +++ b/changes/background-app-stats-server.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- `subminer app` background launches now start the stats server automatically when `stats.autoStartServer` is enabled, and skip startup when a background stats server is already running. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index 0227afd3..fc2db15e 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -95,7 +95,7 @@ Stats server config lives under `stats`: - `toggleKey` is overlay-local, not a system-wide shortcut. - `serverPort` controls the localhost dashboard URL. -- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. +- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one. - `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running. - `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`. - `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement. diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 60021c50..495d1430 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -374,6 +374,46 @@ test('handleCliCommand processes --start for second-instance when overlay runtim ); }); +test('handleCliCommand ensures background stats server for initial --start --background', () => { + const ensured: number[] = []; + const { deps } = createDeps({ + ensureBackgroundStatsServer: () => { + ensured.push(1); + }, + }); + + handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps); + + assert.equal(ensured.length, 1); +}); + +test('handleCliCommand ensures background stats server for second-instance --start --background', () => { + const ensured: number[] = []; + const { deps } = createDeps({ + isOverlayRuntimeInitialized: () => true, + ensureBackgroundStatsServer: () => { + ensured.push(1); + }, + }); + + handleCliCommand(makeArgs({ start: true, background: true }), 'second-instance', deps); + + assert.equal(ensured.length, 1); +}); + +test('handleCliCommand does not ensure background stats server for foreground --start', () => { + const ensured: number[] = []; + const { deps } = createDeps({ + ensureBackgroundStatsServer: () => { + ensured.push(1); + }, + }); + + handleCliCommand(makeArgs({ start: true }), 'initial', deps); + + assert.equal(ensured.length, 0); +}); + test('handleCliCommand forces setup open for second-instance setup command', () => { const { deps, calls } = createDeps(); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index d979486f..79d0edf2 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -106,6 +106,7 @@ export interface CliCommandServiceDeps { mode: NonNullable; source: CliCommandSource; }) => Promise; + ensureBackgroundStatsServer?: () => void; printHelp: () => void; hasMainWindow: () => boolean; getMultiCopyTimeoutMs: () => number; @@ -185,6 +186,7 @@ interface AnilistCliRuntime { interface AppCliRuntime { stop: () => void; hasMainWindow: () => boolean; + ensureBackgroundStatsServer?: () => void; runUpdateCommand: CliCommandServiceDeps['runUpdateCommand']; runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand']; runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow']; @@ -299,6 +301,7 @@ export function createCliCommandDepsRuntime( runUpdateCommand: options.app.runUpdateCommand, runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand, runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow, + ensureBackgroundStatsServer: options.app.ensureBackgroundStatsServer, printHelp: options.ui.printHelp, hasMainWindow: options.app.hasMainWindow, getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, @@ -393,6 +396,10 @@ export function handleCliCommand( deps.log(`Starting MPV IPC connection on socket: ${socketPath}`); } + if (args.start && args.background) { + deps.ensureBackgroundStatsServer?.(); + } + if (args.sessionAction) { dispatchCliSessionAction( args.sessionAction, diff --git a/src/main.ts b/src/main.ts index d3375ae5..975b9306 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,6 +37,7 @@ import { createAniSkipRuntime } from './main/runtime/aniskip-runtime'; import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata'; import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js'; import { startAppControlServer } from './main/runtime/app-control-server'; +import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup'; import { markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState, shouldAutoLoadSecondarySubTrackForJellyfinPlayback, @@ -4069,6 +4070,14 @@ const statsStartupRuntime = { } }, } as const; +const ensureBackgroundStatsServer = createEnsureBackgroundStatsServerHandler({ + isStatsAutoStartEnabled: () => getResolvedConfig().stats.autoStartServer, + isImmersionTrackingEnabled: () => getResolvedConfig().immersionTracking?.enabled !== false, + ensureBackgroundStatsServerStarted: () => + statsStartupRuntime.ensureBackgroundStatsServerStarted(), + logInfo: (message) => logger.info(message), + logWarn: (message, error) => logger.warn(message, error), +}); const runStatsCliCommand = createRunStatsCliCommandHandler({ getResolvedConfig: () => getResolvedConfig(), @@ -5965,6 +5974,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ ); }, runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request), + ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(), openYomitanSettings: () => openYomitanSettings(), openConfigSettingsWindow: () => openConfigSettingsWindow(), cycleSecondarySubMode: () => handleCycleSecondarySubMode(), diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index d5e63ffa..700ca6aa 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -47,6 +47,7 @@ export interface CliCommandRuntimeServiceContext { runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand']; runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceDepsParams['app']['runEnsureLinuxRuntimePluginAssetsCommand']; runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow']; + ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer']; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; cycleSecondarySubMode: () => void; @@ -124,6 +125,7 @@ function createCliCommandDepsFromContext( app: { stop: context.stopApp, hasMainWindow: context.hasMainWindow, + ensureBackgroundStatsServer: context.ensureBackgroundStatsServer, runUpdateCommand: context.runUpdateCommand, runEnsureLinuxRuntimePluginAssetsCommand: context.runEnsureLinuxRuntimePluginAssetsCommand, runYoutubePlaybackFlow: context.runYoutubePlaybackFlow, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index fd578971..cc4f8029 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -200,6 +200,7 @@ export interface CliCommandRuntimeServiceDepsParams { app: { stop: CliCommandDepsRuntimeOptions['app']['stop']; hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow']; + ensureBackgroundStatsServer?: CliCommandDepsRuntimeOptions['app']['ensureBackgroundStatsServer']; runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand']; runEnsureLinuxRuntimePluginAssetsCommand: CliCommandDepsRuntimeOptions['app']['runEnsureLinuxRuntimePluginAssetsCommand']; runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow']; @@ -402,6 +403,7 @@ export function createCliCommandRuntimeServiceDeps( app: { stop: params.app.stop, hasMainWindow: params.app.hasMainWindow, + ensureBackgroundStatsServer: params.app.ensureBackgroundStatsServer, runUpdateCommand: params.app.runUpdateCommand, runEnsureLinuxRuntimePluginAssetsCommand: params.app.runEnsureLinuxRuntimePluginAssetsCommand, runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow, diff --git a/src/main/runtime/background-stats-startup.test.ts b/src/main/runtime/background-stats-startup.test.ts new file mode 100644 index 00000000..a2477a7f --- /dev/null +++ b/src/main/runtime/background-stats-startup.test.ts @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createEnsureBackgroundStatsServerHandler } from './background-stats-startup'; + +function createDeps( + overrides: Partial[0]> = {}, +) { + const calls: string[] = []; + const deps: Parameters[0] = { + isStatsAutoStartEnabled: () => true, + isImmersionTrackingEnabled: () => true, + ensureBackgroundStatsServerStarted: () => { + calls.push('ensureBackgroundStatsServerStarted'); + return { url: 'http://127.0.0.1:3888', runningInCurrentProcess: true }; + }, + logInfo: (message) => { + calls.push(`info:${message}`); + }, + logWarn: (message) => { + calls.push(`warn:${message}`); + }, + ...overrides, + }; + return { deps, calls }; +} + +test('ensures background stats server and logs local startup', () => { + const { deps, calls } = createDeps(); + + createEnsureBackgroundStatsServerHandler(deps)(); + + assert.ok(calls.includes('ensureBackgroundStatsServerStarted')); + assert.ok( + calls.some((value) => value.startsWith('info:') && value.includes('http://127.0.0.1:3888')), + ); +}); + +test('logs reuse when a background stats server is already running', () => { + const { deps, calls } = createDeps({ + ensureBackgroundStatsServerStarted: () => ({ + url: 'http://127.0.0.1:3888', + runningInCurrentProcess: false, + }), + }); + + createEnsureBackgroundStatsServerHandler(deps)(); + + assert.ok( + calls.some((value) => value.startsWith('info:') && /already running|reusing/i.test(value)), + ); +}); + +test('skips when stats.autoStartServer is disabled', () => { + const { deps, calls } = createDeps({ isStatsAutoStartEnabled: () => false }); + + createEnsureBackgroundStatsServerHandler(deps)(); + + assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false); +}); + +test('skips when immersion tracking is disabled', () => { + const { deps, calls } = createDeps({ isImmersionTrackingEnabled: () => false }); + + createEnsureBackgroundStatsServerHandler(deps)(); + + assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false); +}); + +test('logs a warning instead of throwing when startup fails', () => { + const { deps, calls } = createDeps({ + ensureBackgroundStatsServerStarted: () => { + throw new Error('port in use'); + }, + }); + + assert.doesNotThrow(() => createEnsureBackgroundStatsServerHandler(deps)()); + assert.ok(calls.some((value) => value.startsWith('warn:'))); +}); diff --git a/src/main/runtime/background-stats-startup.ts b/src/main/runtime/background-stats-startup.ts new file mode 100644 index 00000000..3cdd5754 --- /dev/null +++ b/src/main/runtime/background-stats-startup.ts @@ -0,0 +1,35 @@ +export interface EnsureBackgroundStatsServerDeps { + isStatsAutoStartEnabled: () => boolean; + isImmersionTrackingEnabled: () => boolean; + ensureBackgroundStatsServerStarted: () => { + url: string; + runningInCurrentProcess: boolean; + }; + logInfo: (message: string) => void; + logWarn: (message: string, error?: unknown) => void; +} + +export function createEnsureBackgroundStatsServerHandler( + deps: EnsureBackgroundStatsServerDeps, +): () => void { + return () => { + if (!deps.isStatsAutoStartEnabled()) { + deps.logInfo('Background start: stats.autoStartServer is disabled; skipping stats server.'); + return; + } + if (!deps.isImmersionTrackingEnabled()) { + deps.logInfo('Background start: immersion tracking is disabled; skipping stats server.'); + return; + } + try { + const result = deps.ensureBackgroundStatsServerStarted(); + deps.logInfo( + result.runningInCurrentProcess + ? `Background start: stats server started at ${result.url}.` + : `Background start: stats server already running at ${result.url}; skipping.`, + ); + } catch (error) { + deps.logWarn('Background start: failed to start stats server.', error); + } + }; +} diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index d48e92d9..75c4afb6 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -45,6 +45,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand']; runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand']; runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; + ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer']; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; cycleSecondarySubMode: () => void; @@ -103,6 +104,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { runUpdateCommand: deps.runUpdateCommand, runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand, runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, + ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, openYomitanSettings: deps.openYomitanSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, cycleSecondarySubMode: deps.cycleSecondarySubMode, diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 95778673..c1354da6 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -61,6 +61,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { source: CliCommandSource, ) => Promise; runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; + ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer']; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; @@ -140,6 +141,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) => deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source), runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request), + ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, openYomitanSettings: () => deps.openYomitanSettings(), openConfigSettingsWindow: () => deps.openConfigSettingsWindow(), cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index 61f10224..81159318 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -50,6 +50,7 @@ export type CliCommandContextFactoryDeps = { runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand']; runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceContext['runEnsureLinuxRuntimePluginAssetsCommand']; runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow']; + ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer']; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; cycleSecondarySubMode: () => void; @@ -130,6 +131,7 @@ export function createCliCommandContext( runUpdateCommand: deps.runUpdateCommand, runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand, runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, + ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, openYomitanSettings: deps.openYomitanSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, cycleSecondarySubMode: deps.cycleSecondarySubMode,