diff --git a/changes/fix-stats-library-cover-after-relink.md b/changes/fix-stats-library-cover-after-relink.md new file mode 100644 index 00000000..8527aa2a --- /dev/null +++ b/changes/fix-stats-library-cover-after-relink.md @@ -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. diff --git a/docs-site/anilist-integration.md b/docs-site/anilist-integration.md index e924e61b..91cce19e 100644 --- a/docs-site/anilist-integration.md +++ b/docs-site/anilist-integration.md @@ -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. diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 5c71c24f..e70ccdf9 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -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 () => { diff --git a/src/core/services/stats-cover-routes.ts b/src/core/services/stats-cover-routes.ts index dd6c0f43..3e70f4f3 100644 --- a/src/core/services/stats-cover-routes.ts +++ b/src/core/services/stats-cover-routes.ts @@ -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, ifNoneMatch: string | undefined): Response { + const etag = `"${createHash('sha256').update(bytes).digest('hex')}"`; + const headers: Record = { + '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 { const queue: Array<() => Promise> = []; 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')); }); } diff --git a/stats/src/components/anime/AnimeDetailView.tsx b/stats/src/components/anime/AnimeDetailView.tsx index b8e28518..fa66c810 100644 --- a/stats/src/components/anime/AnimeDetailView.tsx +++ b/stats/src/components/anime/AnimeDetailView.tsx @@ -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?.(); }} /> )} diff --git a/stats/src/components/anime/AnimeTab.test.tsx b/stats/src/components/anime/AnimeTab.test.tsx new file mode 100644 index 00000000..c02d9590 --- /dev/null +++ b/stats/src/components/anime/AnimeTab.test.tsx @@ -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(); + }); + 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(); + } +}); diff --git a/stats/src/components/anime/AnimeTab.tsx b/stats/src/components/anime/AnimeTab.tsx index 46db040c..ea004be5 100644 --- a/stats/src/components/anime/AnimeTab.tsx +++ b/stats/src/components/anime/AnimeTab.tsx @@ -99,6 +99,7 @@ export function AnimeTab({ : undefined } onAnimeDeleted={reload} + onAnilistRelinked={reload} /> ); }