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
@@ -29,7 +29,7 @@ export function AnilistSelector({
const [loading, setLoading] = useState(false);
const [linking, setLinking] = useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
inputRef.current?.focus();
@@ -53,7 +53,7 @@ export function AnilistSelector({
const handleInput = (value: string) => {
setQuery(value);
clearTimeout(debounceRef.current);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(value), 400);
};
+2 -23
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { RetryingCoverImage } from '../common/RetryingCoverImage';
import { getStatsClient } from '../../hooks/useStatsApi';
interface AnimeCoverImageProps {
@@ -8,28 +8,7 @@ interface AnimeCoverImageProps {
}
export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverImageProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
const src = getStatsClient().getAnimeCoverUrl(animeId);
return (
<img
src={src}
alt={title}
loading="lazy"
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>
);
return <RetryingCoverImage src={src} alt={title} fallbackLabel={title} className={className} />;
}
@@ -0,0 +1,32 @@
interface DeleteProgressToastProps {
/** Number of sessions currently being deleted. The toast is hidden when 0. */
count: number;
}
/**
* Fixed-position toast shown while session deletions are in flight.
*
* The per-row delete buttons are only visible on hover, so once the confirm
* dialog closes the user has no signal that a (potentially slow) batch delete
* is still running. This stays on screen, independent of hover, until the work
* finishes.
*/
export function DeleteProgressToast({ count }: DeleteProgressToastProps) {
if (count <= 0) return null;
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
>
<span
aria-hidden="true"
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
<span className="text-sm text-ctp-text">
Deleting {count} session{count === 1 ? '' : 's'}&hellip;
</span>
</div>
);
}
@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { appendCoverRetryToken, getCoverRetryDelayMs } from '../../lib/cover-retry';
interface RetryingCoverImageProps {
src: string;
alt: string;
fallbackLabel: string;
className?: string;
fallbackTextClassName?: string;
loading?: 'eager' | 'lazy';
}
export function RetryingCoverImage({
src,
alt,
fallbackLabel,
className = '',
fallbackTextClassName = 'text-2xl',
loading = 'lazy',
}: RetryingCoverImageProps) {
const [failed, setFailed] = useState(false);
const [retryToken, setRetryToken] = useState(0);
const fallbackChar = fallbackLabel.charAt(0) || '?';
useEffect(() => {
setFailed(false);
setRetryToken(0);
}, [src]);
useEffect(() => {
if (!failed) return;
const timer = setTimeout(() => {
setRetryToken((value) => value + 1);
setFailed(false);
}, getCoverRetryDelayMs(retryToken));
return () => clearTimeout(timer);
}, [failed, retryToken]);
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 ${fallbackTextClassName} font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
return (
<img
src={appendCoverRetryToken(src, retryToken)}
alt={alt}
loading={loading}
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
onLoad={() => setFailed(false)}
/>
);
}
+2 -24
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { RetryingCoverImage } from '../common/RetryingCoverImage';
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
interface CoverImageProps {
@@ -9,31 +9,9 @@ interface CoverImageProps {
}
export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId);
useEffect(() => {
setFailed(false);
}, [resolvedSrc]);
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
return (
<img
src={resolvedSrc}
alt={title}
loading="lazy"
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>
<RetryingCoverImage src={resolvedSrc} alt={title} fallbackLabel={title} className={className} />
);
}
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { getCoverImageSrc, type CoverImageMap } from '../../lib/cover-images';
interface CoverThumbnailProps {
animeId: number | null;
videoId: number | null;
title: string;
coverImages: CoverImageMap;
}
export function CoverThumbnail({ animeId, videoId, title, coverImages }: CoverThumbnailProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
const fallback = (
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
{fallbackChar}
</div>
);
const src =
animeId != null
? getCoverImageSrc(coverImages, 'anime', animeId)
: getCoverImageSrc(coverImages, 'media', videoId);
useEffect(() => {
setFailed(false);
}, [src]);
if (!src || failed) {
return fallback;
}
return (
<img
src={src}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setFailed(true)}
/>
);
}
@@ -6,6 +6,7 @@ import { StreakCalendar } from './StreakCalendar';
import { RecentSessions } from './RecentSessions';
import { TrackingSnapshot } from './TrackingSnapshot';
import { TrendChart } from '../trends/TrendChart';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { apiClient } from '../../lib/api-client';
import { getStatsClient } from '../../hooks/useStatsApi';
@@ -153,6 +154,8 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
onDeleteAnimeGroup={handleDeleteAnimeGroup}
deletingIds={deletingIds}
/>
<DeleteProgressToast count={deletingIds.size} />
</div>
);
}
@@ -5,7 +5,9 @@ import {
formatNumber,
formatSessionDayLabel,
} from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client';
import { CoverThumbnail } from './CoverThumbnail';
import { useCoverImages } from '../../hooks/useCoverImages';
import type { CoverImageMap } from '../../lib/cover-images';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
import type { SessionSummary } from '../../types/stats';
@@ -85,53 +87,20 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
return Array.from(map.values());
}
function CoverThumbnail({
animeId,
videoId,
title,
}: {
animeId: number | null;
videoId: number | null;
title: string;
}) {
const fallbackChar = title.charAt(0) || '?';
const [isFallback, setIsFallback] = useState(false);
if ((!animeId && !videoId) || isFallback) {
return (
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
{fallbackChar}
</div>
);
}
const src =
animeId != null
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
return (
<img
src={src}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setIsFallback(true)}
/>
);
}
function SessionItem({
session,
onNavigateToMediaDetail,
onNavigateToSession,
onDelete,
deleteDisabled,
coverImages,
}: {
session: SessionSummary;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDelete: () => void;
deleteDisabled: boolean;
coverImages: CoverImageMap;
}) {
const displayWordCount = getSessionDisplayWordCount(session);
const navigationTarget = getSessionNavigationTarget(session);
@@ -153,6 +122,7 @@ function SessionItem({
animeId={session.animeId}
videoId={session.videoId}
title={session.canonicalTitle ?? 'Unknown'}
coverImages={coverImages}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
@@ -205,6 +175,7 @@ function AnimeGroupRow({
onDeleteSession,
onDeleteAnimeGroup,
deletingIds,
coverImages,
}: {
group: AnimeGroup;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
@@ -212,6 +183,7 @@ function AnimeGroupRow({
onDeleteSession: (session: SessionSummary) => void;
onDeleteAnimeGroup: (group: AnimeGroup) => void;
deletingIds: Set<number>;
coverImages: CoverImageMap;
}) {
const [expanded, setExpanded] = useState(false);
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
@@ -225,6 +197,7 @@ function AnimeGroupRow({
onNavigateToSession={onNavigateToSession}
onDelete={() => onDeleteSession(s)}
deleteDisabled={deletingIds.has(s.sessionId)}
coverImages={coverImages}
/>
);
}
@@ -247,6 +220,7 @@ function AnimeGroupRow({
animeId={group.animeId}
videoId={mostRecentSession.videoId}
title={displayTitle}
coverImages={coverImages}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
@@ -319,6 +293,7 @@ function AnimeGroupRow({
animeId={s.animeId}
videoId={s.videoId}
title={s.canonicalTitle ?? 'Unknown'}
coverImages={coverImages}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-subtext1 truncate">
@@ -378,6 +353,8 @@ export function RecentSessions({
onDeleteAnimeGroup,
deletingIds,
}: RecentSessionsProps) {
const coverImages = useCoverImages(sessions);
if (sessions.length === 0) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
@@ -422,6 +399,7 @@ export function RecentSessions({
onDeleteSession={onDeleteSession}
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
deletingIds={deletingIds}
coverImages={coverImages}
/>
))}
</div>
@@ -71,6 +71,63 @@ test('buildBucketDeleteHandler deletes every session in the bucket when confirm
assert.equal(onErrorCalled, false);
});
test('buildBucketDeleteHandler signals onStart after confirm, before deleting', async () => {
const events: string[] = [];
const bucket = makeBucket([
makeSession({ sessionId: 11 }),
makeSession({ sessionId: 22 }),
makeSession({ sessionId: 33 }),
]);
const handler = buildBucketDeleteHandler({
bucket,
apiClient: {
deleteSessions: async () => {
events.push('delete');
},
},
confirm: () => {
events.push('confirm');
return true;
},
onStart: (count) => {
events.push(`start:${count}`);
},
onSuccess: () => {
events.push('success');
},
onError: () => {
events.push('error');
},
});
await handler();
assert.deepEqual(events, ['confirm', 'start:3', 'delete', 'success']);
});
test('buildBucketDeleteHandler does not call onStart when confirm returns false', async () => {
let startCalled = false;
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
const handler = buildBucketDeleteHandler({
bucket,
apiClient: { deleteSessions: async () => {} },
confirm: () => false,
onStart: () => {
startCalled = true;
},
onSuccess: () => {},
onError: () => {},
});
await handler();
assert.equal(startCalled, false);
});
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
let deleteCalled = false;
let successCalled = false;
+12 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { apiClient } from '../../lib/api-client';
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
@@ -28,6 +29,8 @@ export interface BucketDeleteDeps {
bucket: SessionBucket;
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
confirm: (title: string, count: number) => boolean | Promise<boolean>;
/** Called once confirmation passes, just before the delete request begins. */
onStart?: (count: number) => void;
onSuccess: (deletedIds: number[]) => void;
onError: (message: string) => void;
}
@@ -39,12 +42,13 @@ export interface BucketDeleteDeps {
* rendering the full SessionsTab or mocking React state.
*/
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
const { bucket, apiClient: client, confirm, onSuccess, onError } = deps;
const { bucket, apiClient: client, confirm, onStart, onSuccess, onError } = deps;
return async () => {
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
const ids = bucket.sessions.map((s) => s.sessionId);
try {
if (!(await confirm(title, ids.length))) return;
onStart?.(ids.length);
await client.deleteSessions(ids);
onSuccess(ids);
} catch (err) {
@@ -72,6 +76,7 @@ export function SessionsTab({
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
const [deletingCount, setDeletingCount] = useState(0);
useEffect(() => {
setVisibleSessions(sessions);
@@ -131,6 +136,7 @@ export function SessionsTab({
setDeleteError(null);
setDeletingSessionId(session.sessionId);
setDeletingCount(1);
try {
await apiClient.deleteSession(session.sessionId);
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
@@ -139,6 +145,7 @@ export function SessionsTab({
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally {
setDeletingSessionId(null);
setDeletingCount(0);
}
};
@@ -149,6 +156,7 @@ export function SessionsTab({
bucket,
apiClient,
confirm: confirmBucketDelete,
onStart: (count) => setDeletingCount(count),
onSuccess: (ids) => {
const deleted = new Set(ids);
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
@@ -166,6 +174,7 @@ export function SessionsTab({
await handler();
} finally {
setDeletingBucketKey(null);
setDeletingCount(0);
}
};
@@ -305,6 +314,8 @@ export function SessionsTab({
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
</div>
)}
<DeleteProgressToast count={deletingCount} />
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useMemo, useState } from 'react';
import {
collectSessionCoverRequests,
getCoverImageKey,
mergeCoverImageData,
type CoverImageMap,
} from '../lib/cover-images';
import { getCoverRetryDelayMs } from '../lib/cover-retry';
import type { SessionSummary } from '../types/stats';
import { getStatsClient } from './useStatsApi';
function buildRequestKey(animeIds: number[], videoIds: number[]): string {
return `a:${animeIds.join(',')}|m:${videoIds.join(',')}`;
}
export function useCoverImages(sessions: SessionSummary[]): CoverImageMap {
const requests = useMemo(() => collectSessionCoverRequests(sessions), [sessions]);
const requestKey = useMemo(
() => buildRequestKey(requests.animeIds, requests.videoIds),
[requests],
);
const [images, setImages] = useState<CoverImageMap>({});
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let cachedImages: CoverImageMap = {};
const client = getStatsClient();
async function load(animeIds: number[], videoIds: number[], attempt: number): Promise<void> {
if (animeIds.length === 0 && videoIds.length === 0) {
return;
}
try {
const data = await client.getCoverImages({ animeIds, videoIds });
if (cancelled) return;
cachedImages = mergeCoverImageData(cachedImages, data);
setImages(cachedImages);
} catch {
if (cancelled) return;
}
const missingAnimeIds = animeIds.filter((id) => !cachedImages[getCoverImageKey('anime', id)]);
const missingVideoIds = videoIds.filter((id) => !cachedImages[getCoverImageKey('media', id)]);
if (missingAnimeIds.length === 0 && missingVideoIds.length === 0) {
return;
}
timer = setTimeout(() => {
void load(missingAnimeIds, missingVideoIds, attempt + 1);
}, getCoverRetryDelayMs(attempt));
}
setImages({});
void load(requests.animeIds, requests.videoIds, 0);
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [requestKey]);
return images;
}
+37
View File
@@ -32,6 +32,43 @@ test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without
assert.equal(baseUrl, 'http://127.0.0.1:6969');
});
test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as (
animeId: number,
retryToken?: number,
) => string;
assert.equal(
getAnimeCoverUrl(42, 3),
'http://127.0.0.1:6969/api/stats/anime/42/cover?coverRetry=3',
);
});
test('getCoverImages batches anime and media cover requests', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
let seenBody = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
seenBody = String(init?.body ?? '');
return new Response(JSON.stringify({ anime: {}, media: {} }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.getCoverImages({ animeIds: [1, 1, 2], videoIds: [7, 7, 8] });
assert.equal(seenUrl, `${BASE_URL}/api/stats/covers`);
assert.equal(seenMethod, 'POST');
assert.deepEqual(JSON.parse(seenBody), { animeIds: [1, 2], videoIds: [7, 8] });
} finally {
globalThis.fetch = originalFetch;
}
});
test('deleteSession sends a DELETE request to the session endpoint', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
+29 -1
View File
@@ -23,7 +23,9 @@ import type {
EpisodeDetailData,
StatsAnkiNoteInfo,
StatsExcludedWord,
StatsCoverImagesData,
} from '../types/stats';
import { appendCoverRetryToken } from './cover-retry';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
@@ -65,6 +67,16 @@ async function fetchJson<T>(path: string): Promise<T> {
return res.json() as Promise<T>;
}
function uniquePositiveIds(ids: number[]): number[] {
const uniqueIds = new Set<number>();
for (const id of ids) {
if (Number.isFinite(id) && id > 0) {
uniqueIds.add(Math.floor(id));
}
}
return Array.from(uniqueIds).sort((a, b) => a - b);
}
export const apiClient = {
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
getDailyRollups: (limit = 60) =>
@@ -116,7 +128,22 @@ export const apiClient = {
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
getAnimeRollups: (animeId: number, limit = 90) =>
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
getAnimeCoverUrl: (animeId: number) => `${BASE_URL}/api/stats/anime/${animeId}/cover`,
getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
getCoverImages: async (params: {
animeIds: number[];
videoIds: number[];
}): Promise<StatsCoverImagesData> => {
const res = await fetchResponse('/api/stats/covers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
animeIds: uniquePositiveIds(params.animeIds),
videoIds: uniquePositiveIds(params.videoIds),
}),
});
return res.json() as Promise<StatsCoverImagesData>;
},
getStreakCalendar: (days = 90) =>
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
getEpisodesPerDay: (limit = 90) =>
@@ -175,6 +202,7 @@ export const apiClient = {
episodes: number | null;
season: string | null;
seasonYear: number | null;
description: string | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}>
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { collectSessionCoverRequests, getCoverImageKey } from './cover-images';
import type { SessionSummary } from '../types/stats';
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
const { sessionId, ...rest } = overrides;
return {
sessionId,
canonicalTitle: null,
videoId: null,
animeId: null,
animeTitle: null,
startedAtMs: 0,
endedAtMs: null,
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 0,
tokensSeen: 0,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
knownWordsSeen: 0,
knownWordRate: 0,
...rest,
};
}
test('collectSessionCoverRequests dedupes anime ids and only requests media for ungrouped sessions', () => {
const requests = collectSessionCoverRequests([
makeSession({ sessionId: 1, animeId: 10, videoId: 100 }),
makeSession({ sessionId: 2, animeId: 10, videoId: 101 }),
makeSession({ sessionId: 3, animeId: null, videoId: 200 }),
makeSession({ sessionId: 4, animeId: null, videoId: 200 }),
]);
assert.deepEqual(requests, { animeIds: [10], videoIds: [200] });
});
test('getCoverImageKey separates anime and media ids', () => {
assert.equal(getCoverImageKey('anime', 1), 'anime:1');
assert.equal(getCoverImageKey('media', 1), 'media:1');
});
+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);
}
+22
View File
@@ -0,0 +1,22 @@
const COVER_RETRY_PARAM = 'coverRetry';
export function appendCoverRetryToken(src: string, retryToken = 0): string {
if (!Number.isFinite(retryToken) || retryToken <= 0) return src;
const normalizedToken = String(Math.trunc(retryToken));
try {
const url = new URL(src, 'http://subminer.local');
url.searchParams.set(COVER_RETRY_PARAM, normalizedToken);
if (src.startsWith('/')) {
return `${url.pathname}${url.search}${url.hash}`;
}
return url.toString();
} catch {
const separator = src.includes('?') ? '&' : '?';
return `${src}${separator}${COVER_RETRY_PARAM}=${encodeURIComponent(normalizedToken)}`;
}
}
export function getCoverRetryDelayMs(retryToken: number): number {
return Math.min(30_000, 2_000 * 2 ** Math.min(Math.max(retryToken, 0), 4));
}
@@ -5,6 +5,7 @@ import type { MediaLibraryItem } from '../types/stats';
import {
groupMediaLibraryItems,
resolveMediaArtworkUrl,
resolveMediaCoverApiUrl,
summarizeMediaLibraryGroups,
} from './media-library-grouping';
import { CoverImage } from '../components/library/CoverImage';
@@ -172,6 +173,13 @@ test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
});
test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => {
assert.equal(
resolveMediaCoverApiUrl(youtubeEpisodeA.videoId, 2),
'http://127.0.0.1:6969/api/stats/media/1/cover?coverRetry=2',
);
});
test('MediaCard prefers youtube video title over canonical fallback url slug', () => {
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
+3 -2
View File
@@ -1,4 +1,5 @@
import { BASE_URL } from './api-client';
import { appendCoverRetryToken } from './cover-retry';
import type { MediaLibraryItem } from '../types/stats';
export interface MediaLibraryGroup {
@@ -22,8 +23,8 @@ export function resolveMediaArtworkUrl(
return normalized.length > 0 ? normalized : null;
}
export function resolveMediaCoverApiUrl(videoId: number): string {
return `${BASE_URL}/api/stats/media/${videoId}/cover`;
export function resolveMediaCoverApiUrl(videoId: number, retryToken = 0): string {
return appendCoverRetryToken(`${BASE_URL}/api/stats/media/${videoId}/cover`, retryToken);
}
export function summarizeMediaLibraryGroups(groups: MediaLibraryGroup[]): {
+12 -11
View File
@@ -1,51 +1,52 @@
import { describe, it, expect } from 'vitest';
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { fullReading } from './reading-utils';
describe('fullReading', () => {
it('prefixes leading hiragana from headword', () => {
// お前 with reading まえ → おまえ
expect(fullReading('お前', 'まえ')).toBe('おまえ');
assert.equal(fullReading('お前', 'まえ'), 'おまえ');
});
it('handles katakana stored readings', () => {
// お前 with katakana reading マエ → おまえ
expect(fullReading('お前', 'マエ')).toBe('おまえ');
assert.equal(fullReading('お前', 'マエ'), 'おまえ');
});
it('returns stored reading when it already includes leading kana', () => {
// Reading already correct
expect(fullReading('お前', 'おまえ')).toBe('おまえ');
assert.equal(fullReading('お前', 'おまえ'), 'おまえ');
});
it('handles trailing hiragana', () => {
// 隠す with reading かくす — す is trailing hiragana
expect(fullReading('隠す', 'かくす')).toBe('かくす');
assert.equal(fullReading('隠す', 'かくす'), 'かくす');
});
it('handles pure kanji headwords', () => {
expect(fullReading('様', 'さま')).toBe('さま');
assert.equal(fullReading('様', 'さま'), 'さま');
});
it('returns empty for empty reading', () => {
expect(fullReading('前', '')).toBe('');
assert.equal(fullReading('前', ''), '');
});
it('returns empty for empty headword', () => {
expect(fullReading('', 'まえ')).toBe('まえ');
assert.equal(fullReading('', 'まえ'), 'まえ');
});
it('handles all-kana headword', () => {
// Headword is already all hiragana
expect(fullReading('いますぐ', 'いますぐ')).toBe('いますぐ');
assert.equal(fullReading('いますぐ', 'いますぐ'), 'いますぐ');
});
it('handles mixed leading and trailing kana', () => {
// お気に入り: お=leading, に入り=trailing around 気
expect(fullReading('お気に入り', 'きにいり')).toBe('おきにいり');
assert.equal(fullReading('お気に入り', 'きにいり'), 'おきにいり');
});
it('handles katakana in headword', () => {
// カズマ様 — leading katakana + kanji
expect(fullReading('カズマ様', 'さま')).toBe('かずまさま');
assert.equal(fullReading('カズマ様', 'さま'), 'かずまさま');
});
});
+8 -4
View File
@@ -41,8 +41,10 @@ export function fullReading(headword: string, storedReading: string): string {
const chars = [...headword];
let i = 0;
while (i < chars.length && (isHiragana(chars[i]) || isKatakana(chars[i]))) {
leadingKana.push(katakanaToHiragana(chars[i]));
while (i < chars.length) {
const ch = chars[i]!;
if (!isHiragana(ch) && !isKatakana(ch)) break;
leadingKana.push(katakanaToHiragana(ch));
i++;
}
@@ -51,8 +53,10 @@ export function fullReading(headword: string, storedReading: string): string {
}
let j = chars.length - 1;
while (j > i && (isHiragana(chars[j]) || isKatakana(chars[j]))) {
trailingKana.unshift(katakanaToHiragana(chars[j]));
while (j > i) {
const ch = chars[j]!;
if (!isHiragana(ch) && !isKatakana(ch)) break;
trailingKana.unshift(katakanaToHiragana(ch));
j--;
}
+3 -2
View File
@@ -4,8 +4,9 @@ import type { SessionSummary } from '../types/stats';
import { groupSessionsByVideo } from './session-grouping';
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
const { sessionId, ...rest } = overrides;
return {
sessionId: overrides.sessionId,
sessionId,
canonicalTitle: null,
videoId: null,
animeId: null,
@@ -22,7 +23,7 @@ function makeSession(overrides: Partial<SessionSummary> & { sessionId: number })
yomitanLookupCount: 0,
knownWordsSeen: 0,
knownWordRate: 0,
...overrides,
...rest,
};
}
-1
View File
@@ -132,7 +132,6 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
episodeCount: 3,
lastWatchedMs: 0,
}}
avgSessionMs={20_000}
knownWordsSummary={null}
/>,
);
+1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const css = readFileSync(fileURLToPath(new URL('./globals.css', import.meta.url)), 'utf8');
+10
View File
@@ -82,6 +82,16 @@ export interface StatsExcludedWord {
reading: string;
}
export interface StatsCoverImage {
contentType: string;
dataUrl: string;
}
export interface StatsCoverImagesData {
anime: Record<string, StatsCoverImage | null>;
media: Record<string, StatsCoverImage | null>;
}
export interface KanjiEntry {
kanjiId: number;
kanji: string;