mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-11 04:19:26 -07:00
feat(stats): build per-title librarySummary from daily rollups and sessions
This commit is contained in:
@@ -3725,3 +3725,119 @@ test('deleteSession removes zero-session media from library and trends', () => {
|
|||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getTrendsDashboard builds librarySummary with per-title aggregates', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const stmts = createTrackerPreparedStatements(db);
|
||||||
|
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/library-summary-test.mkv', {
|
||||||
|
canonicalTitle: 'Library Summary Test',
|
||||||
|
sourcePath: '/tmp/library-summary-test.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'Summary Anime',
|
||||||
|
canonicalTitle: 'Summary Anime',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: 'library-summary-test.mkv',
|
||||||
|
parsedTitle: 'Summary Anime',
|
||||||
|
parsedSeason: 1,
|
||||||
|
parsedEpisode: 1,
|
||||||
|
parserSource: 'test',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayOneStart = 1_700_000_000_000;
|
||||||
|
const dayTwoStart = dayOneStart + 86_400_000;
|
||||||
|
|
||||||
|
const sessionOne = startSessionRecord(db, videoId, dayOneStart);
|
||||||
|
const sessionTwo = startSessionRecord(db, videoId, dayTwoStart);
|
||||||
|
|
||||||
|
for (const [sessionId, startedAtMs, activeMs, cards, tokens, lookups] of [
|
||||||
|
[sessionOne.sessionId, dayOneStart, 30 * 60_000, 2, 120, 8],
|
||||||
|
[sessionTwo.sessionId, dayTwoStart, 45 * 60_000, 3, 140, 10],
|
||||||
|
] as const) {
|
||||||
|
stmts.telemetryInsertStmt.run(
|
||||||
|
sessionId,
|
||||||
|
`${startedAtMs + 60_000}`,
|
||||||
|
activeMs,
|
||||||
|
activeMs,
|
||||||
|
10,
|
||||||
|
tokens,
|
||||||
|
cards,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
lookups,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
`${startedAtMs + 60_000}`,
|
||||||
|
`${startedAtMs + 60_000}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE imm_sessions
|
||||||
|
SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?,
|
||||||
|
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
|
||||||
|
WHERE session_id = ?
|
||||||
|
`,
|
||||||
|
).run(
|
||||||
|
`${startedAtMs + activeMs}`,
|
||||||
|
activeMs,
|
||||||
|
activeMs,
|
||||||
|
10,
|
||||||
|
tokens,
|
||||||
|
cards,
|
||||||
|
lookups,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [day, active, tokens, cards] of [
|
||||||
|
[Math.floor(dayOneStart / 86_400_000), 30, 120, 2],
|
||||||
|
[Math.floor(dayTwoStart / 86_400_000), 45, 140, 3],
|
||||||
|
] as const) {
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_daily_rollups (
|
||||||
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
|
total_tokens_seen, total_cards
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(day, videoId, 1, active, 10, tokens, cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
||||||
|
|
||||||
|
assert.equal(dashboard.librarySummary.length, 1);
|
||||||
|
const row = dashboard.librarySummary[0]!;
|
||||||
|
assert.equal(row.title, 'Summary Anime');
|
||||||
|
assert.equal(row.watchTimeMin, 75);
|
||||||
|
assert.equal(row.videos, 1);
|
||||||
|
assert.equal(row.sessions, 2);
|
||||||
|
assert.equal(row.cards, 5);
|
||||||
|
assert.equal(row.words, 260);
|
||||||
|
assert.equal(row.lookups, 18);
|
||||||
|
assert.equal(row.lookupsPerHundred, +((18 / 260) * 100).toFixed(1));
|
||||||
|
assert.equal(row.firstWatched, Math.floor(dayOneStart / 86_400_000));
|
||||||
|
assert.equal(row.lastWatched, Math.floor(dayTwoStart / 86_400_000));
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -405,6 +405,89 @@ function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoi
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLibrarySummary(
|
||||||
|
rollups: ImmersionSessionRollupRow[],
|
||||||
|
sessions: TrendSessionMetricRow[],
|
||||||
|
titlesByVideoId: Map<number, string>,
|
||||||
|
): LibrarySummaryRow[] {
|
||||||
|
type Accum = {
|
||||||
|
watchTimeMin: number;
|
||||||
|
videos: Set<number>;
|
||||||
|
cards: number;
|
||||||
|
words: number;
|
||||||
|
firstWatched: number;
|
||||||
|
lastWatched: number;
|
||||||
|
sessions: number;
|
||||||
|
lookups: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const byTitle = new Map<string, Accum>();
|
||||||
|
|
||||||
|
const ensure = (title: string): Accum => {
|
||||||
|
const existing = byTitle.get(title);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created: Accum = {
|
||||||
|
watchTimeMin: 0,
|
||||||
|
videos: new Set<number>(),
|
||||||
|
cards: 0,
|
||||||
|
words: 0,
|
||||||
|
firstWatched: Number.POSITIVE_INFINITY,
|
||||||
|
lastWatched: Number.NEGATIVE_INFINITY,
|
||||||
|
sessions: 0,
|
||||||
|
lookups: 0,
|
||||||
|
};
|
||||||
|
byTitle.set(title, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rollup of rollups) {
|
||||||
|
if (rollup.videoId === null) continue;
|
||||||
|
const title = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId);
|
||||||
|
const acc = ensure(title);
|
||||||
|
acc.watchTimeMin += rollup.totalActiveMin;
|
||||||
|
acc.cards += rollup.totalCards;
|
||||||
|
acc.words += rollup.totalTokensSeen;
|
||||||
|
acc.videos.add(rollup.videoId);
|
||||||
|
if (rollup.rollupDayOrMonth < acc.firstWatched) {
|
||||||
|
acc.firstWatched = rollup.rollupDayOrMonth;
|
||||||
|
}
|
||||||
|
if (rollup.rollupDayOrMonth > acc.lastWatched) {
|
||||||
|
acc.lastWatched = rollup.rollupDayOrMonth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const title = resolveTrendAnimeTitle(session);
|
||||||
|
if (!byTitle.has(title)) continue;
|
||||||
|
const acc = byTitle.get(title)!;
|
||||||
|
acc.sessions += 1;
|
||||||
|
acc.lookups += session.yomitanLookupCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: LibrarySummaryRow[] = [];
|
||||||
|
for (const [title, acc] of byTitle) {
|
||||||
|
if (!Number.isFinite(acc.firstWatched) || !Number.isFinite(acc.lastWatched)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
title,
|
||||||
|
watchTimeMin: Math.round(acc.watchTimeMin),
|
||||||
|
videos: acc.videos.size,
|
||||||
|
sessions: acc.sessions,
|
||||||
|
cards: acc.cards,
|
||||||
|
words: acc.words,
|
||||||
|
lookups: acc.lookups,
|
||||||
|
lookupsPerHundred:
|
||||||
|
acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
|
||||||
|
firstWatched: acc.firstWatched,
|
||||||
|
lastWatched: acc.lastWatched,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort((a, b) => b.watchTimeMin - a.watchTimeMin || a.title.localeCompare(b.title));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
function getVideoAnimeTitleMap(
|
function getVideoAnimeTitleMap(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
videoIds: Array<number | null>,
|
videoIds: Array<number | null>,
|
||||||
@@ -716,6 +799,6 @@ export function getTrendsDashboard(
|
|||||||
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
|
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
|
||||||
watchTimeByHour: buildWatchTimeByHour(sessions),
|
watchTimeByHour: buildWatchTimeByHour(sessions),
|
||||||
},
|
},
|
||||||
librarySummary: [],
|
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user