Files
rootandClaude Opus 5.5 1535d8664e feat(metadata): 作品进入关注时预热元数据缓存
- 新任务 prewarmMetadata(网络道):拉基础详情、富详情(含标志)、各季详情、选中的剧集组,
  海报 / 背景 / 标志原图进图片缓存;请求键与刷新元数据 / 刮削相同,之后直接命中且 pinned
- enqueueWatchedRefresh(建订阅、用户搜索 / 手动识别、提交下载)每次额外入队预热,幂等键按作品 + 日期

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 19:37:34 +08:00

128 lines
5.8 KiB
TypeScript

import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTmdbClient } from "@umt/adapters";
import { sql } from "@umt/db";
import { createMockTmdb, type MockTmdb, SPLIT_COUR_FIXTURE, TMDB_FIXTURES } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { tmdbSharedCache } from "../../src/modules/sources-identity/runtime.ts";
import { prewarmMetadata } from "../../src/modules/sources-identity/service/metadata-prewarm.ts";
import { enqueueWatchedRefresh } from "../../src/modules/sources-identity/service/watched.ts";
import { tmdbCachePolicy } from "../../src/shared-cache.ts";
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
// 关注作品预热:进入关注时入队 prewarmMetadata(幂等键按作品 + 日期);执行后详情(含标志)、各季、选中的剧集组
// 在 TMDB 请求缓存里且 pinned,海报 / 背景 / 标志进图片缓存;再跑一次全部命中,不发请求。
const skip = !(await pgAvailable());
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]);
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1]);
describe.skipIf(skip)("关注作品预热", () => {
let h: Harness;
let tmdb: MockTmdb;
let entityId: string;
const images: string[] = [];
const dataDir = mkdtempSync(join(tmpdir(), "umt-prewarm-"));
beforeAll(async () => {
tmdb = await createMockTmdb({
fixtures: [
...TMDB_FIXTURES,
{
...SPLIT_COUR_FIXTURE,
rich: { ...SPLIT_COUR_FIXTURE.rich, backdrop_path: "/split-bg.jpg" },
logos: [
{ file_path: "/split-en.png", iso_639_1: "en" },
{ file_path: "/split-ja.png", iso_639_1: "ja" },
],
},
],
});
let policy: Awaited<ReturnType<typeof tmdbCachePolicy>> | null = null;
h = await createHarness("prewarm", {
// 和服务端一样走统一元数据缓存(PG 请求缓存)
tmdb: () =>
createTmdbClient({
apiKey: "test-tmdb-key",
baseUrl: `${tmdb.url}/3`,
retries: 0,
sharedCache: tmdbSharedCache(h.ctx, policy!),
}),
// 图片上游(image.tmdb.org)
fetch: async (input) => {
const url = String(input);
images.push(url);
return new Response(url.endsWith(".png") ? PNG : JPEG);
},
});
policy = await tmdbCachePolicy(h.ctx);
(h.ctx.config as { DATA_DIR?: string }).DATA_DIR = dataDir;
entityId = (
await sql<{ id: string }>`insert into media_entity (kind, title, episode_group_id, episode_group_name)
values ('series', '分割番', 'grp-production', '制作季') returning id`.execute(h.db)
).rows[0]!.id;
await sql`insert into external_identity (media_entity_id, provider, entity_type, external_id, status)
values (${entityId}, 'tmdb', 'tv', ${String(SPLIT_COUR_FIXTURE.id)}, 'accepted')`.execute(h.db);
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
rmSync(dataDir, { recursive: true, force: true });
});
it("进入关注(建订阅)时入队预热,幂等键按作品 + 日期;同一天再进入不重复排", async () => {
await sql`insert into subscription (media_entity_id, media_kind, status) values (${entityId}, 'series', 'active')`.execute(
h.db,
);
await enqueueWatchedRefresh(h.ctx, entityId, "subscription");
await enqueueWatchedRefresh(h.ctx, entityId, "download");
const jobs = h.engine.enqueued.filter((e) => e.task === "prewarmMetadata");
expect(jobs).toHaveLength(1);
expect(jobs[0]).toMatchObject({
input: { mediaEntityId: entityId },
key: `prewarmMetadata:${entityId}:${new Date().toISOString().slice(0, 10)}`,
});
});
it("执行后:详情 / 各季 / 剧集组 / 标志在缓存里且 pinned;海报、背景、标志进图片缓存", async () => {
const r = await prewarmMetadata(h.rt, entityId);
expect(r).toMatchObject({ status: "warmed", episodeGroup: true, images: 3, errors: [] });
expect(r.seasons).toBe(SPLIT_COUR_FIXTURE.seasons!.length);
const rows = (
await sql<{ key: string; pinned: boolean }>`select key, pinned from kv_cache where region = 'tmdb.request'
order by key`.execute(h.db)
).rows;
const has = (re: RegExp) => rows.filter((x) => re.test(x.key));
const id = SPLIT_COUR_FIXTURE.id;
expect(has(new RegExp(`^/tv/${id}\\?.*images`)).length).toBeGreaterThan(0);
for (const s of SPLIT_COUR_FIXTURE.seasons!)
expect(has(new RegExp(`^/tv/${id}/season/${s.season_number}\\?`)).length).toBeGreaterThan(0);
expect(has(/^\/tv\/episode_group\/grp-production\?/)).toHaveLength(1);
// 原语言是日语:另取了日语标志
expect(has(new RegExp(`^/tv/${id}/images\\?include_image_language=ja`))).toHaveLength(1);
expect(rows.every((x) => x.pinned)).toBe(true);
for (const f of ["split.jpg", "split-bg.jpg", "split-ja.png"])
expect(existsSync(join(dataDir, "cache/images/t/p/original", f))).toBe(true);
expect(images.every((u) => u.startsWith("https://image.tmdb.org/t/p/original/"))).toBe(true);
});
it("再预热一次全部命中:不发 TMDB 请求、不再下载图片", async () => {
const calls = tmdb.calls.length;
const fetched = images.length;
const r = await prewarmMetadata(h.rt, entityId);
expect(r.status).toBe("warmed");
expect(tmdb.calls.length).toBe(calls);
expect(images.length).toBe(fetched);
});
it("没有 TMDB 身份的作品跳过", async () => {
const other = (
await sql<{ id: string }>`insert into media_entity (kind, title) values ('movie', '本地') returning id`.execute(
h.db,
)
).rows[0]!.id;
expect(await prewarmMetadata(h.rt, other)).toMatchObject({ status: "skipped", reason: "no_tmdb_identity" });
});
});