From d253710c2e275c32ec5b18b508517767723e4dad Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 8 Jul 2026 00:59:35 -0700 Subject: [PATCH] fix(stats): fetch cover art eagerly at session start instead of on series page visit (#148) --- changes/eager-cover-art-fetch.md | 4 + .../services/__tests__/stats-server.test.ts | 70 ++++++- .../immersion-tracker-service.test.ts | 88 +++++++++ .../services/immersion-tracker-service.ts | 43 ++++- src/core/services/stats-cover-routes.ts | 171 ++++++++++++++++++ src/core/services/stats-server.ts | 120 +----------- 6 files changed, 375 insertions(+), 121 deletions(-) create mode 100644 changes/eager-cover-art-fetch.md create mode 100644 src/core/services/stats-cover-routes.ts diff --git a/changes/eager-cover-art-fetch.md b/changes/eager-cover-art-fetch.md new file mode 100644 index 00000000..f8b038db --- /dev/null +++ b/changes/eager-cover-art-fetch.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Cover art is now fetched eagerly when a new series starts playing, instead of waiting for the first visit to its series detail page, so the stats timeline shows the best-guess AniList image right away. The stats covers endpoint also backfills missing series art in the background, so existing series without an image pick one up on the next stats page load. diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 823fcca9..8d1a5c5c 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -301,6 +301,7 @@ function createMockTracker( { epochDay: Math.floor(Date.now() / 86_400_000) - 1, totalActiveMin: 30 }, { epochDay: Math.floor(Date.now() / 86_400_000), totalActiveMin: 45 }, ], + ensureAnimeCoverArt: async () => false, getAnimeCoverArt: async (animeId: number) => animeId === 1 ? { @@ -994,8 +995,9 @@ describe('stats server API routes', () => { assert.equal(res.status, 404); }); - it('POST /api/stats/covers batches stored cover art without fetching missing art', async () => { + it('POST /api/stats/covers batches stored cover art and backfills missing anime art in the background', async () => { let ensureCoverArtCalls = 0; + const ensureAnimeCoverArtCalls: number[] = []; const app = createStatsApp( createMockTracker({ getCoverArt: async (videoId: number) => @@ -1015,6 +1017,10 @@ describe('stats server API routes', () => { ensureCoverArtCalls += 1; return true; }, + ensureAnimeCoverArt: async (animeId: number) => { + ensureAnimeCoverArtCalls.push(animeId); + return false; + }, }), ); @@ -1042,6 +1048,68 @@ describe('stats server API routes', () => { }, }); assert.equal(ensureCoverArtCalls, 0); + assert.deepEqual(ensureAnimeCoverArtCalls, [99999]); + }); + + it('POST /api/stats/covers limits concurrent missing anime cover backfills', async () => { + let activeBackfills = 0; + let maxActiveBackfills = 0; + const pendingBackfills: Array<() => void> = []; + const app = createStatsApp( + createMockTracker({ + getAnimeCoverArt: async () => null, + ensureAnimeCoverArt: async () => { + activeBackfills += 1; + maxActiveBackfills = Math.max(maxActiveBackfills, activeBackfills); + await new Promise((resolve) => { + pendingBackfills.push(resolve); + }); + activeBackfills -= 1; + return false; + }, + }), + ); + + const res = await app.request('/api/stats/covers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ animeIds: [101, 102, 103, 104, 105] }), + }); + + assert.equal(res.status, 200); + assert.equal(maxActiveBackfills, 3); + for (const resolveBackfill of pendingBackfills) { + resolveBackfill(); + } + }); + + it('GET /api/stats/anime/:animeId/cover fetches missing art before serving', async () => { + let fetched = false; + const app = createStatsApp( + createMockTracker({ + getAnimeCoverArt: async () => + fetched + ? { + videoId: 1, + anilistId: 21858, + coverUrl: 'https://example.com/cover.jpg', + coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]), + titleRomaji: 'Little Witch Academia', + titleEnglish: 'Little Witch Academia', + episodesTotal: 25, + fetchedAtMs: Date.now(), + } + : null, + ensureAnimeCoverArt: async () => { + fetched = true; + return true; + }, + }), + ); + + const res = await app.request('/api/stats/anime/1/cover'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/jpeg'); }); it('GET /api/stats/anime/:animeId/words returns top words for an anime', async () => { diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index b08bdf11..413fdfa0 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -4041,3 +4041,91 @@ test('markActiveVideoWatched returns false when no active session', async () => cleanupDbPath(dbPath); } }); + +test('handleMediaChange prefetches cover art at session start', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + + const fetchedVideoIds: number[] = []; + tracker.setCoverArtFetcher({ + fetchIfMissing: async (_db, videoId) => { + fetchedVideoIds.push(videoId); + return false; + }, + }); + + tracker.handleMediaChange('/tmp/Little Witch Academia S02E05.mkv', 'Episode 5'); + await waitForPendingAnimeMetadata(tracker); + await waitForCondition(() => fetchedVideoIds.length > 0); + + const privateApi = tracker as unknown as { + sessionState: { videoId: number } | null; + }; + assert.deepEqual(fetchedVideoIds, [privateApi.sessionState?.videoId]); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test('ensureAnimeCoverArt fetches art via the latest video of the anime', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + const privateApi = tracker as unknown as { db: DatabaseSync }; + + privateApi.db.exec(` + INSERT INTO imm_anime ( + anime_id, + normalized_title_key, + canonical_title, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES ( + 1, + 'little witch academia', + 'Little Witch Academia', + 1000, + 1000 + ); + INSERT INTO imm_videos ( + video_id, + video_key, + canonical_title, + source_type, + duration_ms, + anime_id, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + (1, 'local:/tmp/lwa-1.mkv', 'Little Witch Academia S01E01', 1, 0, 1, 1000, 1000), + (2, 'local:/tmp/lwa-2.mkv', 'Little Witch Academia S01E02', 1, 0, 1, 1000, 1000); + `); + + const fetchedVideoIds: number[] = []; + tracker.setCoverArtFetcher({ + fetchIfMissing: async (_db, videoId) => { + fetchedVideoIds.push(videoId); + return false; + }, + }); + + const result = await tracker.ensureAnimeCoverArt(1); + assert.equal(result, false); + assert.deepEqual(fetchedVideoIds, [2]); + + const missing = await tracker.ensureAnimeCoverArt(999); + assert.equal(missing, false); + assert.deepEqual(fetchedVideoIds, [2]); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 225c5cb2..85f57f92 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -854,6 +854,22 @@ export class ImmersionTrackerService { this.coverArtFetcher = fetcher; } + async ensureAnimeCoverArt(animeId: number): Promise { + const existing = await this.getAnimeCoverArt(animeId); + if (existing?.coverBlob) { + return true; + } + const row = this.db + .prepare( + 'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ? ORDER BY video_id DESC LIMIT 1', + ) + .get(animeId) as { videoId: number } | undefined; + if (!row?.videoId) { + return false; + } + return this.ensureCoverArt(row.videoId); + } + async ensureCoverArt(videoId: number): Promise { const existing = await this.getCoverArt(videoId); if (existing?.coverBlob) { @@ -879,8 +895,10 @@ export class ImmersionTrackerService { } const fetchPromise = (async () => { - const detail = getMediaDetail(this.db, videoId); - const canonicalTitle = detail?.canonicalTitle?.trim(); + const titleRow = this.db + .prepare('SELECT canonical_title AS canonicalTitle FROM imm_videos WHERE video_id = ?') + .get(videoId) as { canonicalTitle: string | null } | undefined; + const canonicalTitle = titleRow?.canonicalTitle?.trim(); if (!canonicalTitle) { return false; } @@ -1342,6 +1360,9 @@ export class ImmersionTrackerService { } else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) { this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null); } + if (!youtubeVideoId) { + this.prefetchCoverArtAsync(sessionInfo.videoId); + } this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath); } @@ -1924,6 +1945,24 @@ export class ImmersionTrackerService { }); } + // Fetch cover art eagerly at session start (after anime metadata parsing + // settles) so new series show art on the stats timeline without requiring a + // visit to the series detail page first. + private prefetchCoverArtAsync(videoId: number): void { + const pendingMetadata = this.pendingAnimeMetadataUpdates.get(videoId); + void (async () => { + try { + await pendingMetadata; + if (this.isDestroyed) { + return; + } + await this.ensureCoverArt(videoId); + } catch (error) { + this.logger.warn('Unable to prefetch cover art', (error as Error).message); + } + })(); + } + private updateVideoTitleForActiveSession(canonicalTitle: string): void { if (!this.sessionState) return; updateVideoTitleRecord(this.db, this.sessionState.videoId, canonicalTitle); diff --git a/src/core/services/stats-cover-routes.ts b/src/core/services/stats-cover-routes.ts new file mode 100644 index 00000000..4aec4906 --- /dev/null +++ b/src/core/services/stats-cover-routes.ts @@ -0,0 +1,171 @@ +import type { Hono } from 'hono'; +import type { ImmersionTrackerService } from './immersion-tracker-service.js'; + +type StatsCoverImagePayload = { + contentType: string; + dataUrl: string; +} | null; + +type StatsCoverBatchBody = { + animeIds?: unknown; + videoIds?: unknown; +}; + +const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3; + +function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number { + if (raw === undefined) return fallback; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) { + return fallback; + } + const parsed = Math.floor(n); + return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); +} + +function parsePositiveIdList(raw: unknown, maxItems = 100): number[] { + if (!Array.isArray(raw)) return []; + + const ids = new Set(); + for (const rawId of raw) { + const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN; + if (Number.isFinite(id) && id > 0) { + ids.add(Math.floor(id)); + if (ids.size >= maxItems) break; + } + } + + return Array.from(ids).sort((a, b) => a - b); +} + +function coverImagePayload( + art: { coverBlob?: Uint8Array | null } | null | undefined, +): StatsCoverImagePayload { + if (!art?.coverBlob) return null; + const bytes = new Uint8Array(art.coverBlob); + const contentType = detectImageContentType(bytes); + return { + contentType, + dataUrl: `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`, + }; +} + +function detectImageContentType(bytes: Uint8Array): string { + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 + ) { + return 'image/png'; + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg'; + } + if ( + bytes.length >= 12 && + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + return 'image/webp'; + } + return 'application/octet-stream'; +} + +function createLimitedTaskRunner(maxConcurrentTasks: number): (task: () => Promise) => void { + const queue: Array<() => Promise> = []; + let activeTasks = 0; + + const drain = (): void => { + while (activeTasks < maxConcurrentTasks && queue.length > 0) { + const task = queue.shift(); + if (!task) return; + activeTasks += 1; + void task() + .catch(() => {}) + .finally(() => { + activeTasks -= 1; + drain(); + }); + } + }; + + return (task: () => Promise): void => { + queue.push(task); + drain(); + }; +} + +export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerService): void { + const enqueueAnimeCoverBackfill = createLimitedTaskRunner(MAX_BACKGROUND_ANIME_COVER_FETCHES); + + app.post('/api/stats/covers', async (c) => { + const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null; + const animeIds = parsePositiveIdList(body?.animeIds); + const videoIds = parsePositiveIdList(body?.videoIds); + const anime: Record = {}; + const media: Record = {}; + + await Promise.all( + animeIds.map(async (animeId) => { + const art = await tracker.getAnimeCoverArt(animeId); + if (!art?.coverBlob) { + enqueueAnimeCoverBackfill(async () => { + await tracker.ensureAnimeCoverArt(animeId); + }); + } + anime[animeId] = coverImagePayload(art); + }), + ); + await Promise.all( + videoIds.map(async (videoId) => { + media[videoId] = coverImagePayload(await tracker.getCoverArt(videoId)); + }), + ); + + return c.json({ anime, media }); + }); + + app.get('/api/stats/anime/:animeId/cover', async (c) => { + const animeId = parseIntQuery(c.req.param('animeId'), 0); + if (animeId <= 0) return c.body(null, 404); + let art = await tracker.getAnimeCoverArt(animeId); + if (!art?.coverBlob) { + await tracker.ensureAnimeCoverArt(animeId); + 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', + }, + }); + }); + + app.get('/api/stats/media/:videoId/cover', async (c) => { + const videoId = parseIntQuery(c.req.param('videoId'), 0); + if (videoId <= 0) return c.body(null, 404); + let art = await tracker.getCoverArt(videoId); + if (!art?.coverBlob) { + await tracker.ensureCoverArt(videoId); + 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', + }, + }); + }); +} diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 24c0c38a..b1f28050 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -17,6 +17,7 @@ import { } from '../../anki-field-config.js'; import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js'; import type { AnilistRateLimiter } from './anilist/rate-limiter.js'; +import { registerStatsCoverRoutes } from './stats-cover-routes.js'; import { resolveRetimedSecondarySubtitleTextFromSidecar, resolveSecondarySubtitleTextFromSidecar, @@ -51,16 +52,6 @@ type StatsExcludedWordPayload = { reading: string; }; -type StatsCoverImagePayload = { - contentType: string; - dataUrl: string; -} | null; - -type StatsCoverBatchBody = { - animeIds?: unknown; - videoIds?: unknown; -}; - function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number { if (raw === undefined) return fallback; const n = Number(raw); @@ -113,62 +104,6 @@ function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | nul return words; } -function parsePositiveIdList(raw: unknown, maxItems = 100): number[] { - if (!Array.isArray(raw)) return []; - - const ids = new Set(); - for (const rawId of raw) { - const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN; - if (Number.isFinite(id) && id > 0) { - ids.add(Math.floor(id)); - if (ids.size >= maxItems) break; - } - } - - return Array.from(ids).sort((a, b) => a - b); -} - -function coverImagePayload( - art: { coverBlob?: Uint8Array | null } | null | undefined, -): StatsCoverImagePayload { - if (!art?.coverBlob) return null; - const bytes = new Uint8Array(art.coverBlob); - const contentType = detectImageContentType(bytes); - return { - contentType, - dataUrl: `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`, - }; -} - -function detectImageContentType(bytes: Uint8Array): string { - if ( - bytes.length >= 8 && - bytes[0] === 0x89 && - bytes[1] === 0x50 && - bytes[2] === 0x4e && - bytes[3] === 0x47 - ) { - return 'image/png'; - } - if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { - return 'image/jpeg'; - } - if ( - bytes.length >= 12 && - bytes[0] === 0x52 && - bytes[1] === 0x49 && - bytes[2] === 0x46 && - bytes[3] === 0x46 && - bytes[8] === 0x57 && - bytes[9] === 0x45 && - bytes[10] === 0x42 && - bytes[11] === 0x50 - ) { - return 'image/webp'; - } - return 'application/octet-stream'; -} - function resolveStatsNoteFieldName( noteInfo: StatsServerNoteInfo, ...preferredNames: (string | undefined)[] @@ -1017,58 +952,7 @@ export function createStatsApp( return c.json({ ok: true }); }); - app.post('/api/stats/covers', async (c) => { - const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null; - const animeIds = parsePositiveIdList(body?.animeIds); - const videoIds = parsePositiveIdList(body?.videoIds); - const anime: Record = {}; - const media: Record = {}; - - await Promise.all( - animeIds.map(async (animeId) => { - anime[animeId] = coverImagePayload(await tracker.getAnimeCoverArt(animeId)); - }), - ); - await Promise.all( - videoIds.map(async (videoId) => { - media[videoId] = coverImagePayload(await tracker.getCoverArt(videoId)); - }), - ); - - return c.json({ anime, media }); - }); - - app.get('/api/stats/anime/:animeId/cover', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 404); - const 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', - }, - }); - }); - - app.get('/api/stats/media/:videoId/cover', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 404); - let art = await tracker.getCoverArt(videoId); - if (!art?.coverBlob) { - await tracker.ensureCoverArt(videoId); - 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', - }, - }); - }); + registerStatsCoverRoutes(app, tracker); app.get('/api/stats/episode/:videoId/detail', async (c) => { const videoId = parseIntQuery(c.req.param('videoId'), 0);