- 新任务 prewarmMetadata(网络道):拉基础详情、富详情(含标志)、各季详情、选中的剧集组, 海报 / 背景 / 标志原图进图片缓存;请求键与刷新元数据 / 刮削相同,之后直接命中且 pinned - enqueueWatchedRefresh(建订阅、用户搜索 / 手动识别、提交下载)每次额外入队预热,幂等键按作品 + 日期 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
128 lines
5.8 KiB
TypeScript
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" });
|
|
});
|
|
});
|