feat: show mining frame toast on OSD and speed up stats session deletes

- Flash mined-frame screenshot as in-overlay image toast for OSD card notifications
- Batch cover art from stored DB blobs via POST /api/stats/covers (no extra AniList fetches)
- Show delete progress toast on stats home and sessions pages
- Refresh only affected rollups on session delete instead of full rebuild
- Rebuild lifetime summaries with aggregate SQL CTEs instead of per-session loop
This commit is contained in:
2026-06-04 22:00:51 -07:00
parent ea79e331fa
commit b343f1cf5c
45 changed files with 1199 additions and 209 deletions
+56
View File
@@ -27,6 +27,16 @@ type StatsExcludedWordPayload = {
reading: string;
};
type StatsCoverImagePayload = {
contentType: 'image/jpeg';
dataUrl: string;
} | null;
type StatsCoverBatchBody = {
animeIds?: unknown;
videoIds?: unknown;
};
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
if (raw === undefined) return fallback;
const n = Number(raw);
@@ -73,6 +83,31 @@ function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | nul
return words;
}
function parsePositiveIdList(raw: unknown, maxItems = 100): number[] {
if (!Array.isArray(raw)) return [];
const ids = new Set<number>();
for (const rawId of raw) {
const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN;
if (Number.isFinite(id) && id > 0) {
ids.add(Math.floor(id));
if (ids.size >= maxItems) break;
}
}
return Array.from(ids).sort((a, b) => a - b);
}
function coverImagePayload(
art: { coverBlob?: Uint8Array | null } | null | undefined,
): StatsCoverImagePayload {
if (!art?.coverBlob) return null;
return {
contentType: 'image/jpeg',
dataUrl: `data:image/jpeg;base64,${Buffer.from(art.coverBlob).toString('base64')}`,
};
}
function resolveStatsNoteFieldName(
noteInfo: StatsServerNoteInfo,
...preferredNames: (string | undefined)[]
@@ -707,6 +742,27 @@ export function createStatsApp(
return c.json({ ok: true });
});
app.post('/api/stats/covers', async (c) => {
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
const animeIds = parsePositiveIdList(body?.animeIds);
const videoIds = parsePositiveIdList(body?.videoIds);
const anime: Record<string, StatsCoverImagePayload> = {};
const media: Record<string, StatsCoverImagePayload> = {};
await Promise.all(
animeIds.map(async (animeId) => {
anime[String(animeId)] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
}),
);
await Promise.all(
videoIds.map(async (videoId) => {
media[String(videoId)] = coverImagePayload(await tracker.getCoverArt(videoId));
}),
);
return c.json({ anime, media });
});
app.get('/api/stats/anime/:animeId/cover', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 404);