feat(metadata): overview falls back to Chinese variants only (MD-2)

- adapters: overviewLanguages() limits overview/tagline chain to Chinese
  variants (zh-CN→zh-SG→zh-TW→zh-HK); titles keep full chain; season
  supplement fetch skips languages useless for overview
- setting metadata.overviewNonChineseFallback (default off) + UI switch
- media detail shows "暂无简介" for empty entity/episode overview
- scripts/overview-recompute.ts: recompute stored non-Chinese overviews
  from kv_cache only (no TMDB requests), dry-run by default, --apply
- tests: chain unit tests, Heart Signal (103147) fixture, server
  recompute test, MP differential for episode overview
- runbook + CHANGELOG

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 18:04:23 +08:00
1 parent 081db4651c
commit f9901b514b
22 files changed
+887 -38

No files matched your search

+80 -2
View File
@@ -1,10 +1,11 @@
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
import { createMockTmdb, HEART_SIGNAL_FIXTURE, type MockTmdb, TMDB_FIXTURES } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
createTmdbClient,
fetchRichDetail,
fetchRichSeason,
isPlaceholderEpisodeName,
overviewLanguages,
parseAltTitles,
parseTranslations,
pickChineseTitle,
@@ -19,7 +20,7 @@ afterEach(async () => {
});
async function setup() {
mock = await createMockTmdb();
mock = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, HEART_SIGNAL_FIXTURE] });
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 });
return { mock, tmdb };
}
@@ -64,6 +65,36 @@ describe("语言回退链(纯函数)", () => {
expect(pickText(["ja-JP"], translations, "overview", { value: null, language: null })).toBeNull();
});
it("MD-2 简介链:缺省只到中文变体;排在中文前面的语言保留;链里没有中文或允许回退时原链", () => {
expect(overviewLanguages(CHAIN)).toEqual(["zh-CN", "zh-SG", "zh-TW", "zh-HK"]);
expect(overviewLanguages(CHAIN, true)).toEqual(CHAIN);
expect(overviewLanguages(["en-US", "zh-CN", "ja-JP"])).toEqual(["en-US", "zh-CN"]);
expect(overviewLanguages(["ja-JP", "en-US"])).toEqual(["ja-JP", "en-US"]);
expect(overviewLanguages(["zh", "en-US"])).toEqual(["zh"]);
});
it("MD-2 简介只有英文:按中文链取不到就为空;标题仍回退到英文", () => {
const enOnly = parseTranslations(
{
translations: [
{ iso_639_1: "zh", iso_3166_1: "CN", data: { name: "", overview: "" } },
{ iso_639_1: "en", iso_3166_1: "US", data: { name: "Heart Signal", overview: "English only" } },
],
},
"name",
);
expect(pickText(overviewLanguages(CHAIN), enOnly, "overview")).toBeNull();
expect(pickText(CHAIN, enOnly, "overview")).toMatchObject({ value: "English only", language: "en-US" });
// 请求语言下的原值其实是英文译文(TMDB 回退):不当作 zh-CN
expect(
pickText(overviewLanguages(CHAIN), enOnly, "overview", { value: "English only", language: "zh-CN" }),
).toBeNull();
expect(pickTitle(CHAIN, { translations: enOnly, primary: null })).toMatchObject({
value: "Heart Signal",
language: "en-US",
});
});
it("占位集名识别", () => {
expect(isPlaceholderEpisodeName("第 3 集", 3)).toBe(true);
expect(isPlaceholderEpisodeName("Episode 3", 3)).toBe(true);
@@ -137,6 +168,53 @@ describe("fetchRichDetail / fetchRichSeason(mock TMDB)", () => {
expect(s2.fetchedLanguages).toEqual(["zh-CN"]);
});
it("MD-2《心动的信号》:作品与每集简介只有英文 → 留空;标题照常回退;允许非中文时恢复英文", async () => {
const { tmdb, mock } = await setup();
const d = await fetchRichDetail(tmdb, "tv", 103147, CHAIN);
expect(d.title).toBe("心动的信号");
expect(d.overview).toBeNull();
expect(d.overviewLanguage).toBeNull();
const s = await fetchRichSeason(tmdb, 103147, 1, CHAIN, { now: new Date("2026-01-01") });
const [e1, e2, e3] = s.episodes;
expect(e1).toMatchObject({ name: "心动不如行动,大胆亮出底牌!", nameLanguage: "zh-CN", overview: null });
expect(e1!.overviewLanguage).toBeNull();
// 标题:zh-CN 是占位名,回退到 en-US;简介仍不回退
expect(e2).toMatchObject({ name: "Who Is the One?", nameLanguage: "en-US", overview: null });
expect(e3).toMatchObject({ name: "约会之夜", overview: "約會之夜。", overviewLanguage: "zh-TW" });
// 补拉 en-US 只为了标题
expect(s.fetchedLanguages).toEqual(CHAIN);
const any = await fetchRichSeason(tmdb, 103147, 1, CHAIN, {
now: new Date("2026-01-01"),
overviewNonChineseFallback: true,
});
expect(any.episodes[0]).toMatchObject({
overview: "If Your Heart Skips a Beat, Play Your Cards Openly!",
overviewLanguage: "en-US",
});
const anyD = await fetchRichDetail(tmdb, "tv", 103147, CHAIN, { overviewNonChineseFallback: true });
expect(anyD).toMatchObject({ overview: "A Chinese dating reality show.", overviewLanguage: "en-US" });
expect(mock.calls.length).toBeGreaterThan(0);
});
it("MD-2 标题都齐、只缺简介时,中文变体走完就停,不为简介补拉英文", async () => {
const fx = {
...HEART_SIGNAL_FIXTURE,
seasons: [
{
...HEART_SIGNAL_FIXTURE.seasons![0]!,
overviews: { "zh-CN": "第一季。" },
episodes: [HEART_SIGNAL_FIXTURE.seasons![0]!.episodes[0]!],
},
],
};
mock = await createMockTmdb({ fixtures: [fx] });
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 });
const s = await fetchRichSeason(tmdb, 103147, 1, CHAIN, { now: new Date("2026-01-01") });
expect(s.episodes[0]!.overview).toBeNull();
expect(s.fetchedLanguages).toEqual(["zh-CN", "zh-SG", "zh-TW", "zh-HK"]);
});
it("补拉走请求缓存:同一季第二次不再请求", async () => {
const { tmdb, mock } = await setup();
await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") });