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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user