feat(tmdb): 多语言保留规则——译名 / 别名只留简繁中文、英文与原语言

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:14:54 +08:00
1 parent 69d820a6bf
commit 9c5f04b02f
8 files changed
+423 -29

No files matched your search

+47 -15
View File
@@ -6,8 +6,11 @@
* 请求缓存语义同 client.ts(MP tmdbv3api request 的 @cached:成功才缓存、空结果短 TTL)。
* 调整:标题 / 简介按语言回退链取(translations.ts);季集在首选语言缺标题(占位名)或缺简介时按链逐个语言补拉,
* 链走完或全部补齐即停。MD-2:简介 / 标语只按 overviewLanguages(缺省只到中文变体,不回退英文)取,取不到留空。主创只留前 10 位演员 + 导演 / 主创(created_by),头像只存路径(前端经图片代理)。
* 多语言保留规则(language-policy.ts):translations / alternative_titles 只留简中 / 繁中 / 英文 / 原语言,
* 语言链按作品收敛(去掉既不是中英文、也不是作品原语言的语言)。
*/
import type { MediaEntityType, TmdbClient } from "./client.ts";
import { allowedLanguageChain, altTitleLanguage, isAllowedLanguage, type WorkLanguages } from "./language-policy.ts";
import {
DEFAULT_METADATA_LANGUAGES,
isPlaceholderEpisodeName,
@@ -85,7 +88,7 @@ export interface TmdbRichDetail {
translations: TmdbTranslation[];
alternativeTitles: TmdbAltTitle[];
seasons: TmdbRichSeason[];
/** 本次使用的语言链 */
/** 本次使用的语言链(按作品收敛后) */
languages: string[];
}
@@ -126,6 +129,12 @@ export interface RichTextOptions {
const chainOf = (languages?: readonly string[]) =>
languages?.length ? [...languages] : [...DEFAULT_METADATA_LANGUAGES];
/** 语言链按作品收敛(保留规则);全被去掉时用缺省链 */
export function workLanguageChain(languages: readonly string[] | undefined, work: WorkLanguages): string[] {
const eff = allowedLanguageChain(chainOf(languages), work);
return eff.length ? eff : allowedLanguageChain(DEFAULT_METADATA_LANGUAGES, work);
}
function credits(raw: unknown, type: MediaEntityType, createdBy: unknown): { cast: TmdbCredit[]; crew: TmdbCredit[] } {
const c = (raw ?? {}) as Json;
const cast = ((Array.isArray(c.cast) ? c.cast : []) as Json[])
@@ -165,24 +174,40 @@ export async function fetchRichDetail(
languages?: readonly string[],
opts: RichTextOptions = {},
): Promise<TmdbRichDetail> {
const chain = chainOf(languages);
const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback);
const requested = chainOf(languages);
const j = await client.getJson<Json>(`/${type}/${id}`, {
language: chain[0],
language: requested[0],
append_to_response: "alternative_titles,credits,external_ids,translations",
});
const titleKey = type === "movie" ? "title" : "name";
const translations = parseTranslations(j.translations, titleKey);
const altTitles = parseAltTitles(j.alternative_titles);
const originalTitle = s(type === "movie" ? j.original_title : j.original_name);
const originalLanguage = s(j.original_language);
const primary = s(j[titleKey]);
const originCountry = Array.isArray(j.origin_country)
? (j.origin_country as unknown[]).filter((x): x is string => typeof x === "string")
: [];
const work: WorkLanguages = { originalLanguage, originCountry };
// 保留规则:译名只留允许语言;别名按地区 + 文字推断语言,推断不出的丢弃;语言链按作品收敛
const translations = parseTranslations(j.translations, titleKey).filter((t) => isAllowedLanguage(t.lang, work));
const altTitles = parseAltTitles(j.alternative_titles)
.map((a) => ({ ...a, language: altTitleLanguage(a, work) }))
.filter((a) => a.language);
const chain = workLanguageChain(requested, work);
const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback);
// 请求语言不在保留范围内时,请求语言下的值不可信(是被去掉的语言),不拿来兜底
const reqLang = isAllowedLanguage(requested[0], work) ? requested[0]! : null;
const primary = reqLang ? s(j[titleKey]) : null;
const titleSrc = { translations, altTitles, originalTitle, originalLanguage, primary };
const title = pickTitle(chain, titleSrc);
const zh = pickChineseTitle(chain, titleSrc);
// 请求语言 = 链首,链首总在 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 overview = pickText(textChain, translations, "overview", {
value: reqLang ? s(j.overview) : null,
language: reqLang,
});
const tagline = pickText(textChain, translations, "tagline", {
value: reqLang ? s(j.tagline) : null,
language: reqLang,
});
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[]) : [];
@@ -190,7 +215,7 @@ export async function fetchRichDetail(
return {
entityType: type,
externalId: String(id),
title: title?.value ?? primary ?? originalTitle ?? "",
title: title?.value ?? primary ?? originalTitle ?? s(j[titleKey]) ?? "",
titleLanguage: title?.language ?? null,
titleZh: zh?.value ?? null,
titleZhLanguage: zh?.language ?? null,
@@ -212,9 +237,7 @@ export async function fetchRichDetail(
networks: ((Array.isArray(j.networks) ? j.networks : []) as Json[])
.map((x) => s(x.name))
.filter((x): x is string => !!x),
originCountry: Array.isArray(j.origin_country)
? (j.origin_country as unknown[]).filter((x): x is string => typeof x === "string")
: [],
originCountry,
posterPath: s(j.poster_path),
backdropPath: s(j.backdrop_path),
cast,
@@ -268,9 +291,18 @@ export async function fetchRichSeason(
tvId: string | number,
seasonNumber: number,
languages?: readonly string[],
opts: { now?: Date } & RichTextOptions = {},
opts: { now?: Date; originalLanguage?: string | null } & RichTextOptions = {},
): Promise<TmdbRichSeasonDetail> {
const chain = chainOf(languages);
const requested = chainOf(languages);
// 保留规则:链里有中英文以外的语言时要知道作品原语言才能判断;调用方没给就读一次作品详情(请求缓存)
let originalLanguage = opts.originalLanguage;
if (originalLanguage === undefined && allowedLanguageChain(requested, {}).length !== requested.length) {
originalLanguage = await client
.getJson<Json>(`/tv/${tvId}`, { language: requested[0] })
.then((d) => s(d.original_language))
.catch(() => null);
}
const chain = workLanguageChain(requested, { originalLanguage: originalLanguage ?? null });
const first = chain[0]!;
// MD-2:简介只从这些语言取(缺省只到中文变体);标题仍按完整链
const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback);