From 08a5401a7d4a0d411ecbc514c8a3e11cbc9d9f09 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 17 Mar 2026 19:54:04 -0700 Subject: [PATCH] feat: add background stats server daemon lifecycle Implement `subminer stats -b` to start a background stats daemon and `subminer stats -s` to stop it, with PID-based process lifecycle management, single-instance lock bypass for daemon mode, and automatic reuse of running daemon instances. --- launcher/commands/command-modules.test.ts | 80 ++++++++++- launcher/commands/stats-command.ts | 60 +++++++-- launcher/config/args-normalizer.ts | 4 + launcher/config/cli-parser-builder.ts | 16 +++ launcher/parse-args.test.ts | 22 +++ launcher/types.ts | 2 + src/cli/args.test.ts | 20 +++ src/cli/args.ts | 12 +- src/config/definitions/defaults-stats.ts | 1 + src/config/definitions/options-stats.ts | 6 + src/config/resolve/stats.ts | 7 + .../services/__tests__/stats-server.test.ts | 107 +++++++++++++++ src/core/services/stats-server.ts | 127 ++++++++++++++++-- src/main.ts | 123 ++++++++++++++++- src/main/dependencies.ts | 2 + src/main/early-single-instance.test.ts | 14 ++ src/main/early-single-instance.ts | 4 + src/main/runtime/stats-cli-command.test.ts | 87 ++++++++++++ src/main/runtime/stats-cli-command.ts | 43 +++++- src/main/runtime/stats-daemon.ts | 72 ++++++++++ 20 files changed, 776 insertions(+), 33 deletions(-) create mode 100644 src/main/runtime/stats-daemon.ts diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 101da12..15d436c 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -159,6 +159,36 @@ test('stats command launches attached app command with response path', async () ]); }); +test('stats background command launches detached app command with response path', async () => { + const context = createContext(); + context.args.stats = true; + (context.args as typeof context.args & { statsBackground?: boolean }).statsBackground = true; + const forwarded: string[][] = []; + + const handled = await runStatsCommand(context, { + createTempDir: () => '/tmp/subminer-stats-test', + joinPath: (...parts) => parts.join('/'), + runAppCommandAttached: async () => { + throw new Error('attached path should not run for stats -b'); + }, + launchAppCommandDetached: (_appPath, appArgs) => { + forwarded.push(appArgs); + }, + waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }), + removeDir: () => {}, + } as Parameters[1]); + + assert.equal(handled, true); + assert.deepEqual(forwarded, [ + [ + '--stats', + '--stats-response-path', + '/tmp/subminer-stats-test/response.json', + '--stats-background', + ], + ]); +}); + test('stats command returns after startup response even if app process stays running', async () => { const context = createContext(); context.args.stats = true; @@ -185,11 +215,7 @@ test('stats command returns after startup response even if app process stays run const final = await statsCommand; assert.equal(final, true); assert.deepEqual(forwarded, [ - [ - '--stats', - '--stats-response-path', - '/tmp/subminer-stats-test/response.json', - ], + ['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json'], ]); }); @@ -223,6 +249,50 @@ test('stats cleanup command forwards cleanup vocab flags to the app', async () = ]); }); +test('stats stop command forwards stop flag to the app', async () => { + const context = createContext(); + context.args.stats = true; + (context.args as typeof context.args & { statsStop?: boolean }).statsStop = true; + const forwarded: string[][] = []; + + const handled = await runStatsCommand(context, { + createTempDir: () => '/tmp/subminer-stats-test', + joinPath: (...parts) => parts.join('/'), + runAppCommandAttached: async (_appPath, appArgs) => { + forwarded.push(appArgs); + return 0; + }, + waitForStatsResponse: async () => ({ ok: true }), + removeDir: () => {}, + }); + + assert.equal(handled, true); + assert.deepEqual(forwarded, [ + ['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', '--stats-stop'], + ]); +}); + +test('stats stop command exits on process exit without waiting for startup response', async () => { + const context = createContext(); + context.args.stats = true; + (context.args as typeof context.args & { statsStop?: boolean }).statsStop = true; + let waitedForResponse = false; + + const handled = await runStatsCommand(context, { + createTempDir: () => '/tmp/subminer-stats-test', + joinPath: (...parts) => parts.join('/'), + runAppCommandAttached: async () => 0, + waitForStatsResponse: async () => { + waitedForResponse = true; + return { ok: true }; + }, + removeDir: () => {}, + }); + + assert.equal(handled, true); + assert.equal(waitedForResponse, false); +}); + test('stats cleanup command forwards lifetime rebuild flag to the app', async () => { const context = createContext(); context.args.stats = true; diff --git a/launcher/commands/stats-command.ts b/launcher/commands/stats-command.ts index 3572acb..e21e8f6 100644 --- a/launcher/commands/stats-command.ts +++ b/launcher/commands/stats-command.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { runAppCommandAttached } from '../mpv.js'; +import { launchAppCommandDetached, runAppCommandAttached } from '../mpv.js'; import { sleep } from '../util.js'; import type { LauncherCommandContext } from './context.js'; @@ -20,17 +20,25 @@ type StatsCommandDeps = { logLevel: LauncherCommandContext['args']['logLevel'], label: string, ) => Promise; + launchAppCommandDetached: ( + appPath: string, + appArgs: string[], + logLevel: LauncherCommandContext['args']['logLevel'], + label: string, + ) => void; waitForStatsResponse: (responsePath: string) => Promise; removeDir: (targetPath: string) => void; }; -const STATS_STARTUP_RESPONSE_TIMEOUT_MS = 8_000; +const STATS_STARTUP_RESPONSE_TIMEOUT_MS = 12_000; const defaultDeps: StatsCommandDeps = { createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)), joinPath: (...parts) => path.join(...parts), runAppCommandAttached: (appPath, appArgs, logLevel, label) => runAppCommandAttached(appPath, appArgs, logLevel, label), + launchAppCommandDetached: (appPath, appArgs, logLevel, label) => + launchAppCommandDetached(appPath, appArgs, logLevel, label), waitForStatsResponse: async (responsePath) => { const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS; while (Date.now() < deadline) { @@ -55,18 +63,25 @@ const defaultDeps: StatsCommandDeps = { export async function runStatsCommand( context: LauncherCommandContext, - deps: StatsCommandDeps = defaultDeps, + deps: Partial = {}, ): Promise { + const resolvedDeps: StatsCommandDeps = { ...defaultDeps, ...deps }; const { args, appPath } = context; if (!args.stats || !appPath) { return false; } - const tempDir = deps.createTempDir('subminer-stats-'); - const responsePath = deps.joinPath(tempDir, 'response.json'); + const tempDir = resolvedDeps.createTempDir('subminer-stats-'); + const responsePath = resolvedDeps.joinPath(tempDir, 'response.json'); try { const forwarded = ['--stats', '--stats-response-path', responsePath]; + if (args.statsBackground) { + forwarded.push('--stats-background'); + } + if (args.statsStop) { + forwarded.push('--stats-stop'); + } if (args.statsCleanup) { forwarded.push('--stats-cleanup'); } @@ -79,11 +94,32 @@ export async function runStatsCommand( if (args.logLevel !== 'info') { forwarded.push('--log-level', args.logLevel); } - const attachedExitPromise = deps.runAppCommandAttached(appPath, forwarded, args.logLevel, 'stats'); + if (args.statsBackground) { + resolvedDeps.launchAppCommandDetached(appPath, forwarded, args.logLevel, 'stats'); + const startupResult = await resolvedDeps.waitForStatsResponse(responsePath); + if (!startupResult.ok) { + throw new Error(startupResult.error || 'Stats dashboard failed to start.'); + } + return true; + } + const attachedExitPromise = resolvedDeps.runAppCommandAttached( + appPath, + forwarded, + args.logLevel, + 'stats', + ); - if (!args.statsCleanup) { + if (args.statsStop) { + const status = await attachedExitPromise; + if (status !== 0) { + throw new Error(`Stats app exited with status ${status}.`); + } + return true; + } + + if (!args.statsCleanup && !args.statsStop) { const startupResult = await Promise.race([ - deps + resolvedDeps .waitForStatsResponse(responsePath) .then((response) => ({ kind: 'response' as const, response })), attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })), @@ -94,7 +130,7 @@ export async function runStatsCommand( `Stats app exited before startup response (status ${startupResult.status}).`, ); } - const response = await deps.waitForStatsResponse(responsePath); + const response = await resolvedDeps.waitForStatsResponse(responsePath); if (!response.ok) { throw new Error(response.error || 'Stats dashboard failed to start.'); } @@ -109,7 +145,7 @@ export async function runStatsCommand( const attachedExitPromiseCleanup = attachedExitPromise; const startupResult = await Promise.race([ - deps + resolvedDeps .waitForStatsResponse(responsePath) .then((response) => ({ kind: 'response' as const, response })), attachedExitPromiseCleanup.then((status) => ({ kind: 'exit' as const, status })), @@ -120,7 +156,7 @@ export async function runStatsCommand( `Stats app exited before startup response (status ${startupResult.status}).`, ); } - const response = await deps.waitForStatsResponse(responsePath); + const response = await resolvedDeps.waitForStatsResponse(responsePath); if (!response.ok) { throw new Error(response.error || 'Stats dashboard failed to start.'); } @@ -135,6 +171,6 @@ export async function runStatsCommand( } return true; } finally { - deps.removeDir(tempDir); + resolvedDeps.removeDir(tempDir); } } diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index f7f267e..3be167b 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -123,6 +123,8 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): jellyfinDiscovery: false, dictionary: false, stats: false, + statsBackground: false, + statsStop: false, statsCleanup: false, statsCleanupVocab: false, statsCleanupLifetime: false, @@ -193,6 +195,8 @@ export function applyRootOptionsToArgs( export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void { if (invocations.dictionaryTriggered) parsed.dictionary = true; if (invocations.statsTriggered) parsed.stats = true; + if (invocations.statsBackground) parsed.statsBackground = true; + if (invocations.statsStop) parsed.statsStop = true; if (invocations.statsCleanup) parsed.statsCleanup = true; if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true; if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true; diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 83172ab..07fc159 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -41,6 +41,8 @@ export interface CliInvocations { dictionaryTarget: string | null; dictionaryLogLevel: string | null; statsTriggered: boolean; + statsBackground: boolean; + statsStop: boolean; statsCleanup: boolean; statsCleanupVocab: boolean; statsCleanupLifetime: boolean; @@ -144,6 +146,8 @@ export function parseCliPrograms( let dictionaryTarget: string | null = null; let dictionaryLogLevel: string | null = null; let statsTriggered = false; + let statsBackground = false; + let statsStop = false; let statsCleanup = false; let statsCleanupVocab = false; let statsCleanupLifetime = false; @@ -256,12 +260,22 @@ export function parseCliPrograms( .command('stats') .description('Launch the local immersion stats dashboard') .argument('[action]', 'cleanup|rebuild|backfill') + .option('-b, --background', 'Start the stats server in the background') + .option('-s, --stop', 'Stop the background stats server') .option('-v, --vocab', 'Clean vocabulary rows in the stats database') .option('-l, --lifetime', 'Rebuild lifetime summary rows from retained data') .option('--log-level ', 'Log level') .action((action: string | undefined, options: Record) => { statsTriggered = true; const normalizedAction = (action || '').toLowerCase(); + statsBackground = options.background === true; + statsStop = options.stop === true; + if (statsBackground && statsStop) { + throw new Error('Stats background and stop flags cannot be combined.'); + } + if (normalizedAction && (statsBackground || statsStop)) { + throw new Error('Stats background and stop flags cannot be combined with stats actions.'); + } if (normalizedAction === 'cleanup') { statsCleanup = true; statsCleanupLifetime = options.lifetime === true; @@ -353,6 +367,8 @@ export function parseCliPrograms( dictionaryTarget, dictionaryLogLevel, statsTriggered, + statsBackground, + statsStop, statsCleanup, statsCleanupVocab, statsCleanupLifetime, diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index 50d9f0e..6944bbc 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -66,6 +66,28 @@ test('parseArgs maps stats command and log-level override', () => { assert.equal(parsed.logLevel, 'debug'); }); +test('parseArgs maps stats background flag', () => { + const parsed = parseArgs(['stats', '-b'], 'subminer', {}) as ReturnType & { + statsBackground?: boolean; + statsStop?: boolean; + }; + + assert.equal(parsed.stats, true); + assert.equal(parsed.statsBackground, true); + assert.equal(parsed.statsStop, false); +}); + +test('parseArgs maps stats stop flag', () => { + const parsed = parseArgs(['stats', '-s'], 'subminer', {}) as ReturnType & { + statsBackground?: boolean; + statsStop?: boolean; + }; + + assert.equal(parsed.stats, true); + assert.equal(parsed.statsStop, true); + assert.equal(parsed.statsBackground, false); +}); + test('parseArgs maps stats cleanup to vocab mode by default', () => { const parsed = parseArgs(['stats', 'cleanup'], 'subminer', {}); diff --git a/launcher/types.ts b/launcher/types.ts index 5c69c3b..4378db8 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -112,6 +112,8 @@ export interface Args { jellyfinDiscovery: boolean; dictionary: boolean; stats: boolean; + statsBackground?: boolean; + statsStop?: boolean; statsCleanup?: boolean; statsCleanupVocab?: boolean; statsCleanupLifetime?: boolean; diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 7af73fc..a8c20ca 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -157,6 +157,26 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(hasExplicitCommand(stats), true); assert.equal(shouldStartApp(stats), true); + const statsBackground = parseArgs(['--stats', '--stats-background']) as typeof stats & { + statsBackground?: boolean; + statsStop?: boolean; + }; + assert.equal(statsBackground.stats, true); + assert.equal(statsBackground.statsBackground, true); + assert.equal(statsBackground.statsStop, false); + assert.equal(hasExplicitCommand(statsBackground), true); + assert.equal(shouldStartApp(statsBackground), true); + + const statsStop = parseArgs(['--stats', '--stats-stop']) as typeof stats & { + statsBackground?: boolean; + statsStop?: boolean; + }; + assert.equal(statsStop.stats, true); + assert.equal(statsStop.statsStop, true); + assert.equal(statsStop.statsBackground, false); + assert.equal(hasExplicitCommand(statsStop), true); + assert.equal(shouldStartApp(statsStop), true); + const statsLifetimeRebuild = parseArgs([ '--stats', '--stats-cleanup', diff --git a/src/cli/args.ts b/src/cli/args.ts index 43a9a5a..9988f2a 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -30,6 +30,8 @@ export interface CliArgs { dictionary: boolean; dictionaryTarget?: string; stats: boolean; + statsBackground?: boolean; + statsStop?: boolean; statsCleanup?: boolean; statsCleanupVocab?: boolean; statsCleanupLifetime?: boolean; @@ -103,6 +105,8 @@ export function parseArgs(argv: string[]): CliArgs { anilistRetryQueue: false, dictionary: false, stats: false, + statsBackground: false, + statsStop: false, statsCleanup: false, statsCleanupVocab: false, statsCleanupLifetime: false, @@ -172,7 +176,13 @@ export function parseArgs(argv: string[]): CliArgs { const value = readValue(argv[i + 1]); if (value) args.dictionaryTarget = value; } else if (arg === '--stats') args.stats = true; - else if (arg === '--stats-cleanup') args.statsCleanup = true; + else if (arg === '--stats-background') { + args.stats = true; + args.statsBackground = true; + } else if (arg === '--stats-stop') { + args.stats = true; + args.statsStop = true; + } else if (arg === '--stats-cleanup') args.statsCleanup = true; else if (arg === '--stats-cleanup-vocab') args.statsCleanupVocab = true; else if (arg === '--stats-cleanup-lifetime') args.statsCleanupLifetime = true; else if (arg.startsWith('--stats-response-path=')) { diff --git a/src/config/definitions/defaults-stats.ts b/src/config/definitions/defaults-stats.ts index 7d5e6dd..3b4bb81 100644 --- a/src/config/definitions/defaults-stats.ts +++ b/src/config/definitions/defaults-stats.ts @@ -3,6 +3,7 @@ import { ResolvedConfig } from '../../types.js'; export const STATS_DEFAULT_CONFIG: Pick = { stats: { toggleKey: 'Backquote', + markWatchedKey: 'KeyW', serverPort: 6969, autoStartServer: true, autoOpenBrowser: true, diff --git a/src/config/definitions/options-stats.ts b/src/config/definitions/options-stats.ts index e1f63ff..16657e6 100644 --- a/src/config/definitions/options-stats.ts +++ b/src/config/definitions/options-stats.ts @@ -11,6 +11,12 @@ export function buildStatsConfigOptionRegistry( defaultValue: defaultConfig.stats.toggleKey, description: 'Key code to toggle the stats overlay.', }, + { + path: 'stats.markWatchedKey', + kind: 'string', + defaultValue: defaultConfig.stats.markWatchedKey, + description: 'Key code to mark the current video as watched and advance to the next playlist entry.', + }, { path: 'stats.serverPort', kind: 'number', diff --git a/src/config/resolve/stats.ts b/src/config/resolve/stats.ts index 697ae98..ba2641b 100644 --- a/src/config/resolve/stats.ts +++ b/src/config/resolve/stats.ts @@ -13,6 +13,13 @@ export function applyStatsConfig(context: ResolveContext): void { warn('stats.toggleKey', src.stats.toggleKey, resolved.stats.toggleKey, 'Expected string.'); } + const markWatchedKey = asString(src.stats.markWatchedKey); + if (markWatchedKey !== undefined) { + resolved.stats.markWatchedKey = markWatchedKey; + } else if (src.stats.markWatchedKey !== undefined) { + warn('stats.markWatchedKey', src.stats.markWatchedKey, resolved.stats.markWatchedKey, 'Expected string.'); + } + const serverPort = asNumber(src.stats.serverPort); if (serverPort !== undefined) { resolved.stats.serverPort = serverPort; diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index c0d5047..d49dcaa 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -23,6 +23,7 @@ const SESSION_SUMMARIES = [ cardsMined: 2, lookupCount: 5, lookupHits: 4, + yomitanLookupCount: 5, }, ]; @@ -147,6 +148,45 @@ const WATCH_TIME_PER_ANIME = [ }, ]; +const TRENDS_DASHBOARD = { + activity: { + watchTime: [{ label: 'Mar 1', value: 25 }], + cards: [{ label: 'Mar 1', value: 5 }], + words: [{ label: 'Mar 1', value: 300 }], + sessions: [{ label: 'Mar 1', value: 3 }], + }, + progress: { + watchTime: [{ label: 'Mar 1', value: 25 }], + sessions: [{ label: 'Mar 1', value: 3 }], + words: [{ label: 'Mar 1', value: 300 }], + newWords: [{ label: 'Mar 1', value: 12 }], + cards: [{ label: 'Mar 1', value: 5 }], + episodes: [{ label: 'Mar 1', value: 2 }], + lookups: [{ label: 'Mar 1', value: 15 }], + }, + ratios: { + lookupsPerHundred: [{ label: 'Mar 1', value: 5 }], + }, + animePerDay: { + episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }], + watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }], + cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }], + words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }], + lookups: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 15 }], + lookupsPerHundred: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }], + }, + animeCumulative: { + watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }], + episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }], + cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }], + words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }], + }, + patterns: { + watchTimeByDayOfWeek: [{ label: 'Sun', value: 25 }], + watchTimeByHour: [{ label: '12:00', value: 25 }], + }, +}; + const ANIME_EPISODES = [ { animeId: 1, @@ -238,6 +278,7 @@ function createMockTracker( getEpisodesPerDay: async () => EPISODES_PER_DAY, getNewAnimePerDay: async () => NEW_ANIME_PER_DAY, getWatchTimePerAnime: async () => WATCH_TIME_PER_ANIME, + getTrendsDashboard: async () => TRENDS_DASHBOARD, getStreakCalendar: async () => [ { epochDay: Math.floor(Date.now() / 86_400_000) - 1, totalActiveMin: 30 }, { epochDay: Math.floor(Date.now() / 86_400_000), totalActiveMin: 45 }, @@ -308,6 +349,37 @@ describe('stats server API routes', () => { assert.ok(Array.isArray(body)); }); + it('GET /api/stats/sessions/:id/known-words-timeline preserves line positions and counts known occurrences', async () => { + await withTempDir(async (dir) => { + const cachePath = path.join(dir, 'known-words.json'); + fs.writeFileSync( + cachePath, + JSON.stringify({ + version: 1, + words: ['知る', '猫'], + }), + ); + + const app = createStatsApp( + createMockTracker({ + getSessionWordsByLine: async () => [ + { lineIndex: 1, headword: '知る', occurrenceCount: 2 }, + { lineIndex: 3, headword: '猫', occurrenceCount: 1 }, + { lineIndex: 3, headword: '見る', occurrenceCount: 4 }, + ], + }), + { knownWordCachePath: cachePath }, + ); + + const res = await app.request('/api/stats/sessions/1/known-words-timeline'); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), [ + { linesSeen: 1, knownWordsSeen: 2 }, + { linesSeen: 3, knownWordsSeen: 3 }, + ]); + }); + }); + it('GET /api/stats/vocabulary returns word frequency data', async () => { const app = createStatsApp(createMockTracker()); const res = await app.request('/api/stats/vocabulary'); @@ -429,6 +501,41 @@ describe('stats server API routes', () => { assert.equal(seenLimit, 365); }); + it('GET /api/stats/trends/dashboard returns chart-ready trends data', async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + getTrendsDashboard: async (...args: unknown[]) => { + seenArgs = args; + return TRENDS_DASHBOARD; + }, + }), + ); + + const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.deepEqual(seenArgs, ['90d', 'month']); + assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime); + assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime); + }); + + it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + getTrendsDashboard: async (...args: unknown[]) => { + seenArgs = args; + return TRENDS_DASHBOARD; + }, + }), + ); + + const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year'); + assert.equal(res.status, 200); + assert.deepEqual(seenArgs, ['30d', 'day']); + }); + it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => { let seenArgs: unknown[] = []; const app = createStatsApp( diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index f7a75e3..c458e39 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -17,6 +17,41 @@ function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: num return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); } +function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | 'all' { + return raw === '7d' || raw === '30d' || raw === '90d' || raw === 'all' ? raw : '30d'; +} + +function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' { + return raw === 'month' ? 'month' : 'day'; +} + +/** Load known words cache from disk into a Set. Returns null if unavailable. */ +function loadKnownWordsSet(cachePath: string | undefined): Set | null { + if (!cachePath || !existsSync(cachePath)) return null; + try { + const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as { + version?: number; + words?: string[]; + }; + if (raw.version === 1 && Array.isArray(raw.words)) return new Set(raw.words); + } catch { + /* ignore */ + } + return null; +} + +/** Count how many headwords in the given list are in the known words set. */ +function countKnownWords( + headwords: string[], + knownWordsSet: Set, +): { totalUniqueWords: number; knownWordCount: number } { + let knownWordCount = 0; + for (const hw of headwords) { + if (knownWordsSet.has(hw)) knownWordCount++; + } + return { totalUniqueWords: headwords.length, knownWordCount }; +} + export interface StatsServerConfig { port: number; staticDir: string; // Path to stats/dist/ @@ -139,6 +174,12 @@ export function createStatsApp( return c.json(await tracker.getWatchTimePerAnime(limit)); }); + app.get('/api/stats/trends/dashboard', async (c) => { + const range = parseTrendRange(c.req.query('range')); + const groupBy = parseTrendGroupBy(c.req.query('groupBy')); + return c.json(await tracker.getTrendsDashboard(range, groupBy)); + }); + app.get('/api/stats/sessions', async (c) => { const limit = parseIntQuery(c.req.query('limit'), 50, 500); const sessions = await tracker.getSessionSummaries(limit); @@ -161,6 +202,42 @@ export function createStatsApp( return c.json(events); }); + app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => { + const id = parseIntQuery(c.req.param('id'), 0); + if (id <= 0) return c.json([], 400); + + const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); + if (!knownWordsSet) return c.json([]); + + // Get per-line word occurrences for the session. + const wordsByLine = await tracker.getSessionWordsByLine(id); + + // Build cumulative known-word occurrence count per recorded line index. + // The stats UI uses line-count progress to align this series with the session + // timeline, so preserve the stored line position rather than compressing gaps. + const lineGroups = new Map(); + for (const row of wordsByLine) { + if (!knownWordsSet.has(row.headword)) { + continue; + } + lineGroups.set(row.lineIndex, (lineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount); + } + + const sortedLineIndices = [...lineGroups.keys()].sort((a, b) => a - b); + let knownWordsSeen = 0; + const knownByLinesSeen: Array<{ linesSeen: number; knownWordsSeen: number }> = []; + + for (const lineIdx of sortedLineIndices) { + knownWordsSeen += lineGroups.get(lineIdx)!; + knownByLinesSeen.push({ + linesSeen: lineIdx, + knownWordsSeen, + }); + } + + return c.json(knownByLinesSeen); + }); + app.get('/api/stats/vocabulary', async (c) => { const limit = parseIntQuery(c.req.query('limit'), 100, 500); const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean); @@ -274,6 +351,16 @@ export function createStatsApp( return c.json({ ok: true }); }); + app.delete('/api/stats/sessions', async (c) => { + const body = await c.req.json().catch(() => null); + const ids = Array.isArray(body?.sessionIds) + ? body.sessionIds.filter((id: unknown) => typeof id === 'number' && id > 0) + : []; + if (ids.length === 0) return c.body(null, 400); + await tracker.deleteSessions(ids); + return c.json({ ok: true }); + }); + app.delete('/api/stats/sessions/:sessionId', async (c) => { const sessionId = parseIntQuery(c.req.param('sessionId'), 0); if (sessionId <= 0) return c.body(null, 400); @@ -320,18 +407,34 @@ export function createStatsApp( }); app.get('/api/stats/known-words', (c) => { - const cachePath = options?.knownWordCachePath; - if (!cachePath || !existsSync(cachePath)) return c.json([]); - try { - const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as { - version?: number; - words?: string[]; - }; - if (raw.version === 1 && Array.isArray(raw.words)) return c.json(raw.words); - } catch { - /* ignore */ - } - return c.json([]); + const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); + if (!knownWordsSet) return c.json([]); + return c.json([...knownWordsSet]); + }); + + app.get('/api/stats/known-words-summary', async (c) => { + const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); + if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }); + const headwords = await tracker.getAllDistinctHeadwords(); + return c.json(countKnownWords(headwords, knownWordsSet)); + }); + + app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => { + const animeId = parseIntQuery(c.req.param('animeId'), 0); + if (animeId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400); + const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); + if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }); + const headwords = await tracker.getAnimeDistinctHeadwords(animeId); + return c.json(countKnownWords(headwords, knownWordsSet)); + }); + + app.get('/api/stats/media/:videoId/known-words-summary', async (c) => { + const videoId = parseIntQuery(c.req.param('videoId'), 0); + if (videoId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400); + const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); + if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }); + const headwords = await tracker.getMediaDistinctHeadwords(videoId); + return c.json(countKnownWords(headwords, knownWordsSet)); }); app.patch('/api/stats/anime/:animeId/anilist', async (c) => { diff --git a/src/main.ts b/src/main.ts index d09b32c..fb5fda0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -334,6 +334,13 @@ import { createRunStatsCliCommandHandler, writeStatsCliCommandResponse, } from './main/runtime/stats-cli-command'; +import { + isBackgroundStatsServerProcessAlive, + readBackgroundStatsServerState, + removeBackgroundStatsServerState, + resolveBackgroundStatsServerUrl, + writeBackgroundStatsServerState, +} from './main/runtime/stats-daemon'; import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; import { @@ -366,6 +373,7 @@ import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle'; import { registerSecondInstanceHandlerEarly, requestSingleInstanceLockEarly, + shouldBypassSingleInstanceLockForArgv, } from './main/early-single-instance'; import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command'; import { registerIpcRuntimeServices } from './main/ipc-runtime'; @@ -600,7 +608,10 @@ const appLogger = { }; const runtimeRegistry = createMainRuntimeRegistry(); const appLifecycleApp = { - requestSingleInstanceLock: () => requestSingleInstanceLockEarly(app), + requestSingleInstanceLock: () => + shouldBypassSingleInstanceLockForArgv(process.argv) + ? true + : requestSingleInstanceLockEarly(app), quit: () => app.quit(), on: (event: string, listener: (...args: unknown[]) => void) => { if (event === 'second-instance') { @@ -633,6 +644,35 @@ app.setPath('userData', USER_DATA_PATH); let forceQuitTimer: ReturnType | null = null; let statsServer: ReturnType | null = null; +const statsDaemonStatePath = path.join(USER_DATA_PATH, 'stats-daemon.json'); + +function readLiveBackgroundStatsDaemonState(): { + pid: number; + port: number; + startedAtMs: number; +} | null { + const state = readBackgroundStatsServerState(statsDaemonStatePath); + if (!state) { + removeBackgroundStatsServerState(statsDaemonStatePath); + return null; + } + if (state.pid === process.pid && !statsServer) { + removeBackgroundStatsServerState(statsDaemonStatePath); + return null; + } + if (!isBackgroundStatsServerProcessAlive(state.pid)) { + removeBackgroundStatsServerState(statsDaemonStatePath); + return null; + } + return state; +} + +function clearOwnedBackgroundStatsDaemonState(): void { + const state = readBackgroundStatsServerState(statsDaemonStatePath); + if (state?.pid === process.pid) { + removeBackgroundStatsServerState(statsDaemonStatePath); + } +} function stopStatsServer(): void { if (!statsServer) { @@ -640,6 +680,7 @@ function stopStatsServer(): void { } statsServer.close(); statsServer = null; + clearOwnedBackgroundStatsDaemonState(); } function requestAppQuit(): void { @@ -2524,6 +2565,10 @@ const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); const statsPreloadPath = path.join(__dirname, 'preload-stats.js'); const ensureStatsServerStarted = (): string => { + const liveDaemon = readLiveBackgroundStatsDaemonState(); + if (liveDaemon && liveDaemon.pid !== process.pid) { + return resolveBackgroundStatsServerUrl(liveDaemon); + } const tracker = appState.immersionTracker; if (!tracker) { throw new Error('Immersion tracker failed to initialize.'); @@ -2567,6 +2612,73 @@ const ensureStatsServerStarted = (): string => { return `http://127.0.0.1:${getResolvedConfig().stats.serverPort}`; }; +const ensureBackgroundStatsServerStarted = (): { + url: string; + runningInCurrentProcess: boolean; +} => { + const liveDaemon = readLiveBackgroundStatsDaemonState(); + if (liveDaemon && liveDaemon.pid !== process.pid) { + return { + url: resolveBackgroundStatsServerUrl(liveDaemon), + runningInCurrentProcess: false, + }; + } + + appState.statsStartupInProgress = true; + try { + ensureImmersionTrackerStarted(); + } finally { + appState.statsStartupInProgress = false; + } + + const port = getResolvedConfig().stats.serverPort; + const url = ensureStatsServerStarted(); + writeBackgroundStatsServerState(statsDaemonStatePath, { + pid: process.pid, + port, + startedAtMs: Date.now(), + }); + return { url, runningInCurrentProcess: true }; +}; + +const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => { + const state = readBackgroundStatsServerState(statsDaemonStatePath); + if (!state) { + removeBackgroundStatsServerState(statsDaemonStatePath); + return { ok: true, stale: true }; + } + if (!isBackgroundStatsServerProcessAlive(state.pid)) { + removeBackgroundStatsServerState(statsDaemonStatePath); + return { ok: true, stale: true }; + } + + try { + process.kill(state.pid, 'SIGTERM'); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') { + removeBackgroundStatsServerState(statsDaemonStatePath); + return { ok: true, stale: true }; + } + if ((error as NodeJS.ErrnoException)?.code === 'EPERM') { + throw new Error( + `Insufficient permissions to stop background stats server (pid ${state.pid}).`, + ); + } + throw error; + } + + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (!isBackgroundStatsServerProcessAlive(state.pid)) { + removeBackgroundStatsServerState(statsDaemonStatePath); + return { ok: true, stale: false }; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + throw new Error('Timed out stopping background stats server.'); +}; + const resolveLegacyVocabularyPos = async (row: { headword: string; word: string; @@ -2675,6 +2787,8 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({ }, getImmersionTracker: () => appState.immersionTracker, ensureStatsServerStarted: () => ensureStatsServerStarted(), + ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(), + stopBackgroundStatsServer: () => stopBackgroundStatsServer(), openExternal: (url: string) => shell.openExternal(url), writeResponse: (responsePath, payload) => { writeStatsCliCommandResponse(responsePath, payload); @@ -2837,7 +2951,12 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ initializeOverlayRuntime: () => initializeOverlayRuntime(), handleInitialArgs: () => handleInitialArgs(), shouldUseMinimalStartup: () => - Boolean(appState.initialArgs?.stats && appState.initialArgs?.statsCleanup), + Boolean( + appState.initialArgs?.stats && + (appState.initialArgs?.statsCleanup || + appState.initialArgs?.statsBackground || + appState.initialArgs?.statsStop), + ), shouldSkipHeavyStartup: () => Boolean( appState.initialArgs && diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 49abdaa..1ae07d0 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -73,6 +73,7 @@ export interface MainIpcRuntimeServiceDepsParams { getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey']; + getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig']; saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference']; @@ -220,6 +221,7 @@ export function createMainIpcRuntimeServiceDeps( getKeybindings: params.getKeybindings, getConfiguredShortcuts: params.getConfiguredShortcuts, getStatsToggleKey: params.getStatsToggleKey, + getMarkWatchedKey: params.getMarkWatchedKey, getControllerConfig: params.getControllerConfig, saveControllerConfig: params.saveControllerConfig, saveControllerPreference: params.saveControllerPreference, diff --git a/src/main/early-single-instance.test.ts b/src/main/early-single-instance.test.ts index 48123e3..0d0624e 100644 --- a/src/main/early-single-instance.test.ts +++ b/src/main/early-single-instance.test.ts @@ -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); +}); diff --git a/src/main/early-single-instance.ts b/src/main/early-single-instance.ts index 0f6d2b9..5c748a8 100644 --- a/src/main/early-single-instance.ts +++ b/src/main/early-single-instance.ts @@ -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[][] = []; diff --git a/src/main/runtime/stats-cli-command.test.ts b/src/main/runtime/stats-cli-command.test.ts index ebbddbc..cb0d641 100644 --- a/src/main/runtime/stats-cli-command.test.ts +++ b/src/main/runtime/stats-cli-command.test.ts @@ -27,6 +27,11 @@ function makeHandler( 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}`); }, @@ -70,6 +75,88 @@ test('stats cli command starts tracker, server, browser, and writes success resp ]); }); +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: () => ({ diff --git a/src/main/runtime/stats-cli-command.ts b/src/main/runtime/stats-cli-command.ts index 7e0dd00..3ea9190 100644 --- a/src/main/runtime/stats-cli-command.ts +++ b/src/main/runtime/stats-cli-command.ts @@ -22,6 +22,16 @@ export type StatsCliCommandResponse = { error?: string; }; +type BackgroundStatsStartResult = { + url: string; + runningInCurrentProcess: boolean; +}; + +type BackgroundStatsStopResult = { + ok: boolean; + stale: boolean; +}; + export function writeStatsCliCommandResponse( responsePath: string, payload: StatsCliCommandResponse, @@ -39,6 +49,8 @@ export function createRunStatsCliCommandHandler(deps: { rebuildLifetimeSummaries?: () => Promise; } | null; ensureStatsServerStarted: () => string; + ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult; + stopBackgroundStatsServer: () => Promise | BackgroundStatsStopResult; openExternal: (url: string) => Promise; writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void; exitAppWithCode: (code: number) => void; @@ -61,16 +73,45 @@ export function createRunStatsCliCommandHandler(deps: { return async ( args: Pick< CliArgs, - 'statsResponsePath' | 'statsCleanup' | 'statsCleanupVocab' | 'statsCleanupLifetime' + | 'statsResponsePath' + | 'statsBackground' + | 'statsStop' + | 'statsCleanup' + | 'statsCleanupVocab' + | 'statsCleanupLifetime' >, source: CliCommandSource, ): Promise => { 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) { diff --git a/src/main/runtime/stats-daemon.ts b/src/main/runtime/stats-daemon.ts new file mode 100644 index 0000000..493c216 --- /dev/null +++ b/src/main/runtime/stats-daemon.ts @@ -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; + 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, +): string { + return `http://127.0.0.1:${state.port}`; +}