- 策略表「来源 × 资源类型」(metaCachePolicy):搜索 / 识别 7 天(空 1 天)、详情 / 季 / 剧集组 30 天(空 1 天)、 榜单 / 热门 / 每日放送 6 小时(空 1 小时)、人物 / 图片 90 天;取值来自 storage.cacheTtl, 追加 tmdbListHours / tmdbListEmptyHours / imageDays / metaDetailDays / metaMissDays,metaSearchDays 缺省改 7 天 - TMDB 客户端共享缓存端口加 fetch(带请求类别与空结果判定),服务端整条读取交给 MetadataCache; 识别缓存、douban.request / bangumi.request、douban.subject / bangumi.subject、meta.subject_miss、discover.match 都走这一层 - 豆瓣 / bgm 条目只在「不存在」时记查无,网络失败 / 被拦不缓存(本进程冷却 30 分钟) - 关注中作品(isEntityWatched 口径,含选中的剧集组)的 TMDB 条目写入 pinned、命中续期 - 各进程启动 LISTEN 失效通知并每分钟把命中计数写 cache_stat;GET /cache/regions 给元数据 region 带近 7 天命中统计 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
289 lines
13 KiB
TypeScript
289 lines
13 KiB
TypeScript
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
compareNames,
|
|
createTmdbClient,
|
|
matchTmdb,
|
|
matchTmdbMulti,
|
|
type TmdbConfig,
|
|
TmdbError,
|
|
tmdbRequestKind,
|
|
yearRange,
|
|
} from "./index.ts";
|
|
|
|
let mock: MockTmdb | null = null;
|
|
afterEach(async () => {
|
|
await mock?.close();
|
|
mock = null;
|
|
});
|
|
|
|
async function setup(extra: Partial<TmdbConfig> = {}) {
|
|
mock = await createMockTmdb();
|
|
const tmdb = createTmdbClient({
|
|
baseUrl: `${mock.url}/3`,
|
|
apiKey: "test-tmdb-key",
|
|
retries: 2,
|
|
retryBaseMs: 5,
|
|
timeoutMs: 300,
|
|
...extra,
|
|
});
|
|
return { mock, tmdb };
|
|
}
|
|
|
|
describe("TmdbClient 搜索与详情", () => {
|
|
it("中文搜索电影,中英标题合并为一个候选", async () => {
|
|
const { tmdb } = await setup();
|
|
const r = await tmdb.search("奥本海默", { type: "movie" });
|
|
expect(r).toHaveLength(1);
|
|
expect(r[0]).toMatchObject({
|
|
provider: "tmdb",
|
|
entityType: "movie",
|
|
externalId: "872585",
|
|
year: 2023,
|
|
posterPath: "/oppenheimer.jpg",
|
|
});
|
|
expect(r[0]!.title).toBe("奥本海默");
|
|
expect(r[0]!.titles.map((t) => `${t.kind}:${t.lang}:${t.text}`)).toEqual([
|
|
"localized:zh-CN:奥本海默",
|
|
"original:en:Oppenheimer",
|
|
"localized:en-US:Oppenheimer",
|
|
]);
|
|
});
|
|
|
|
it("英文搜剧 + 年份过滤", async () => {
|
|
const { tmdb } = await setup();
|
|
const r = await tmdb.search("the bear", { type: "tv" });
|
|
expect(r.map((c) => c.externalId)).toEqual(["136315"]);
|
|
expect(await tmdb.search("the bear", { type: "tv", year: 2019 })).toEqual([]);
|
|
const all = await tmdb.searchAll("流浪地球");
|
|
expect(all.map((c) => `${c.entityType}:${c.externalId}`)).toEqual(["movie:842675"]);
|
|
});
|
|
|
|
it("电影详情:别名与外部 ID", async () => {
|
|
const { tmdb } = await setup();
|
|
const d = await tmdb.details("movie", 693134);
|
|
expect(d.detailed).toBe(true);
|
|
expect(d.title).toBe("沙丘2");
|
|
expect(d.aliases).toEqual(["Dune 2", "沙丘:第二部", "沙丘:第二部", "沙丘2", "Dune: Part Two"]);
|
|
expect(d.titles.find((t) => t.kind === "alias" && t.region === "TW")?.text).toBe("沙丘:第二部");
|
|
expect(d.externalIds).toEqual({ imdb: "tt15239678" });
|
|
expect(d.seasons).toBeUndefined();
|
|
});
|
|
|
|
it("剧集详情:季列表与集列表", async () => {
|
|
const { tmdb } = await setup();
|
|
const d = await tmdb.details("tv", 136315);
|
|
expect(d.seasons?.map((s) => [s.seasonNumber, s.episodeCount])).toEqual([
|
|
[1, 8],
|
|
[2, 10],
|
|
[3, 10],
|
|
]);
|
|
expect(d.externalIds).toEqual({ imdb: "tt14452776", tvdb: "401654" });
|
|
const s2 = await tmdb.season(136315, 2);
|
|
expect(s2.episodes).toHaveLength(10);
|
|
expect(s2.episodes[0]).toMatchObject({ seasonNumber: 2, episodeNumber: 1, name: "第 1 集" });
|
|
});
|
|
|
|
it("国产综艺:特别篇季 0、按期命名、简称别名可召回", async () => {
|
|
const { tmdb } = await setup();
|
|
const r = await tmdb.search("向往", { type: "tv" });
|
|
expect(r.map((c) => c.externalId)).toEqual(["900001"]);
|
|
const d = await tmdb.details("tv", 900001);
|
|
expect(d.originalLanguage).toBe("zh");
|
|
expect(d.seasons?.map((s) => s.seasonNumber)).toEqual([0, 1, 2]);
|
|
expect(d.externalIds).toEqual({});
|
|
const s1 = await tmdb.season(900001, 1);
|
|
expect(s1.episodes.at(-1)).toMatchObject({ episodeNumber: 12, name: "第 12 期" });
|
|
});
|
|
});
|
|
|
|
describe("TmdbClient 缓存/限流/错误分类", () => {
|
|
it("LRU+TTL 缓存:命中不发请求,过期后重新拉取", async () => {
|
|
let now = 1_000_000;
|
|
const { mock, tmdb } = await setup({ now: () => now, cache: { ttlMs: 1000, max: 100 } });
|
|
await tmdb.details("movie", 872585);
|
|
const n1 = mock.calls.length;
|
|
await tmdb.details("movie", 872585);
|
|
expect(mock.calls.length).toBe(n1);
|
|
expect(tmdb.cacheStats().hits).toBeGreaterThan(0);
|
|
now += 5000;
|
|
await tmdb.details("movie", 872585);
|
|
expect(mock.calls.length).toBe(n1 * 2);
|
|
});
|
|
|
|
it("共享响应缓存:给了 sharedCache 就走它,key 不含凭证,空结果 30 分钟,其余按 ttlMs", async () => {
|
|
const store = new Map<string, { v: unknown; ttl: number }>();
|
|
const { mock, tmdb } = await setup({
|
|
cache: { ttlMs: 7 * 3600_000 },
|
|
sharedCache: {
|
|
get: async (key) => store.get(key)?.v ?? null,
|
|
set: async (key, v, ttl) => {
|
|
store.set(key, { v, ttl });
|
|
},
|
|
},
|
|
});
|
|
await tmdb.details("movie", 872585);
|
|
const n1 = mock.calls.length;
|
|
await tmdb.details("movie", 872585);
|
|
expect(mock.calls.length).toBe(n1);
|
|
await tmdb.search("不存在的片名xyz", { type: "movie" });
|
|
const keys = [...store.keys()];
|
|
expect(keys.some((k) => k.includes("test-tmdb-key"))).toBe(false);
|
|
const detailKey = keys.find((k) => k.startsWith("/movie/872585?"))!;
|
|
expect(store.get(detailKey)!.ttl).toBe(7 * 3600_000);
|
|
const emptyKey = keys.find((k) => k.startsWith("/search/movie?"))!;
|
|
expect(store.get(emptyKey)!.ttl).toBe(30 * 60_000);
|
|
expect(tmdb.cacheStats().size).toBe(0);
|
|
});
|
|
|
|
it("共享缓存给了 fetch:整条读取交给它,带请求类别(搜索 / 详情 / 列表 / 图片)与空结果判定;失败不进缓存", async () => {
|
|
const seen: { key: string; kind: string; empty: boolean }[] = [];
|
|
const store = new Map<string, unknown>();
|
|
const { mock, tmdb } = await setup({
|
|
sharedCache: {
|
|
get: async () => null,
|
|
set: async () => {},
|
|
fetch: async (req, load) => {
|
|
if (store.has(req.key)) return store.get(req.key);
|
|
const v = await load();
|
|
seen.push({ key: req.key, kind: req.kind, empty: req.isEmpty(v) });
|
|
store.set(req.key, v);
|
|
return v;
|
|
},
|
|
},
|
|
});
|
|
await tmdb.details("movie", 872585);
|
|
await tmdb.details("movie", 872585);
|
|
await tmdb.search("不存在的片名xyz", { type: "movie" });
|
|
await tmdb.getJson("/movie/872585/images", {});
|
|
await tmdb.getJson("/trending/all/week", {});
|
|
await expect(tmdb.getJson("/movie/999999999", {})).rejects.toThrow();
|
|
const byPath = new Map(seen.map((x) => [x.key.split("?")[0], [x.kind, x.empty]]));
|
|
expect(Object.fromEntries(byPath)).toEqual({
|
|
"/movie/872585": ["detail", false],
|
|
"/search/movie": ["search", true],
|
|
"/movie/872585/images": ["image", false],
|
|
"/trending/all/week": ["list", false],
|
|
});
|
|
const detailCalls = mock.callsTo("/3/movie/872585").length;
|
|
await tmdb.details("movie", 872585);
|
|
expect(mock.callsTo("/3/movie/872585")).toHaveLength(detailCalls);
|
|
expect(seen.some((x) => x.key.startsWith("/movie/999999999"))).toBe(false);
|
|
expect(tmdbRequestKind("/person/1")).toBe("image");
|
|
expect(tmdbRequestKind("/tv/1/season/2")).toBe("detail");
|
|
expect(tmdbRequestKind("/find/tt1")).toBe("search");
|
|
});
|
|
|
|
it("并发合并:同一请求并发只发一次", async () => {
|
|
const { mock, tmdb } = await setup();
|
|
await Promise.all(Array.from({ length: 5 }, () => tmdb.season(100088, 1)));
|
|
expect(mock.callsTo("/3/tv/100088/season/1")).toHaveLength(1);
|
|
expect(tmdb.cacheStats().coalesced).toBe(4);
|
|
});
|
|
|
|
it("429 按 Retry-After 退避后成功;持续 429 → rate", async () => {
|
|
const { mock, tmdb } = await setup();
|
|
mock.fault("/3/movie/842675", { status: 429, headers: { "retry-after": "0" }, times: 1 });
|
|
const d = await tmdb.details("movie", 842675);
|
|
expect(d.title).toBe("流浪地球2");
|
|
mock.fault("/3/tv/100088", { status: 429, headers: { "retry-after": "0" }, times: 10 });
|
|
const e = await tmdb.details("tv", 100088).catch((x) => x);
|
|
expect(e).toBeInstanceOf(TmdbError);
|
|
expect(e.kind).toBe("rate");
|
|
});
|
|
|
|
it("失败不进缓存:5xx 后重试成功,notfound 分类", async () => {
|
|
const { mock, tmdb } = await setup({ retries: 0 });
|
|
mock.fault("/3/tv/136315/season/3", { status: 503 });
|
|
expect((await tmdb.season(136315, 3).catch((e) => e)).kind).toBe("protocol");
|
|
expect((await tmdb.season(136315, 3)).episodes).toHaveLength(10);
|
|
expect((await tmdb.details("movie", 1).catch((e) => e)).kind).toBe("notfound");
|
|
expect((await tmdb.season(136315, 9).catch((e) => e)).kind).toBe("notfound");
|
|
});
|
|
|
|
it("凭据错误 → auth;超时 → timeout;连接失败 → network", async () => {
|
|
const { mock } = await setup();
|
|
const bad = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "wrong", retries: 0 });
|
|
expect((await bad.search("x", { type: "movie" }).catch((e) => e)).kind).toBe("auth");
|
|
const slow = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 50 });
|
|
mock.fault("/3/search", { delayMs: 300, times: 2 });
|
|
expect((await slow.search("dune", { type: "movie" }).catch((e) => e)).kind).toBe("timeout");
|
|
const dead = createTmdbClient({ baseUrl: "http://127.0.0.1:1/3", apiKey: "k", retries: 0 });
|
|
expect((await dead.details("movie", 1).catch((e) => e)).kind).toBe("network");
|
|
});
|
|
|
|
it("网络/HTTP 错误的 message 与 cause 不含 api_key(验收 S3)", async () => {
|
|
const KEY = "tmdbSecretKey0123456789abcdef";
|
|
// 真实连接失败:Deno fetch 的错误信息带完整 URL
|
|
const dead = createTmdbClient({ baseUrl: "http://127.0.0.1:1/3", apiKey: KEY, retries: 0 });
|
|
const e1 = await dead.details("movie", 1).catch((x) => x);
|
|
expect(e1.kind).toBe("network");
|
|
// 模拟把 URL(含 api_key 参数与编码后的明文)拼进信息的 fetch 实现
|
|
const leaky: typeof fetch = async (input) => {
|
|
throw new TypeError(`error sending request for url (${String(input)}): key ${encodeURIComponent(KEY)}`);
|
|
};
|
|
const e2 = await createTmdbClient({ baseUrl: "http://tmdb.local/3", apiKey: KEY, retries: 0, fetch: leaky })
|
|
.details("movie", 1)
|
|
.catch((x) => x);
|
|
expect(e2.kind).toBe("network");
|
|
expect(e2.message).toContain("api_key=[REDACTED]");
|
|
// HTTP 错误
|
|
const { mock } = await setup();
|
|
mock.fault("/3/movie/872585", { status: 503 });
|
|
const e3 = await createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: KEY, retries: 0 })
|
|
.details("movie", 872585)
|
|
.catch((x) => x);
|
|
expect(e3.kind).toBe("protocol");
|
|
for (const e of [e1, e2, e3]) {
|
|
expect(e).toBeInstanceOf(TmdbError);
|
|
expect(e.message).not.toContain(KEY);
|
|
expect(e.stack ?? "").not.toContain(KEY);
|
|
expect(JSON.stringify(e.detail)).not.toContain(KEY);
|
|
expect(e.detail.cause instanceof Error).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("Bearer token 鉴权可用", async () => {
|
|
mock = await createMockTmdb({ accessToken: "v4-token" });
|
|
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, accessToken: "v4-token" });
|
|
expect((await tmdb.search("last of us", { type: "tv" }))[0]?.externalId).toBe("100088");
|
|
expect(mock.calls.every((c) => c.query.api_key === undefined)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("TMDB 匹配链(对照 MP TmdbApi.match)", () => {
|
|
it("名称比较:去标点/空白、大小写不敏感、完全相等", () => {
|
|
expect(compareNames("Dune Part Two", "Dune: Part Two")).toBe(true);
|
|
expect(compareNames("沙丘 第二部", ["沙丘:第二部"])).toBe(true);
|
|
expect(compareNames("Dune", "Dune: Part Two")).toBe(false);
|
|
expect(yearRange(2023)).toEqual(["2023", "2024", "2022"]);
|
|
expect(yearRange(undefined)).toEqual([undefined]);
|
|
});
|
|
|
|
it("年份容差 ±1:2023 未命中后按 2024 命中", async () => {
|
|
const { tmdb, mock } = await setup();
|
|
const r = await matchTmdb(tmdb, { name: "沙丘2", type: "movie", year: 2023 });
|
|
expect(r?.candidate.externalId).toBe("693134");
|
|
expect(r?.step).toEqual({ target: "movie_name", year: "2024" });
|
|
expect(mock.callsTo("/3/search/movie").map((c) => c.query.year)).toEqual(["2023", "2024"]);
|
|
});
|
|
|
|
it("标题不直达时拉详情用别名/译名匹配", async () => {
|
|
const { tmdb } = await setup();
|
|
const r = await matchTmdb(tmdb, { name: "奥本海默传", type: "movie" });
|
|
expect(r?.candidate).toMatchObject({ externalId: "872585", detailed: true });
|
|
const ja = await matchTmdb(tmdb, { name: "オッペンハイマー", type: "movie", year: 2023 });
|
|
expect(ja).toBeNull(); // mock 搜索不索引译名 → 搜不到候选,与 TMDB 行为一致:译名只在详情比较阶段参与
|
|
expect(await matchTmdb(tmdb, { name: "完全不存在", type: "movie", year: 2000 })).toBeNull();
|
|
});
|
|
|
|
it("季年份匹配优先:按季首播年份命中剧集;multi 电影优先", async () => {
|
|
const { tmdb } = await setup();
|
|
const r = await matchTmdb(tmdb, { name: "熊家餐馆", type: "tv", seasonYear: 2024, seasonNumber: 3, year: 2022 });
|
|
expect(r?.candidate.externalId).toBe("136315");
|
|
expect(r?.step.target).toBe("tv_season");
|
|
const m = await matchTmdbMulti(tmdb, "The Last of Us");
|
|
expect(m).toMatchObject({ entityType: "tv", externalId: "100088" });
|
|
});
|
|
});
|