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
This commit is contained in:
2026-06-06 15:12:07 -07:00
parent 3c3bf3bb18
commit e0e9c182ee
2 changed files with 84 additions and 9 deletions
@@ -950,6 +950,26 @@ describe('stats server API routes', () => {
assert.equal(res.headers.get('cache-control'), 'public, max-age=86400'); 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 () => { it('GET /api/stats/anime/:animeId/cover returns 404 for missing anime', async () => {
const app = createStatsApp(createMockTracker()); const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anime/99999/cover'); const res = await app.request('/api/stats/anime/99999/cover');
@@ -1371,8 +1391,12 @@ describe('stats server API routes', () => {
fs.writeFileSync(sourcePath, 'fake media'); fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(async (requests, url) => { await withFakeAnkiConnect(async (requests, url) => {
let retimedCalls = 0;
const options = { const options = {
resolveRetimedSecondarySubtitleText: async () => 'Aligned English subtitle', resolveRetimedSecondarySubtitleText: async () => {
retimedCalls += 1;
return 'Aligned English subtitle';
},
ankiConnectConfig: { ankiConnectConfig: {
url, url,
deck: 'Mining', deck: 'Mining',
@@ -1416,6 +1440,7 @@ describe('stats server API routes', () => {
addNoteRequest?.params?.note?.fields?.SelectionText, addNoteRequest?.params?.note?.fields?.SelectionText,
'Stale stored English subtitle', '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 () => { it('serves stats index and asset files from absolute static dir paths', async () => {
await withTempDir(async (dir) => { await withTempDir(async (dir) => {
const assetDir = path.join(dir, 'assets'); const assetDir = path.join(dir, 'assets');
@@ -2667,7 +2738,9 @@ Aligned English subtitle
videoId: 1, videoId: 1,
anilistId: 1, anilistId: 1,
coverUrl: 'https://example.com/cover.jpg', 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', titleRomaji: 'Test',
titleEnglish: 'Test', titleEnglish: 'Test',
episodesTotal: 12, episodesTotal: 12,
@@ -2684,7 +2757,7 @@ Aligned English subtitle
const res = await app.request('/api/stats/media/1/cover'); const res = await app.request('/api/stats/media/1/cover');
assert.equal(res.status, 200); 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); assert.equal(ensureCalls, 1);
}); });
+8 -6
View File
@@ -1013,9 +1013,10 @@ export function createStatsApp(
if (animeId <= 0) return c.body(null, 404); if (animeId <= 0) return c.body(null, 404);
const art = await tracker.getAnimeCoverArt(animeId); const art = await tracker.getAnimeCoverArt(animeId);
if (!art?.coverBlob) return c.body(null, 404); 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: { headers: {
'Content-Type': 'image/jpeg', 'Content-Type': detectImageContentType(bytes),
'Cache-Control': 'public, max-age=86400', 'Cache-Control': 'public, max-age=86400',
}, },
}); });
@@ -1030,9 +1031,10 @@ export function createStatsApp(
art = await tracker.getCoverArt(videoId); art = await tracker.getCoverArt(videoId);
} }
if (!art?.coverBlob) return c.body(null, 404); 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: { headers: {
'Content-Type': 'image/jpeg', 'Content-Type': detectImageContentType(bytes),
'Cache-Control': 'public, max-age=604800', 'Cache-Control': 'public, max-age=604800',
}, },
}); });
@@ -1110,7 +1112,7 @@ export function createStatsApp(
return c.json( return c.json(
(result.result ?? []).map((note) => ({ (result.result ?? []).map((note) => ({
...note, ...note,
preview: buildAnkiNotePreview(note.fields, getAnkiConnectConfig()), preview: buildAnkiNotePreview(note.fields, ankiConfig),
})), })),
); );
} catch { } catch {
@@ -1148,7 +1150,7 @@ export function createStatsApp(
} }
const secondarySubtitleLanguages = getSecondarySubtitleLanguages(); const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
let retimedSecondaryText = ''; let retimedSecondaryText = '';
if (mode === 'sentence') { if (mode === 'sentence' && !bodySecondaryText) {
try { try {
retimedSecondaryText = await ( retimedSecondaryText = await (
options?.resolveRetimedSecondarySubtitleText ?? options?.resolveRetimedSecondarySubtitleText ??