feat(tmdb): language fallback chain, rich details and per-episode season fetch (MD-1)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c3aaf901ad
commit
fc1856c2e3
8 files changed
+834
-5
No files matched your search
@@ -0,0 +1,147 @@
|
||||
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createTmdbClient,
|
||||
fetchRichDetail,
|
||||
fetchRichSeason,
|
||||
isPlaceholderEpisodeName,
|
||||
parseAltTitles,
|
||||
parseTranslations,
|
||||
pickChineseTitle,
|
||||
pickText,
|
||||
pickTitle,
|
||||
} from "./index.ts";
|
||||
|
||||
let mock: MockTmdb | null = null;
|
||||
afterEach(async () => {
|
||||
await mock?.close();
|
||||
mock = null;
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
mock = await createMockTmdb();
|
||||
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 });
|
||||
return { mock, tmdb };
|
||||
}
|
||||
|
||||
const CHAIN = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"];
|
||||
|
||||
describe("语言回退链(纯函数)", () => {
|
||||
const translations = parseTranslations(
|
||||
{
|
||||
translations: [
|
||||
{ iso_639_1: "zh", iso_3166_1: "CN", data: { name: "", overview: "简中简介" } },
|
||||
{ iso_639_1: "zh", iso_3166_1: "TW", data: { name: "人生切割術", overview: "繁中簡介" } },
|
||||
{ iso_639_1: "en", iso_3166_1: "US", data: { name: "", overview: "English" } },
|
||||
],
|
||||
},
|
||||
"name",
|
||||
);
|
||||
const altTitles = parseAltTitles({
|
||||
results: [
|
||||
{ iso_3166_1: "CN", title: "Severance CN", type: "" },
|
||||
{ iso_3166_1: "CN", title: "人生切割术", type: "" },
|
||||
{ iso_3166_1: "HK", title: "生活割離術", type: "" },
|
||||
],
|
||||
});
|
||||
const src = { translations, altTitles, originalTitle: "Severance", originalLanguage: "en", primary: "Severance" };
|
||||
|
||||
it("zh-CN 译名为空时取 CN 地区的中文别名(对照 MP __update_tmdbinfo_cn_title)", () => {
|
||||
expect(pickTitle(CHAIN, src)).toEqual({ value: "人生切割术", language: "zh-CN", via: "alternative_title" });
|
||||
expect(pickChineseTitle(CHAIN, src)?.value).toBe("人生切割术");
|
||||
});
|
||||
|
||||
it("没有 CN 别名时按链落到 zh-TW 译名;链只有英文时英文原名", () => {
|
||||
const noCn = { ...src, altTitles: altTitles.filter((a) => a.region !== "CN") };
|
||||
expect(pickTitle(CHAIN, noCn)).toMatchObject({ value: "人生切割術", language: "zh-TW", via: "translation" });
|
||||
expect(pickTitle(["en-US"], noCn)).toMatchObject({ value: "Severance", language: "en-US", via: "original" });
|
||||
expect(pickTitle(["ja-JP"], { ...noCn, originalLanguage: "ko" })).toMatchObject({ via: "primary" });
|
||||
});
|
||||
|
||||
it("简介按链取第一个非空值", () => {
|
||||
expect(pickText(CHAIN, translations, "overview")).toMatchObject({ value: "简中简介", language: "zh-CN" });
|
||||
expect(pickText(["zh-HK", "en-US"], translations, "overview")).toMatchObject({ value: "English" });
|
||||
expect(pickText(["ja-JP"], translations, "overview", { value: null, language: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("占位集名识别", () => {
|
||||
expect(isPlaceholderEpisodeName("第 3 集", 3)).toBe(true);
|
||||
expect(isPlaceholderEpisodeName("Episode 3", 3)).toBe(true);
|
||||
expect(isPlaceholderEpisodeName("第3話")).toBe(true);
|
||||
expect(isPlaceholderEpisodeName("第 3 集", 4)).toBe(false);
|
||||
expect(isPlaceholderEpisodeName("有关地狱的好消息", 1)).toBe(false);
|
||||
expect(isPlaceholderEpisodeName("")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRichDetail / fetchRichSeason(mock TMDB)", () => {
|
||||
it("Severance:标题取 CN 别名,中文名 zh-CN,简介 zh-CN,主创与外部 ID", async () => {
|
||||
const { tmdb, mock } = await setup();
|
||||
const d = await fetchRichDetail(tmdb, "tv", 95396, CHAIN);
|
||||
expect(d.title).toBe("人生切割术");
|
||||
expect(d.titleLanguage).toBe("zh-CN");
|
||||
expect(d.titleZh).toBe("人生切割术");
|
||||
expect(d.overview).toBe("故事发生在一家名为卢蒙的超级大公司内。");
|
||||
expect(d.overviewLanguage).toBe("zh-CN");
|
||||
expect(d.genres).toEqual(["剧情", "悬疑", "Sci-Fi & Fantasy"]);
|
||||
expect(d.voteAverage).toBe(8.4);
|
||||
expect(d.status).toBe("Returning Series");
|
||||
expect(d.networks).toEqual(["Apple TV"]);
|
||||
expect(d.cast[0]).toEqual({
|
||||
id: 4495,
|
||||
name: "亚当·斯科特",
|
||||
originalName: null,
|
||||
role: "Mark Scout",
|
||||
profilePath: "/adam.jpg",
|
||||
});
|
||||
expect(d.crew[0]).toMatchObject({ name: "丹·埃里克森", role: "Creator" });
|
||||
expect(d.externalIds).toEqual({ imdb: "tt11280740", tvdb: "371980", wikidata: null });
|
||||
expect(d.seasons.map((s) => s.seasonNumber)).toEqual([1, 2]);
|
||||
// 一次请求(credits 等随 append_to_response)
|
||||
expect(mock.calls.filter((r) => r.path === "/3/tv/95396")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("链首 en-US:英文原名、英文简介,中文名仍按中文链取", async () => {
|
||||
const { tmdb } = await setup();
|
||||
const d = await fetchRichDetail(tmdb, "tv", 95396, ["en-US"]);
|
||||
expect(d.title).toBe("Severance");
|
||||
expect(d.overview).toBe("Mark leads a team of office workers.");
|
||||
expect(d.titleZh).toBe("人生切割术");
|
||||
});
|
||||
|
||||
it("季集:首选语言缺标题 / 简介的已播集按链补拉;未播出的集不触发补拉", async () => {
|
||||
const { tmdb, mock } = await setup();
|
||||
const s = await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") });
|
||||
expect(s.name).toBe("第 1 季");
|
||||
expect(s.overview).toContain("卢蒙");
|
||||
const [e1, e2, e3] = s.episodes;
|
||||
expect(e1).toMatchObject({
|
||||
name: "有关地狱的好消息",
|
||||
nameLanguage: "zh-CN",
|
||||
overview: "马克升职成为部门主管。",
|
||||
runtime: 59,
|
||||
stillPath: "/sev-101.jpg",
|
||||
voteAverage: 8.2,
|
||||
voteCount: 171,
|
||||
});
|
||||
expect(e2).toMatchObject({ name: "半循環", nameLanguage: "zh-TW", overview: "馬克帶領新員工參觀。" });
|
||||
// 链走完仍没有中文名:en-US 标题;简介各语言都空
|
||||
expect(e3).toMatchObject({ name: "In Perpetuity", nameLanguage: "en-US", overview: null });
|
||||
expect(s.fetchedLanguages).toEqual(CHAIN);
|
||||
const perLang = mock.calls.filter((r) => r.path === "/3/tv/95396/season/1").map((r) => r.query.language);
|
||||
expect(perLang).toEqual(CHAIN);
|
||||
|
||||
mock.calls.length = 0;
|
||||
const s2 = await fetchRichSeason(tmdb, 95396, 2, CHAIN, { now: new Date("2026-01-01") });
|
||||
expect(s2.episodes[0]).toMatchObject({ name: "第 1 集", nameLanguage: null });
|
||||
expect(s2.fetchedLanguages).toEqual(["zh-CN"]);
|
||||
});
|
||||
|
||||
it("补拉走请求缓存:同一季第二次不再请求", async () => {
|
||||
const { tmdb, mock } = await setup();
|
||||
await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") });
|
||||
const n = mock.calls.length;
|
||||
await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") });
|
||||
expect(mock.calls.length).toBe(n);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user