fix(stats): fetch cover art eagerly at session start instead of on series page visit (#148)

This commit is contained in:
2026-07-08 00:59:35 -07:00
committed by GitHub
parent c3df510e4f
commit d253710c2e
6 changed files with 375 additions and 121 deletions
@@ -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<void>((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 () => {
@@ -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);
}
});
+41 -2
View File
@@ -854,6 +854,22 @@ export class ImmersionTrackerService {
this.coverArtFetcher = fetcher;
}
async ensureAnimeCoverArt(animeId: number): Promise<boolean> {
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<boolean> {
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);
+171
View File
@@ -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<number>();
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>) => void {
const queue: Array<() => Promise<void>> = [];
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>): 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<number, StatsCoverImagePayload> = {};
const media: Record<number, StatsCoverImagePayload> = {};
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',
},
});
});
}
+2 -118
View File
@@ -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<number>();
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<number, StatsCoverImagePayload> = {};
const media: Record<number, StatsCoverImagePayload> = {};
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);