mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
fix(stats): fetch cover art eagerly at session start instead of on series page visit (#148)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user