mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 07:21:33 -07:00
fix(stats): refresh library cover art after AniList relink
- Refetch the Library list after relinking a title's AniList entry so the grid picks up the new cover, not just the detail view - Serve cover responses with an ETag and revalidate instead of caching for a day/week, so stale art can't be served from the browser cache - Add stats-server and AnimeTab coverage for the 304 revalidation path and the post-relink refetch - Document the relink/cover-refresh behavior in the AniList integration guide
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Relinking a title to a different AniList entry now updates its cover in the stats Library grid too. The detail view picked up the new art immediately while the grid kept the previous entry's image, so several unrelated titles could end up sharing one wrong cover. The library list is refetched after a relink, and cover responses now carry an ETag and revalidate instead of being cached for a day, so a stale image can no longer be served from the browser cache.
|
||||
@@ -69,6 +69,8 @@ SubMiner fetches cover art from AniList for display in the stats dashboard. When
|
||||
|
||||
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
|
||||
|
||||
If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
All AniList API calls go through a shared rate limiter that enforces a sliding window of 20 requests per minute. The limiter also reads AniList's `X-RateLimit-Remaining` and `Retry-After` response headers and pauses requests when the server signals throttling. This applies to both episode tracking and cover art fetching.
|
||||
|
||||
@@ -1100,12 +1100,56 @@ describe('stats server API routes', () => {
|
||||
assert.deepEqual(assignments, [{ animeId: 1, body }]);
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime/:animeId/cover returns cover art', async () => {
|
||||
it('GET /api/stats/anime/:animeId/cover returns revalidated cover art', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const res = await app.request('/api/stats/anime/1/cover');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'image/jpeg');
|
||||
assert.equal(res.headers.get('cache-control'), 'public, max-age=86400');
|
||||
assert.equal(res.headers.get('cache-control'), 'no-cache');
|
||||
assert.ok(res.headers.get('etag'));
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime/:animeId/cover answers 304 for a matching ETag', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const first = await app.request('/api/stats/anime/1/cover');
|
||||
const etag = first.headers.get('etag');
|
||||
assert.ok(etag);
|
||||
|
||||
const cached = await app.request('/api/stats/anime/1/cover', {
|
||||
headers: { 'If-None-Match': `W/${etag}, "other"` },
|
||||
});
|
||||
assert.equal(cached.status, 304);
|
||||
assert.equal(cached.headers.get('etag'), etag);
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime/:animeId/cover resends art when the ETag no longer matches', async () => {
|
||||
// A relinked AniList entry swaps the bytes behind the same cover URL.
|
||||
let coverBlob = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getAnimeCoverArt: async () => ({
|
||||
videoId: 1,
|
||||
anilistId: 21858,
|
||||
coverUrl: 'https://example.com/cover.jpg',
|
||||
coverBlob,
|
||||
titleRomaji: 'Little Witch Academia',
|
||||
titleEnglish: 'Little Witch Academia',
|
||||
episodesTotal: 25,
|
||||
fetchedAtMs: Date.now(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const first = await app.request('/api/stats/anime/1/cover');
|
||||
const staleEtag = first.headers.get('etag');
|
||||
assert.ok(staleEtag);
|
||||
|
||||
coverBlob = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const res = await app.request('/api/stats/anime/1/cover', {
|
||||
headers: { 'If-None-Match': staleEtag },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'image/png');
|
||||
assert.notEqual(res.headers.get('etag'), staleEtag);
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime/:animeId/cover serves detected cover MIME type', async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Hono } from 'hono';
|
||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||
import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js';
|
||||
@@ -74,6 +75,31 @@ function detectImageContentType(bytes: Uint8Array): string {
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function ifNoneMatchHits(header: string | undefined, etag: string): boolean {
|
||||
if (!header) return false;
|
||||
if (header.trim() === '*') return true;
|
||||
return header
|
||||
.split(',')
|
||||
.map((candidate) => candidate.trim().replace(/^W\//, ''))
|
||||
.some((candidate) => candidate === etag);
|
||||
}
|
||||
|
||||
// Cover URLs are stable per anime/video, so relinking an AniList entry swaps the
|
||||
// bytes behind an unchanged URL. Long max-age would keep serving the old art from
|
||||
// the browser cache; revalidate instead and let the ETag skip the transfer.
|
||||
function coverResponse(bytes: Uint8Array<ArrayBuffer>, ifNoneMatch: string | undefined): Response {
|
||||
const etag = `"${createHash('sha256').update(bytes).digest('hex')}"`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': detectImageContentType(bytes),
|
||||
'Cache-Control': 'no-cache',
|
||||
ETag: etag,
|
||||
};
|
||||
if (ifNoneMatchHits(ifNoneMatch, etag)) {
|
||||
return new Response(null, { status: 304, headers });
|
||||
}
|
||||
return new Response(bytes, { headers });
|
||||
}
|
||||
|
||||
function createLimitedTaskRunner(maxConcurrentTasks: number): (task: () => Promise<void>) => void {
|
||||
const queue: Array<() => Promise<void>> = [];
|
||||
let activeTasks = 0;
|
||||
@@ -137,13 +163,7 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
|
||||
art = await tracker.getAnimeCoverArt(animeId);
|
||||
}
|
||||
if (!art?.coverBlob) return c.body(null, 404);
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
'Content-Type': detectImageContentType(bytes),
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
});
|
||||
return coverResponse(new Uint8Array(art.coverBlob), c.req.header('if-none-match'));
|
||||
});
|
||||
|
||||
app.get('/api/stats/media/:videoId/cover', async (c) => {
|
||||
@@ -155,12 +175,6 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
|
||||
art = await tracker.getCoverArt(videoId);
|
||||
}
|
||||
if (!art?.coverBlob) return c.body(null, 404);
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
'Content-Type': detectImageContentType(bytes),
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
},
|
||||
});
|
||||
return coverResponse(new Uint8Array(art.coverBlob), c.req.header('if-none-match'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ interface AnimeDetailViewProps {
|
||||
onOpenEpisodeDetail?: (videoId: number) => void;
|
||||
/** Called after the whole library entry is deleted, so the caller can refresh. */
|
||||
onAnimeDeleted?: () => void;
|
||||
/**
|
||||
* Called after the AniList link changes. The library list caches the old
|
||||
* anilistId (and with it the cover URL), so it has to refetch or the grid
|
||||
* keeps showing the previous title's art.
|
||||
*/
|
||||
onAnilistRelinked?: () => void;
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
@@ -143,6 +149,7 @@ export function AnimeDetailView({
|
||||
onNavigateToWord,
|
||||
onOpenEpisodeDetail,
|
||||
onAnimeDeleted,
|
||||
onAnilistRelinked,
|
||||
}: AnimeDetailViewProps) {
|
||||
const { data, loading, error, reload } = useAnimeDetail(animeId);
|
||||
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
|
||||
@@ -229,6 +236,7 @@ export function AnimeDetailView({
|
||||
setShowAnilistSelector(false);
|
||||
setCoverRetryToken((value) => value + 1);
|
||||
reload();
|
||||
onAnilistRelinked?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import type { AnimeDetailData, AnimeLibraryItem } from '../../types/stats';
|
||||
import { AnimeTab } from './AnimeTab';
|
||||
|
||||
interface TestWindow extends Window {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
|
||||
function installDom(): () => void {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
const previousIsReactActEnvironment = (
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT;
|
||||
const window = new Window() as TestWindow;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: window.HTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: previousHTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
|
||||
};
|
||||
}
|
||||
|
||||
function libraryItem(anilistId: number | null): AnimeLibraryItem {
|
||||
return {
|
||||
animeId: 7,
|
||||
canonicalTitle: 'Test Anime Season 2',
|
||||
anilistId,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 0,
|
||||
episodeCount: 1,
|
||||
episodesTotal: 13,
|
||||
lastWatchedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function detailData(anilistId: number | null): AnimeDetailData {
|
||||
return {
|
||||
detail: {
|
||||
animeId: 7,
|
||||
canonicalTitle: 'Test Anime Season 2',
|
||||
anilistId,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
description: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLinesSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
episodeCount: 1,
|
||||
lastWatchedMs: 1,
|
||||
},
|
||||
episodes: [],
|
||||
anilistEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
function findButton(container: Element, label: string): HTMLElement {
|
||||
const match = [...container.querySelectorAll('button')].find((button) =>
|
||||
(button.textContent ?? '').includes(label),
|
||||
);
|
||||
assert.ok(match, `expected a "${label}" button`);
|
||||
return match as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
test('AnimeTab refetches the library after the AniList entry is relinked', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeDetail: apiClient.getAnimeDetail,
|
||||
getAnimeWords: apiClient.getAnimeWords,
|
||||
getAnimeRollups: apiClient.getAnimeRollups,
|
||||
getAnimeKnownWordsSummary: apiClient.getAnimeKnownWordsSummary,
|
||||
searchAnilist: apiClient.searchAnilist,
|
||||
reassignAnimeAnilist: apiClient.reassignAnimeAnilist,
|
||||
};
|
||||
|
||||
// The library grid keys its cover URL off anilistId, so a stale list keeps
|
||||
// pointing at the previous entry's art.
|
||||
let anilistId: number | null = 14813;
|
||||
let libraryFetches = 0;
|
||||
|
||||
apiClient.getAnimeLibrary = (async () => {
|
||||
libraryFetches += 1;
|
||||
return [libraryItem(anilistId)];
|
||||
}) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeDetail = (async () => detailData(anilistId)) as typeof apiClient.getAnimeDetail;
|
||||
apiClient.getAnimeWords = (async () => []) as typeof apiClient.getAnimeWords;
|
||||
apiClient.getAnimeRollups = (async () => []) as typeof apiClient.getAnimeRollups;
|
||||
apiClient.getAnimeKnownWordsSummary = (async () => ({
|
||||
totalUniqueWords: 0,
|
||||
knownWordCount: 0,
|
||||
})) as typeof apiClient.getAnimeKnownWordsSummary;
|
||||
apiClient.searchAnilist = (async () => [
|
||||
{
|
||||
id: 108489,
|
||||
episodes: 12,
|
||||
season: null,
|
||||
seasonYear: null,
|
||||
description: null,
|
||||
coverImage: null,
|
||||
title: { romaji: 'Test Anime Kan', english: null, native: null },
|
||||
},
|
||||
]) as typeof apiClient.searchAnilist;
|
||||
apiClient.reassignAnimeAnilist = (async (_animeId: number, info: { anilistId: number }) => {
|
||||
anilistId = info.anilistId;
|
||||
}) as typeof apiClient.reassignAnimeAnilist;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<AnimeTab />);
|
||||
});
|
||||
assert.equal(libraryFetches, 1);
|
||||
assert.match(
|
||||
container.querySelector('img')?.getAttribute('src') ?? '',
|
||||
/\/api\/stats\/anime\/7\/cover\?coverRetry=14813/,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Test Anime Season 2').click();
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Change AniList Entry').click();
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Test Anime Kan').click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Back to Library').click();
|
||||
});
|
||||
|
||||
assert.equal(libraryFetches, 2);
|
||||
assert.match(
|
||||
container.querySelector('img')?.getAttribute('src') ?? '',
|
||||
/\/api\/stats\/anime\/7\/cover\?coverRetry=108489/,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
@@ -99,6 +99,7 @@ export function AnimeTab({
|
||||
: undefined
|
||||
}
|
||||
onAnimeDeleted={reload}
|
||||
onAnilistRelinked={reload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user