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-06 01:50:15 -07:00
parent ea79e331fa
commit b343f1cf5c
45 changed files with 1199 additions and 209 deletions
+72
View File
@@ -0,0 +1,72 @@
import type { SessionSummary, StatsCoverImagesData } from '../types/stats';
export type CoverImageKind = 'anime' | 'media';
export type CoverImageMap = Record<string, string>;
export interface CoverImageRequest {
animeIds: number[];
videoIds: number[];
}
function normalizePositiveIds(ids: Iterable<number | null | undefined>): number[] {
const uniqueIds = new Set<number>();
for (const id of ids) {
if (typeof id === 'number' && Number.isFinite(id) && id > 0) {
uniqueIds.add(Math.floor(id));
}
}
return Array.from(uniqueIds).sort((a, b) => a - b);
}
export function getCoverImageKey(kind: CoverImageKind, id: number): string {
return `${kind}:${id}`;
}
export function collectSessionCoverRequests(
sessions: Pick<SessionSummary, 'animeId' | 'videoId'>[],
): CoverImageRequest {
const animeIds: number[] = [];
const videoIds: number[] = [];
for (const session of sessions) {
if (session.animeId != null) {
animeIds.push(session.animeId);
} else if (session.videoId != null) {
videoIds.push(session.videoId);
}
}
return {
animeIds: normalizePositiveIds(animeIds),
videoIds: normalizePositiveIds(videoIds),
};
}
export function mergeCoverImageData(
previous: CoverImageMap,
data: StatsCoverImagesData,
): CoverImageMap {
const next = { ...previous };
for (const [id, image] of Object.entries(data.anime)) {
if (image?.dataUrl) {
next[getCoverImageKey('anime', Number(id))] = image.dataUrl;
}
}
for (const [id, image] of Object.entries(data.media)) {
if (image?.dataUrl) {
next[getCoverImageKey('media', Number(id))] = image.dataUrl;
}
}
return next;
}
export function getCoverImageSrc(
images: CoverImageMap,
kind: CoverImageKind,
id: number | null,
): string | null {
return id == null ? null : (images[getCoverImageKey(kind, id)] ?? null);
}