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

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