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 () => {
|
||||
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;
|
||||
|
||||
@@ -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<number>;
|
||||
launchAppCommandDetached: (
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
label: string,
|
||||
) => void;
|
||||
waitForStatsResponse: (responsePath: string) => Promise<StatsCommandResponse>;
|
||||
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<StatsCommandDeps> = {},
|
||||
): Promise<boolean> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <level>', 'Log level')
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
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,
|
||||
|
||||
@@ -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<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', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup'], 'subminer', {});
|
||||
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface Args {
|
||||
jellyfinDiscovery: boolean;
|
||||
dictionary: boolean;
|
||||
stats: boolean;
|
||||
statsBackground?: boolean;
|
||||
statsStop?: boolean;
|
||||
statsCleanup?: boolean;
|
||||
statsCleanupVocab?: boolean;
|
||||
statsCleanupLifetime?: boolean;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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=')) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ResolvedConfig } from '../../types.js';
|
||||
export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = {
|
||||
stats: {
|
||||
toggleKey: 'Backquote',
|
||||
markWatchedKey: 'KeyW',
|
||||
serverPort: 6969,
|
||||
autoStartServer: true,
|
||||
autoOpenBrowser: true,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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 {
|
||||
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<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) => {
|
||||
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) => {
|
||||
|
||||
123
src/main.ts
123
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<typeof setTimeout> | 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 {
|
||||
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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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[][] = [];
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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<LifetimeRebuildSummary>;
|
||||
} | null;
|
||||
ensureStatsServerStarted: () => string;
|
||||
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
|
||||
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
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<void> => {
|
||||
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) {
|
||||
|
||||
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