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

+12 -2
View File
@@ -123,6 +123,17 @@ export interface TmdbSharedCache {
set(key: string, value: unknown, ttlMs: number): Promise<void>;
}
function requestQuery(params: Record<string, string | undefined>): URLSearchParams {
const qs = new URLSearchParams();
for (const k of Object.keys(params).sort()) if (params[k] !== undefined) qs.set(k, params[k]!);
return qs;
}
/** 请求缓存键 `path?query`(参数按名排序、不含凭证);只读缓存的离线重算(MD-2)据此找已缓存的响应 */
export function tmdbRequestKey(path: string, params: Record<string, string | undefined> = {}): string {
return `${path}?${requestQuery(params)}`;
}
/** 空结果(搜索 0 条)用短 TTL(对照 MP EMPTY_RESULT_CACHE_TTL) */
const isEmptyResult = (v: unknown) =>
Array.isArray((v as Json | null)?.results) && ((v as Json).results as unknown[]).length === 0;
@@ -163,8 +174,7 @@ export class TmdbClient {
}
private async get<T>(path: string, params: Record<string, string | undefined>): Promise<T> {
const qs = new URLSearchParams();
for (const k of Object.keys(params).sort()) if (params[k] !== undefined) qs.set(k, params[k]!);
const qs = requestQuery(params);
const key = `${path}?${qs}`;
const emptyTtl = this.cfg.cache?.emptyTtlMs ?? 30 * 60_000;
const baseTtl = this.cfg.cache?.ttlMs ?? 24 * 3600_000;
+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") });
+27 -9
View File
@@ -5,13 +5,14 @@
* get_tv_season_detail(季名、简介、海报、episodes[]:name/overview/air_date/runtime/still_path/vote_average)→ fetchRichSeason;
* 请求缓存语义同 client.ts(MP tmdbv3api request 的 @cached:成功才缓存、空结果短 TTL)。
* 调整:标题 / 简介按语言回退链取(translations.ts);季集在首选语言缺标题(占位名)或缺简介时按链逐个语言补拉,
* 链走完或全部补齐即停。主创只留前 10 位演员 + 导演 / 主创(created_by),头像只存路径(前端经图片代理)。
* 链走完或全部补齐即停。MD-2:简介 / 标语只按 overviewLanguages(缺省只到中文变体,不回退英文)取,取不到留空。主创只留前 10 位演员 + 导演 / 主创(created_by),头像只存路径(前端经图片代理)。
*/
import type { MediaEntityType, TmdbClient } from "./client.ts";
import {
DEFAULT_METADATA_LANGUAGES,
isPlaceholderEpisodeName,
isPlaceholderSeasonName,
overviewLanguages,
parseAltTitles,
parseTranslations,
pickChineseTitle,
@@ -116,6 +117,12 @@ export interface TmdbRichSeasonDetail {
fetchedLanguages: string[];
}
/** MD-2 取值选项 */
export interface RichTextOptions {
/** 简介 / 标语是否允许回退到非中文(设置 metadata.overviewNonChineseFallback,缺省否) */
overviewNonChineseFallback?: boolean;
}
const chainOf = (languages?: readonly string[]) =>
languages?.length ? [...languages] : [...DEFAULT_METADATA_LANGUAGES];
@@ -156,8 +163,10 @@ export async function fetchRichDetail(
type: MediaEntityType,
id: string | number,
languages?: readonly string[],
opts: RichTextOptions = {},
): Promise<TmdbRichDetail> {
const chain = chainOf(languages);
const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback);
const j = await client.getJson<Json>(`/${type}/${id}`, {
language: chain[0],
append_to_response: "alternative_titles,credits,external_ids,translations",
@@ -171,8 +180,9 @@ export async function fetchRichDetail(
const titleSrc = { translations, altTitles, originalTitle, originalLanguage, primary };
const title = pickTitle(chain, titleSrc);
const zh = pickChineseTitle(chain, titleSrc);
const overview = pickText(chain, translations, "overview", { value: s(j.overview), language: chain[0] ?? null });
const tagline = pickText(chain, translations, "tagline", { value: s(j.tagline), language: chain[0] ?? null });
// 请求语言 = 链首,链首总在 textChain 里(见 overviewLanguages),所以 primary 可以兜底
const overview = pickText(textChain, translations, "overview", { value: s(j.overview), language: chain[0] ?? null });
const tagline = pickText(textChain, translations, "tagline", { value: s(j.tagline), language: chain[0] ?? null });
const date = s(type === "movie" ? j.release_date : j.first_air_date);
const ext = (j.external_ids ?? {}) as Json;
const runtimes = Array.isArray(j.episode_run_time) ? (j.episode_run_time as unknown[]) : [];
@@ -258,10 +268,12 @@ export async function fetchRichSeason(
tvId: string | number,
seasonNumber: number,
languages?: readonly string[],
opts: { now?: Date } = {},
opts: { now?: Date } & RichTextOptions = {},
): Promise<TmdbRichSeasonDetail> {
const chain = chainOf(languages);
const first = chain[0]!;
// MD-2:简介只从这些语言取(缺省只到中文变体);标题仍按完整链
const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback);
const j = await client.getJson<Json>(`/tv/${tvId}/season/${seasonNumber}`, { language: first });
const rawEps = (Array.isArray(j.episodes) ? j.episodes : []) as Json[];
const eps = rawEps.map((e) => episodeOf(e, seasonNumber, first));
@@ -274,9 +286,15 @@ export async function fetchRichSeason(
// 只为已播出的集补拉(未播出的集各语言通常都空,补拉只会白白耗请求);季名"第 N 季"各语言都是占位,不触发补拉
const today = (opts.now ?? new Date()).toISOString().slice(0, 10);
const aired = (e: TmdbRichEpisode) => !!e.airDate && e.airDate <= today;
const missing = () => eps.some((e) => aired(e) && (!e.name || !e.overview)) || (!seasonOverview && eps.some(aired));
for (const lang of chain.slice(1)) {
if (!eps.length || !missing()) break;
// 缺简介只在剩下的语言里还有可取简介的语言时才值得补拉
const missing = (rest: readonly string[]) => {
const ov = rest.some((l) => textChain.includes(l));
return eps.some((e) => aired(e) && (!e.name || (ov && !e.overview))) || (ov && !seasonOverview && eps.some(aired));
};
for (const [i, lang] of chain.entries()) {
if (i === 0) continue;
if (!eps.length || !missing(chain.slice(i))) break;
const textOk = textChain.includes(lang);
let k: Json;
try {
k = await client.getJson<Json>(`/tv/${tvId}/season/${seasonNumber}`, { language: lang });
@@ -296,12 +314,12 @@ export async function fetchRichSeason(
e.name = other.name;
e.nameLanguage = lang;
}
if (!e.overview && other.overview) {
if (textOk && !e.overview && other.overview) {
e.overview = other.overview;
e.overviewLanguage = lang;
}
}
if (!seasonOverview && s(k.overview)) {
if (textOk && !seasonOverview && s(k.overview)) {
seasonOverview = s(k.overview);
seasonOverviewLang = lang;
}
+23 -2
View File
@@ -147,7 +147,26 @@ export function pickChineseTitle(chain: readonly string[], src: TitleSources): P
return null;
}
/** 简介 / 标语:按链取 translations 的非空值;全部落空时用 primary(请求语言下的原值) */
/** 语言标签是否中文(zh / zh-CN / zh-TW …) */
export const isChineseTag = (tag: string): boolean => splitTag(tag).lang === "zh";
/**
* MD-2 简介 / 标语用的语言链:缺省只回退到中文变体,不回退到非中文(TMDB 上不少剧集没有中文简介,
* 英文"简介"常是标题英译,中文界面里显得不对)。规则:
* - allowNonChinese = true(设置 metadata.overviewNonChineseFallback):原链不变;
* - 链里没有中文:原链不变(用户本来就选了非中文);
* - 否则:第一个中文之前的语言(用户排在中文前面、是首选而不是回退)+ 链中的全部中文,之后的非中文去掉。
* 缺省链 zh-CN → zh-SG → zh-TW → zh-HK → en-US 得到 zh-CN → zh-SG → zh-TW → zh-HK。
* 标题 / 别名不受影响,仍按完整链回退。MP 对照:tmdbapi 每集信息只按配置语言取,简介为空就为空。
*/
export function overviewLanguages(chain: readonly string[], allowNonChinese = false): string[] {
if (allowNonChinese) return [...chain];
const firstZh = chain.findIndex(isChineseTag);
if (firstZh < 0) return [...chain];
return chain.filter((t, i) => i < firstZh || isChineseTag(t));
}
/** 简介 / 标语:按链取 translations 的非空值;全部落空时用 primary(请求语言下的原值,等于链外语言译文时不用) */
export function pickText(
chain: readonly string[],
translations: readonly TmdbTranslation[],
@@ -158,7 +177,9 @@ export function pickText(
const tr = translations.find((t) => tagMatches(tag, t) && t[key]);
if (tr?.[key]) return { value: tr[key]!, language: tag, via: "translation" };
}
if (primary?.value) return { value: primary.value, language: primary.language, via: "primary" };
// 请求语言下的原值若正是链外某语言的译文(TMDB / 反代把缺译名的字段回退成了别的语言),不当作链内的值
const foreign = translations.some((t) => t[key] === primary?.value && !chain.some((tag) => tagMatches(tag, t)));
if (primary?.value && !foreign) return { value: primary.value, language: primary.language, via: "primary" };
return null;
}