mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
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.
This commit is contained in:
@@ -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<typeof runStatsCommand>[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 () => {
|
test('stats command returns after startup response even if app process stays running', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args.stats = true;
|
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;
|
const final = await statsCommand;
|
||||||
assert.equal(final, true);
|
assert.equal(final, true);
|
||||||
assert.deepEqual(forwarded, [
|
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 () => {
|
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args.stats = true;
|
context.args.stats = true;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { runAppCommandAttached } from '../mpv.js';
|
import { launchAppCommandDetached, runAppCommandAttached } from '../mpv.js';
|
||||||
import { sleep } from '../util.js';
|
import { sleep } from '../util.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
@@ -20,17 +20,25 @@ type StatsCommandDeps = {
|
|||||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||||
label: string,
|
label: string,
|
||||||
) => Promise<number>;
|
) => Promise<number>;
|
||||||
|
launchAppCommandDetached: (
|
||||||
|
appPath: string,
|
||||||
|
appArgs: string[],
|
||||||
|
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||||
|
label: string,
|
||||||
|
) => void;
|
||||||
waitForStatsResponse: (responsePath: string) => Promise<StatsCommandResponse>;
|
waitForStatsResponse: (responsePath: string) => Promise<StatsCommandResponse>;
|
||||||
removeDir: (targetPath: string) => void;
|
removeDir: (targetPath: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATS_STARTUP_RESPONSE_TIMEOUT_MS = 8_000;
|
const STATS_STARTUP_RESPONSE_TIMEOUT_MS = 12_000;
|
||||||
|
|
||||||
const defaultDeps: StatsCommandDeps = {
|
const defaultDeps: StatsCommandDeps = {
|
||||||
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
||||||
joinPath: (...parts) => path.join(...parts),
|
joinPath: (...parts) => path.join(...parts),
|
||||||
runAppCommandAttached: (appPath, appArgs, logLevel, label) =>
|
runAppCommandAttached: (appPath, appArgs, logLevel, label) =>
|
||||||
runAppCommandAttached(appPath, appArgs, logLevel, label),
|
runAppCommandAttached(appPath, appArgs, logLevel, label),
|
||||||
|
launchAppCommandDetached: (appPath, appArgs, logLevel, label) =>
|
||||||
|
launchAppCommandDetached(appPath, appArgs, logLevel, label),
|
||||||
waitForStatsResponse: async (responsePath) => {
|
waitForStatsResponse: async (responsePath) => {
|
||||||
const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS;
|
const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
@@ -55,18 +63,25 @@ const defaultDeps: StatsCommandDeps = {
|
|||||||
|
|
||||||
export async function runStatsCommand(
|
export async function runStatsCommand(
|
||||||
context: LauncherCommandContext,
|
context: LauncherCommandContext,
|
||||||
deps: StatsCommandDeps = defaultDeps,
|
deps: Partial<StatsCommandDeps> = {},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const resolvedDeps: StatsCommandDeps = { ...defaultDeps, ...deps };
|
||||||
const { args, appPath } = context;
|
const { args, appPath } = context;
|
||||||
if (!args.stats || !appPath) {
|
if (!args.stats || !appPath) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempDir = deps.createTempDir('subminer-stats-');
|
const tempDir = resolvedDeps.createTempDir('subminer-stats-');
|
||||||
const responsePath = deps.joinPath(tempDir, 'response.json');
|
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const forwarded = ['--stats', '--stats-response-path', responsePath];
|
const forwarded = ['--stats', '--stats-response-path', responsePath];
|
||||||
|
if (args.statsBackground) {
|
||||||
|
forwarded.push('--stats-background');
|
||||||
|
}
|
||||||
|
if (args.statsStop) {
|
||||||
|
forwarded.push('--stats-stop');
|
||||||
|
}
|
||||||
if (args.statsCleanup) {
|
if (args.statsCleanup) {
|
||||||
forwarded.push('--stats-cleanup');
|
forwarded.push('--stats-cleanup');
|
||||||
}
|
}
|
||||||
@@ -79,11 +94,32 @@ export async function runStatsCommand(
|
|||||||
if (args.logLevel !== 'info') {
|
if (args.logLevel !== 'info') {
|
||||||
forwarded.push('--log-level', args.logLevel);
|
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([
|
const startupResult = await Promise.race([
|
||||||
deps
|
resolvedDeps
|
||||||
.waitForStatsResponse(responsePath)
|
.waitForStatsResponse(responsePath)
|
||||||
.then((response) => ({ kind: 'response' as const, response })),
|
.then((response) => ({ kind: 'response' as const, response })),
|
||||||
attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })),
|
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}).`,
|
`Stats app exited before startup response (status ${startupResult.status}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const response = await deps.waitForStatsResponse(responsePath);
|
const response = await resolvedDeps.waitForStatsResponse(responsePath);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(response.error || 'Stats dashboard failed to start.');
|
throw new Error(response.error || 'Stats dashboard failed to start.');
|
||||||
}
|
}
|
||||||
@@ -109,7 +145,7 @@ export async function runStatsCommand(
|
|||||||
const attachedExitPromiseCleanup = attachedExitPromise;
|
const attachedExitPromiseCleanup = attachedExitPromise;
|
||||||
|
|
||||||
const startupResult = await Promise.race([
|
const startupResult = await Promise.race([
|
||||||
deps
|
resolvedDeps
|
||||||
.waitForStatsResponse(responsePath)
|
.waitForStatsResponse(responsePath)
|
||||||
.then((response) => ({ kind: 'response' as const, response })),
|
.then((response) => ({ kind: 'response' as const, response })),
|
||||||
attachedExitPromiseCleanup.then((status) => ({ kind: 'exit' as const, status })),
|
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}).`,
|
`Stats app exited before startup response (status ${startupResult.status}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const response = await deps.waitForStatsResponse(responsePath);
|
const response = await resolvedDeps.waitForStatsResponse(responsePath);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(response.error || 'Stats dashboard failed to start.');
|
throw new Error(response.error || 'Stats dashboard failed to start.');
|
||||||
}
|
}
|
||||||
@@ -135,6 +171,6 @@ export async function runStatsCommand(
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
deps.removeDir(tempDir);
|
resolvedDeps.removeDir(tempDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
|||||||
jellyfinDiscovery: false,
|
jellyfinDiscovery: false,
|
||||||
dictionary: false,
|
dictionary: false,
|
||||||
stats: false,
|
stats: false,
|
||||||
|
statsBackground: false,
|
||||||
|
statsStop: false,
|
||||||
statsCleanup: false,
|
statsCleanup: false,
|
||||||
statsCleanupVocab: false,
|
statsCleanupVocab: false,
|
||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
@@ -193,6 +195,8 @@ export function applyRootOptionsToArgs(
|
|||||||
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||||
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
||||||
if (invocations.statsTriggered) parsed.stats = 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.statsCleanup) parsed.statsCleanup = true;
|
||||||
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
|
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
|
||||||
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
|
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface CliInvocations {
|
|||||||
dictionaryTarget: string | null;
|
dictionaryTarget: string | null;
|
||||||
dictionaryLogLevel: string | null;
|
dictionaryLogLevel: string | null;
|
||||||
statsTriggered: boolean;
|
statsTriggered: boolean;
|
||||||
|
statsBackground: boolean;
|
||||||
|
statsStop: boolean;
|
||||||
statsCleanup: boolean;
|
statsCleanup: boolean;
|
||||||
statsCleanupVocab: boolean;
|
statsCleanupVocab: boolean;
|
||||||
statsCleanupLifetime: boolean;
|
statsCleanupLifetime: boolean;
|
||||||
@@ -144,6 +146,8 @@ export function parseCliPrograms(
|
|||||||
let dictionaryTarget: string | null = null;
|
let dictionaryTarget: string | null = null;
|
||||||
let dictionaryLogLevel: string | null = null;
|
let dictionaryLogLevel: string | null = null;
|
||||||
let statsTriggered = false;
|
let statsTriggered = false;
|
||||||
|
let statsBackground = false;
|
||||||
|
let statsStop = false;
|
||||||
let statsCleanup = false;
|
let statsCleanup = false;
|
||||||
let statsCleanupVocab = false;
|
let statsCleanupVocab = false;
|
||||||
let statsCleanupLifetime = false;
|
let statsCleanupLifetime = false;
|
||||||
@@ -256,12 +260,22 @@ export function parseCliPrograms(
|
|||||||
.command('stats')
|
.command('stats')
|
||||||
.description('Launch the local immersion stats dashboard')
|
.description('Launch the local immersion stats dashboard')
|
||||||
.argument('[action]', 'cleanup|rebuild|backfill')
|
.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('-v, --vocab', 'Clean vocabulary rows in the stats database')
|
||||||
.option('-l, --lifetime', 'Rebuild lifetime summary rows from retained data')
|
.option('-l, --lifetime', 'Rebuild lifetime summary rows from retained data')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||||
statsTriggered = true;
|
statsTriggered = true;
|
||||||
const normalizedAction = (action || '').toLowerCase();
|
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') {
|
if (normalizedAction === 'cleanup') {
|
||||||
statsCleanup = true;
|
statsCleanup = true;
|
||||||
statsCleanupLifetime = options.lifetime === true;
|
statsCleanupLifetime = options.lifetime === true;
|
||||||
@@ -353,6 +367,8 @@ export function parseCliPrograms(
|
|||||||
dictionaryTarget,
|
dictionaryTarget,
|
||||||
dictionaryLogLevel,
|
dictionaryLogLevel,
|
||||||
statsTriggered,
|
statsTriggered,
|
||||||
|
statsBackground,
|
||||||
|
statsStop,
|
||||||
statsCleanup,
|
statsCleanup,
|
||||||
statsCleanupVocab,
|
statsCleanupVocab,
|
||||||
statsCleanupLifetime,
|
statsCleanupLifetime,
|
||||||
|
|||||||
@@ -66,6 +66,28 @@ test('parseArgs maps stats command and log-level override', () => {
|
|||||||
assert.equal(parsed.logLevel, 'debug');
|
assert.equal(parsed.logLevel, 'debug');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs maps stats background flag', () => {
|
||||||
|
const parsed = parseArgs(['stats', '-b'], 'subminer', {}) as ReturnType<typeof parseArgs> & {
|
||||||
|
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<typeof parseArgs> & {
|
||||||
|
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', () => {
|
test('parseArgs maps stats cleanup to vocab mode by default', () => {
|
||||||
const parsed = parseArgs(['stats', 'cleanup'], 'subminer', {});
|
const parsed = parseArgs(['stats', 'cleanup'], 'subminer', {});
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export interface Args {
|
|||||||
jellyfinDiscovery: boolean;
|
jellyfinDiscovery: boolean;
|
||||||
dictionary: boolean;
|
dictionary: boolean;
|
||||||
stats: boolean;
|
stats: boolean;
|
||||||
|
statsBackground?: boolean;
|
||||||
|
statsStop?: boolean;
|
||||||
statsCleanup?: boolean;
|
statsCleanup?: boolean;
|
||||||
statsCleanupVocab?: boolean;
|
statsCleanupVocab?: boolean;
|
||||||
statsCleanupLifetime?: boolean;
|
statsCleanupLifetime?: boolean;
|
||||||
|
|||||||
@@ -157,6 +157,26 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(hasExplicitCommand(stats), true);
|
assert.equal(hasExplicitCommand(stats), true);
|
||||||
assert.equal(shouldStartApp(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([
|
const statsLifetimeRebuild = parseArgs([
|
||||||
'--stats',
|
'--stats',
|
||||||
'--stats-cleanup',
|
'--stats-cleanup',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface CliArgs {
|
|||||||
dictionary: boolean;
|
dictionary: boolean;
|
||||||
dictionaryTarget?: string;
|
dictionaryTarget?: string;
|
||||||
stats: boolean;
|
stats: boolean;
|
||||||
|
statsBackground?: boolean;
|
||||||
|
statsStop?: boolean;
|
||||||
statsCleanup?: boolean;
|
statsCleanup?: boolean;
|
||||||
statsCleanupVocab?: boolean;
|
statsCleanupVocab?: boolean;
|
||||||
statsCleanupLifetime?: boolean;
|
statsCleanupLifetime?: boolean;
|
||||||
@@ -103,6 +105,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
anilistRetryQueue: false,
|
anilistRetryQueue: false,
|
||||||
dictionary: false,
|
dictionary: false,
|
||||||
stats: false,
|
stats: false,
|
||||||
|
statsBackground: false,
|
||||||
|
statsStop: false,
|
||||||
statsCleanup: false,
|
statsCleanup: false,
|
||||||
statsCleanupVocab: false,
|
statsCleanupVocab: false,
|
||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
@@ -172,7 +176,13 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
const value = readValue(argv[i + 1]);
|
const value = readValue(argv[i + 1]);
|
||||||
if (value) args.dictionaryTarget = value;
|
if (value) args.dictionaryTarget = value;
|
||||||
} else if (arg === '--stats') args.stats = true;
|
} 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-vocab') args.statsCleanupVocab = true;
|
||||||
else if (arg === '--stats-cleanup-lifetime') args.statsCleanupLifetime = true;
|
else if (arg === '--stats-cleanup-lifetime') args.statsCleanupLifetime = true;
|
||||||
else if (arg.startsWith('--stats-response-path=')) {
|
else if (arg.startsWith('--stats-response-path=')) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ResolvedConfig } from '../../types.js';
|
|||||||
export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = {
|
export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = {
|
||||||
stats: {
|
stats: {
|
||||||
toggleKey: 'Backquote',
|
toggleKey: 'Backquote',
|
||||||
|
markWatchedKey: 'KeyW',
|
||||||
serverPort: 6969,
|
serverPort: 6969,
|
||||||
autoStartServer: true,
|
autoStartServer: true,
|
||||||
autoOpenBrowser: true,
|
autoOpenBrowser: true,
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export function buildStatsConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.stats.toggleKey,
|
defaultValue: defaultConfig.stats.toggleKey,
|
||||||
description: 'Key code to toggle the stats overlay.',
|
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',
|
path: 'stats.serverPort',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export function applyStatsConfig(context: ResolveContext): void {
|
|||||||
warn('stats.toggleKey', src.stats.toggleKey, resolved.stats.toggleKey, 'Expected string.');
|
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);
|
const serverPort = asNumber(src.stats.serverPort);
|
||||||
if (serverPort !== undefined) {
|
if (serverPort !== undefined) {
|
||||||
resolved.stats.serverPort = serverPort;
|
resolved.stats.serverPort = serverPort;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const SESSION_SUMMARIES = [
|
|||||||
cardsMined: 2,
|
cardsMined: 2,
|
||||||
lookupCount: 5,
|
lookupCount: 5,
|
||||||
lookupHits: 4,
|
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 = [
|
const ANIME_EPISODES = [
|
||||||
{
|
{
|
||||||
animeId: 1,
|
animeId: 1,
|
||||||
@@ -238,6 +278,7 @@ function createMockTracker(
|
|||||||
getEpisodesPerDay: async () => EPISODES_PER_DAY,
|
getEpisodesPerDay: async () => EPISODES_PER_DAY,
|
||||||
getNewAnimePerDay: async () => NEW_ANIME_PER_DAY,
|
getNewAnimePerDay: async () => NEW_ANIME_PER_DAY,
|
||||||
getWatchTimePerAnime: async () => WATCH_TIME_PER_ANIME,
|
getWatchTimePerAnime: async () => WATCH_TIME_PER_ANIME,
|
||||||
|
getTrendsDashboard: async () => TRENDS_DASHBOARD,
|
||||||
getStreakCalendar: async () => [
|
getStreakCalendar: async () => [
|
||||||
{ epochDay: Math.floor(Date.now() / 86_400_000) - 1, totalActiveMin: 30 },
|
{ epochDay: Math.floor(Date.now() / 86_400_000) - 1, totalActiveMin: 30 },
|
||||||
{ epochDay: Math.floor(Date.now() / 86_400_000), totalActiveMin: 45 },
|
{ epochDay: Math.floor(Date.now() / 86_400_000), totalActiveMin: 45 },
|
||||||
@@ -308,6 +349,37 @@ describe('stats server API routes', () => {
|
|||||||
assert.ok(Array.isArray(body));
|
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 () => {
|
it('GET /api/stats/vocabulary returns word frequency data', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/vocabulary');
|
const res = await app.request('/api/stats/vocabulary');
|
||||||
@@ -429,6 +501,41 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(seenLimit, 365);
|
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 () => {
|
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
|
||||||
let seenArgs: unknown[] = [];
|
let seenArgs: unknown[] = [];
|
||||||
const app = createStatsApp(
|
const app = createStatsApp(
|
||||||
|
|||||||
@@ -17,6 +17,41 @@ function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: num
|
|||||||
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
|
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<string> | 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<string>,
|
||||||
|
): { 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 {
|
export interface StatsServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
staticDir: string; // Path to stats/dist/
|
staticDir: string; // Path to stats/dist/
|
||||||
@@ -139,6 +174,12 @@ export function createStatsApp(
|
|||||||
return c.json(await tracker.getWatchTimePerAnime(limit));
|
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) => {
|
app.get('/api/stats/sessions', async (c) => {
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||||
const sessions = await tracker.getSessionSummaries(limit);
|
const sessions = await tracker.getSessionSummaries(limit);
|
||||||
@@ -161,6 +202,42 @@ export function createStatsApp(
|
|||||||
return c.json(events);
|
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<number, number>();
|
||||||
|
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) => {
|
app.get('/api/stats/vocabulary', async (c) => {
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||||
const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean);
|
const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean);
|
||||||
@@ -274,6 +351,16 @@ export function createStatsApp(
|
|||||||
return c.json({ ok: true });
|
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) => {
|
app.delete('/api/stats/sessions/:sessionId', async (c) => {
|
||||||
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
|
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
|
||||||
if (sessionId <= 0) return c.body(null, 400);
|
if (sessionId <= 0) return c.body(null, 400);
|
||||||
@@ -320,18 +407,34 @@ export function createStatsApp(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/known-words', (c) => {
|
app.get('/api/stats/known-words', (c) => {
|
||||||
const cachePath = options?.knownWordCachePath;
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||||
if (!cachePath || !existsSync(cachePath)) return c.json([]);
|
if (!knownWordsSet) return c.json([]);
|
||||||
try {
|
return c.json([...knownWordsSet]);
|
||||||
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as {
|
});
|
||||||
version?: number;
|
|
||||||
words?: string[];
|
app.get('/api/stats/known-words-summary', async (c) => {
|
||||||
};
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||||
if (raw.version === 1 && Array.isArray(raw.words)) return c.json(raw.words);
|
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
|
||||||
} catch {
|
const headwords = await tracker.getAllDistinctHeadwords();
|
||||||
/* ignore */
|
return c.json(countKnownWords(headwords, knownWordsSet));
|
||||||
}
|
});
|
||||||
return c.json([]);
|
|
||||||
|
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) => {
|
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
|
||||||
|
|||||||
123
src/main.ts
123
src/main.ts
@@ -334,6 +334,13 @@ import {
|
|||||||
createRunStatsCliCommandHandler,
|
createRunStatsCliCommandHandler,
|
||||||
writeStatsCliCommandResponse,
|
writeStatsCliCommandResponse,
|
||||||
} from './main/runtime/stats-cli-command';
|
} 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 { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos';
|
||||||
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
||||||
import {
|
import {
|
||||||
@@ -366,6 +373,7 @@ import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
|
|||||||
import {
|
import {
|
||||||
registerSecondInstanceHandlerEarly,
|
registerSecondInstanceHandlerEarly,
|
||||||
requestSingleInstanceLockEarly,
|
requestSingleInstanceLockEarly,
|
||||||
|
shouldBypassSingleInstanceLockForArgv,
|
||||||
} from './main/early-single-instance';
|
} from './main/early-single-instance';
|
||||||
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
||||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||||
@@ -600,7 +608,10 @@ const appLogger = {
|
|||||||
};
|
};
|
||||||
const runtimeRegistry = createMainRuntimeRegistry();
|
const runtimeRegistry = createMainRuntimeRegistry();
|
||||||
const appLifecycleApp = {
|
const appLifecycleApp = {
|
||||||
requestSingleInstanceLock: () => requestSingleInstanceLockEarly(app),
|
requestSingleInstanceLock: () =>
|
||||||
|
shouldBypassSingleInstanceLockForArgv(process.argv)
|
||||||
|
? true
|
||||||
|
: requestSingleInstanceLockEarly(app),
|
||||||
quit: () => app.quit(),
|
quit: () => app.quit(),
|
||||||
on: (event: string, listener: (...args: unknown[]) => void) => {
|
on: (event: string, listener: (...args: unknown[]) => void) => {
|
||||||
if (event === 'second-instance') {
|
if (event === 'second-instance') {
|
||||||
@@ -633,6 +644,35 @@ app.setPath('userData', USER_DATA_PATH);
|
|||||||
|
|
||||||
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let statsServer: ReturnType<typeof startStatsServer> | null = null;
|
let statsServer: ReturnType<typeof startStatsServer> | 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 {
|
function stopStatsServer(): void {
|
||||||
if (!statsServer) {
|
if (!statsServer) {
|
||||||
@@ -640,6 +680,7 @@ function stopStatsServer(): void {
|
|||||||
}
|
}
|
||||||
statsServer.close();
|
statsServer.close();
|
||||||
statsServer = null;
|
statsServer = null;
|
||||||
|
clearOwnedBackgroundStatsDaemonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestAppQuit(): void {
|
function requestAppQuit(): void {
|
||||||
@@ -2524,6 +2565,10 @@ const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
|
|||||||
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
|
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
|
||||||
|
|
||||||
const ensureStatsServerStarted = (): string => {
|
const ensureStatsServerStarted = (): string => {
|
||||||
|
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
||||||
|
if (liveDaemon && liveDaemon.pid !== process.pid) {
|
||||||
|
return resolveBackgroundStatsServerUrl(liveDaemon);
|
||||||
|
}
|
||||||
const tracker = appState.immersionTracker;
|
const tracker = appState.immersionTracker;
|
||||||
if (!tracker) {
|
if (!tracker) {
|
||||||
throw new Error('Immersion tracker failed to initialize.');
|
throw new Error('Immersion tracker failed to initialize.');
|
||||||
@@ -2567,6 +2612,73 @@ const ensureStatsServerStarted = (): string => {
|
|||||||
return `http://127.0.0.1:${getResolvedConfig().stats.serverPort}`;
|
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: {
|
const resolveLegacyVocabularyPos = async (row: {
|
||||||
headword: string;
|
headword: string;
|
||||||
word: string;
|
word: string;
|
||||||
@@ -2675,6 +2787,8 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
|
|||||||
},
|
},
|
||||||
getImmersionTracker: () => appState.immersionTracker,
|
getImmersionTracker: () => appState.immersionTracker,
|
||||||
ensureStatsServerStarted: () => ensureStatsServerStarted(),
|
ensureStatsServerStarted: () => ensureStatsServerStarted(),
|
||||||
|
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
|
||||||
|
stopBackgroundStatsServer: () => stopBackgroundStatsServer(),
|
||||||
openExternal: (url: string) => shell.openExternal(url),
|
openExternal: (url: string) => shell.openExternal(url),
|
||||||
writeResponse: (responsePath, payload) => {
|
writeResponse: (responsePath, payload) => {
|
||||||
writeStatsCliCommandResponse(responsePath, payload);
|
writeStatsCliCommandResponse(responsePath, payload);
|
||||||
@@ -2837,7 +2951,12 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||||
handleInitialArgs: () => handleInitialArgs(),
|
handleInitialArgs: () => handleInitialArgs(),
|
||||||
shouldUseMinimalStartup: () =>
|
shouldUseMinimalStartup: () =>
|
||||||
Boolean(appState.initialArgs?.stats && appState.initialArgs?.statsCleanup),
|
Boolean(
|
||||||
|
appState.initialArgs?.stats &&
|
||||||
|
(appState.initialArgs?.statsCleanup ||
|
||||||
|
appState.initialArgs?.statsBackground ||
|
||||||
|
appState.initialArgs?.statsStop),
|
||||||
|
),
|
||||||
shouldSkipHeavyStartup: () =>
|
shouldSkipHeavyStartup: () =>
|
||||||
Boolean(
|
Boolean(
|
||||||
appState.initialArgs &&
|
appState.initialArgs &&
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||||
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
|
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
|
||||||
|
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
|
||||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||||
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
|
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
|
||||||
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
||||||
@@ -220,6 +221,7 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
getKeybindings: params.getKeybindings,
|
getKeybindings: params.getKeybindings,
|
||||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||||
getStatsToggleKey: params.getStatsToggleKey,
|
getStatsToggleKey: params.getStatsToggleKey,
|
||||||
|
getMarkWatchedKey: params.getMarkWatchedKey,
|
||||||
getControllerConfig: params.getControllerConfig,
|
getControllerConfig: params.getControllerConfig,
|
||||||
saveControllerConfig: params.saveControllerConfig,
|
saveControllerConfig: params.saveControllerConfig,
|
||||||
saveControllerPreference: params.saveControllerPreference,
|
saveControllerPreference: params.saveControllerPreference,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
requestSingleInstanceLockEarly,
|
requestSingleInstanceLockEarly,
|
||||||
resetEarlySingleInstanceStateForTests,
|
resetEarlySingleInstanceStateForTests,
|
||||||
} from './early-single-instance';
|
} from './early-single-instance';
|
||||||
|
import * as earlySingleInstance from './early-single-instance';
|
||||||
|
|
||||||
function createFakeApp(lockValue = true) {
|
function createFakeApp(lockValue = true) {
|
||||||
let requestCalls = 0;
|
let requestCalls = 0;
|
||||||
@@ -54,3 +55,16 @@ test('registerSecondInstanceHandlerEarly replays queued argv and forwards new ev
|
|||||||
['SubMiner.exe', '--start', '--show-visible-overlay'],
|
['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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ interface ElectronSecondInstanceAppLike {
|
|||||||
on: (event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => unknown;
|
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 cachedSingleInstanceLock: boolean | null = null;
|
||||||
let secondInstanceListenerAttached = false;
|
let secondInstanceListenerAttached = false;
|
||||||
const secondInstanceArgvHistory: string[][] = [];
|
const secondInstanceArgvHistory: string[][] = [];
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ function makeHandler(
|
|||||||
calls.push('ensureStatsServerStarted');
|
calls.push('ensureStatsServerStarted');
|
||||||
return 'http://127.0.0.1:6969';
|
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) => {
|
openExternal: async (url) => {
|
||||||
calls.push(`openExternal:${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 () => {
|
test('stats cli command fails when immersion tracking is disabled', async () => {
|
||||||
const { handler, calls, responses } = makeHandler({
|
const { handler, calls, responses } = makeHandler({
|
||||||
getResolvedConfig: () => ({
|
getResolvedConfig: () => ({
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export type StatsCliCommandResponse = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BackgroundStatsStartResult = {
|
||||||
|
url: string;
|
||||||
|
runningInCurrentProcess: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BackgroundStatsStopResult = {
|
||||||
|
ok: boolean;
|
||||||
|
stale: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function writeStatsCliCommandResponse(
|
export function writeStatsCliCommandResponse(
|
||||||
responsePath: string,
|
responsePath: string,
|
||||||
payload: StatsCliCommandResponse,
|
payload: StatsCliCommandResponse,
|
||||||
@@ -39,6 +49,8 @@ export function createRunStatsCliCommandHandler(deps: {
|
|||||||
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
|
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
|
||||||
} | null;
|
} | null;
|
||||||
ensureStatsServerStarted: () => string;
|
ensureStatsServerStarted: () => string;
|
||||||
|
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
|
||||||
|
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
|
||||||
openExternal: (url: string) => Promise<unknown>;
|
openExternal: (url: string) => Promise<unknown>;
|
||||||
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
|
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
|
||||||
exitAppWithCode: (code: number) => void;
|
exitAppWithCode: (code: number) => void;
|
||||||
@@ -61,16 +73,45 @@ export function createRunStatsCliCommandHandler(deps: {
|
|||||||
return async (
|
return async (
|
||||||
args: Pick<
|
args: Pick<
|
||||||
CliArgs,
|
CliArgs,
|
||||||
'statsResponsePath' | 'statsCleanup' | 'statsCleanupVocab' | 'statsCleanupLifetime'
|
| 'statsResponsePath'
|
||||||
|
| 'statsBackground'
|
||||||
|
| 'statsStop'
|
||||||
|
| 'statsCleanup'
|
||||||
|
| 'statsCleanupVocab'
|
||||||
|
| 'statsCleanupLifetime'
|
||||||
>,
|
>,
|
||||||
source: CliCommandSource,
|
source: CliCommandSource,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
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();
|
const config = deps.getResolvedConfig();
|
||||||
if (config.immersionTracking?.enabled === false) {
|
if (config.immersionTracking?.enabled === false) {
|
||||||
throw new Error('Immersion tracking is disabled in config.');
|
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();
|
deps.ensureImmersionTrackerStarted();
|
||||||
const tracker = deps.getImmersionTracker();
|
const tracker = deps.getImmersionTracker();
|
||||||
if (!tracker) {
|
if (!tracker) {
|
||||||
|
|||||||
72
src/main/runtime/stats-daemon.ts
Normal file
72
src/main/runtime/stats-daemon.ts
Normal file
@@ -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<BackgroundStatsServerState>;
|
||||||
|
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<BackgroundStatsServerState, 'port'>,
|
||||||
|
): string {
|
||||||
|
return `http://127.0.0.1:${state.port}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user