From e0e9c182eee205630318a4d2ba3a2fb65ffc2cce Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 6 Jun 2026 15:12:07 -0700 Subject: [PATCH] fix(stats): detect cover MIME type, fix ankiConfig reuse, skip retiming - Cover routes detect JPEG/PNG/WebP from magic bytes instead of hardcoding image/jpeg - notesInfo builds previews with the same ankiConfig snapshot used for the fetch call - Skip alass retiming when secondary text is already present in the request body --- .../services/__tests__/stats-server.test.ts | 79 ++++++++++++++++++- src/core/services/stats-server.ts | 14 ++-- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index f354bca8..01b97ba9 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -950,6 +950,26 @@ describe('stats server API routes', () => { assert.equal(res.headers.get('cache-control'), 'public, max-age=86400'); }); + it('GET /api/stats/anime/:animeId/cover serves detected cover MIME type', async () => { + const app = createStatsApp( + createMockTracker({ + getAnimeCoverArt: async () => ({ + videoId: 1, + anilistId: 21858, + coverUrl: 'https://example.com/cover.png', + coverBlob: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + titleRomaji: 'Little Witch Academia', + titleEnglish: 'Little Witch Academia', + episodesTotal: 25, + fetchedAtMs: Date.now(), + }), + }), + ); + const res = await app.request('/api/stats/anime/1/cover'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/png'); + }); + it('GET /api/stats/anime/:animeId/cover returns 404 for missing anime', async () => { const app = createStatsApp(createMockTracker()); const res = await app.request('/api/stats/anime/99999/cover'); @@ -1371,8 +1391,12 @@ describe('stats server API routes', () => { fs.writeFileSync(sourcePath, 'fake media'); await withFakeAnkiConnect(async (requests, url) => { + let retimedCalls = 0; const options = { - resolveRetimedSecondarySubtitleText: async () => 'Aligned English subtitle', + resolveRetimedSecondarySubtitleText: async () => { + retimedCalls += 1; + return 'Aligned English subtitle'; + }, ankiConnectConfig: { url, deck: 'Mining', @@ -1416,6 +1440,7 @@ describe('stats server API routes', () => { addNoteRequest?.params?.note?.fields?.SelectionText, 'Stale stored English subtitle', ); + assert.equal(retimedCalls, 0); }); }); }); @@ -2634,6 +2659,52 @@ Aligned English subtitle } }); + it('POST /api/stats/anki/notesInfo builds previews with the same config used for fetch', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + result: [ + { + noteId: 444, + fields: { + TargetWord: { value: '一致' }, + OtherWord: { value: '不一致' }, + }, + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + )) as typeof fetch; + + try { + let configCalls = 0; + const app = createStatsApp(createMockTracker(), { + getAnkiConnectConfig: () => { + configCalls += 1; + return { + url: 'http://127.0.0.1:8765', + fields: { word: configCalls === 1 ? 'TargetWord' : 'OtherWord' }, + }; + }, + }); + const res = await app.request('/api/stats/anki/notesInfo', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ noteIds: [444] }), + }); + + assert.equal(res.status, 200); + assert.equal(configCalls, 1); + assert.equal((await res.json())[0].preview.word, '一致'); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('serves stats index and asset files from absolute static dir paths', async () => { await withTempDir(async (dir) => { const assetDir = path.join(dir, 'assets'); @@ -2667,7 +2738,9 @@ Aligned English subtitle videoId: 1, anilistId: 1, coverUrl: 'https://example.com/cover.jpg', - coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]), + coverBlob: Buffer.from([ + 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, + ]), titleRomaji: 'Test', titleEnglish: 'Test', episodesTotal: 12, @@ -2684,7 +2757,7 @@ Aligned English subtitle const res = await app.request('/api/stats/media/1/cover'); assert.equal(res.status, 200); - assert.equal(res.headers.get('content-type'), 'image/jpeg'); + assert.equal(res.headers.get('content-type'), 'image/webp'); assert.equal(ensureCalls, 1); }); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 0e64f9fd..c197a459 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -1013,9 +1013,10 @@ export function createStatsApp( if (animeId <= 0) return c.body(null, 404); const art = await tracker.getAnimeCoverArt(animeId); if (!art?.coverBlob) return c.body(null, 404); - return new Response(new Uint8Array(art.coverBlob), { + const bytes = new Uint8Array(art.coverBlob); + return new Response(bytes, { headers: { - 'Content-Type': 'image/jpeg', + 'Content-Type': detectImageContentType(bytes), 'Cache-Control': 'public, max-age=86400', }, }); @@ -1030,9 +1031,10 @@ export function createStatsApp( art = await tracker.getCoverArt(videoId); } if (!art?.coverBlob) return c.body(null, 404); - return new Response(new Uint8Array(art.coverBlob), { + const bytes = new Uint8Array(art.coverBlob); + return new Response(bytes, { headers: { - 'Content-Type': 'image/jpeg', + 'Content-Type': detectImageContentType(bytes), 'Cache-Control': 'public, max-age=604800', }, }); @@ -1110,7 +1112,7 @@ export function createStatsApp( return c.json( (result.result ?? []).map((note) => ({ ...note, - preview: buildAnkiNotePreview(note.fields, getAnkiConnectConfig()), + preview: buildAnkiNotePreview(note.fields, ankiConfig), })), ); } catch { @@ -1148,7 +1150,7 @@ export function createStatsApp( } const secondarySubtitleLanguages = getSecondarySubtitleLanguages(); let retimedSecondaryText = ''; - if (mode === 'sentence') { + if (mode === 'sentence' && !bodySecondaryText) { try { retimedSecondaryText = await ( options?.resolveRetimedSecondarySubtitleText ??