mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-11 04:46:07 -08:00
Compare commits
2
Commits
d5f887a834
...
ee15dbb572
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee15dbb572
|
||
|
|
8ac0d41d78
|
No files matched your search
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,3 +108,23 @@ body.overlay-mode #root {
|
||||
.animate-indeterminate {
|
||||
animation: indeterminate-sweep 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-fade-in {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
|
||||
/* Looping motion can't just be shortened the way a one-shot entrance can —
|
||||
that speeds it up rather than removing it. The delete indicator holds a
|
||||
static busy state instead: the bar fills its track and the spinner stops,
|
||||
leaving the toast text to carry the meaning. */
|
||||
.animate-indeterminate {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,25 @@ test('stats overlay mode paints an opaque full-viewport background', () => {
|
||||
/body\.overlay-mode #root\s*\{[^}]*background-color:\s*var\(--color-ctp-base\);/s,
|
||||
);
|
||||
});
|
||||
|
||||
test('reduced motion stops the looping delete indicator rather than speeding it up', () => {
|
||||
const reducedMotion = /@media \(prefers-reduced-motion: reduce\)\s*\{(?<body>.*)\n\}/s.exec(css)
|
||||
?.groups?.body;
|
||||
assert.ok(reducedMotion, 'expected a prefers-reduced-motion block');
|
||||
|
||||
// Shortening an infinite animation makes it run faster, so the looping rules
|
||||
// have to switch it off outright and hold a static state instead.
|
||||
assert.match(reducedMotion, /\.animate-indeterminate\s*\{[^}]*animation:\s*none;/s);
|
||||
assert.match(reducedMotion, /\.animate-indeterminate\s*\{[^}]*width:\s*100%;/s);
|
||||
assert.match(reducedMotion, /\.animate-spin\s*\{[^}]*animation:\s*none;/s);
|
||||
assert.doesNotMatch(reducedMotion, /\.animate-indeterminate\s*\{[^}]*animation-duration:/s);
|
||||
assert.doesNotMatch(reducedMotion, /\.animate-spin\s*\{[^}]*animation-duration:/s);
|
||||
});
|
||||
|
||||
test('the looping delete indicator keeps its animation for everyone else', () => {
|
||||
const beforeMediaQuery = css.slice(0, css.indexOf('@media (prefers-reduced-motion'));
|
||||
assert.match(
|
||||
beforeMediaQuery,
|
||||
/\.animate-indeterminate\s*\{\s*animation:\s*indeterminate-sweep/s,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user