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:
2026-03-17 19:54:04 -07:00
parent 55ee12e87f
commit 08a5401a7d
20 changed files with 776 additions and 33 deletions

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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', {});

View File

@@ -112,6 +112,8 @@ export interface Args {
jellyfinDiscovery: boolean;
dictionary: boolean;
stats: boolean;
statsBackground?: boolean;
statsStop?: boolean;
statsCleanup?: boolean;
statsCleanupVocab?: boolean;
statsCleanupLifetime?: boolean;

View File

@@ -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',

View File

@@ -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=')) {

View File

@@ -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,

View File

@@ -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',

View File

@@ -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;

View File

@@ -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(

View File

@@ -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) => {

View File

@@ -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 &&

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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[][] = [];

View File

@@ -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: () => ({

View File

@@ -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) {

View 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}`;
}