mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
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:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'}…
|
||||
</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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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;
|
||||
}>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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={() => {}} />);
|
||||
|
||||
|
||||
@@ -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[]): {
|
||||
|
||||
@@ -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('カズマ様', 'さま'), 'かずまさま');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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--;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
||||
episodeCount: 3,
|
||||
lastWatchedMs: 0,
|
||||
}}
|
||||
avgSessionMs={20_000}
|
||||
knownWordsSummary={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user