Files
UHD_Media_Tool/packages/adapters/src/tmdb/translations.ts
T

185 lines
8.1 KiB
TypeScript

/**
* TMDB 多语言取值(MD-1):按语言回退链从 translations / alternative_titles 取标题、简介。
* 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8)
* __update_tmdbinfo_cn_title:本地化名不含中文 → alternative_titles 的 CN 地区中文名 → 新加坡译名(sg_title);
* __update_tmdbinfo_extra_title:translations 按地区取 en / hk / tw / sg 译名。
* 调整:MP 的"中文名 → CN 别名 → SG"写死在代码里,这里泛化成可配置的语言链(缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US),
* 链中每一项先看 translations(语言 + 地区精确匹配),再看同地区、文字相符的 alternative_titles;
* TMDB 在某语言没有译名时会把 name 回退成原名,所以不信任请求语言下的 name,只信 translations。
* MP 用 zhconv 判断 CN 别名是否简体,这里不引入繁简转换,只要求含汉字。
*/
export const DEFAULT_METADATA_LANGUAGES: readonly string[] = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"];
export interface TmdbTranslation {
/** BCP47,如 zh-CN;没有地区时只有语言 */
lang: string;
iso639: string;
region: string | null;
title: string | null;
overview: string | null;
tagline: string | null;
}
export interface TmdbAltTitle {
title: string;
region: string | null;
type: string | null;
}
/** 取到的值与其来源语言;via 说明来自哪里(给元数据来源页和测试看) */
export interface PickedText {
value: string;
language: string | null;
via: "translation" | "alternative_title" | "original" | "primary";
}
type Json = Record<string, unknown>;
const str = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
const HAN = /\p{Script=Han}/u;
const KANA = /[\p{Script=Hiragana}\p{Script=Katakana}]/u;
const HANGUL = /\p{Script=Hangul}/u;
const LATIN = /\p{Script=Latin}/u;
const containsChinese = (s: string | null | undefined): boolean => !!s && HAN.test(s);
/** 标题的文字与语言是否相符(用于 alternative_titles 只有地区、没有语言的情况) */
export function scriptMatches(lang: string, text: string): boolean {
const l = lang.split("-")[0]!.toLowerCase();
if (l === "zh") return HAN.test(text) && !KANA.test(text);
if (l === "ja") return KANA.test(text) || HAN.test(text);
if (l === "ko") return HANGUL.test(text);
if (["en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi", "pl", "cs", "tr"].includes(l))
return LATIN.test(text) && !HAN.test(text) && !KANA.test(text) && !HANGUL.test(text);
return true;
}
function splitTag(tag: string): { lang: string; region: string | null } {
const [l, r] = tag.split("-");
return { lang: (l ?? "").toLowerCase(), region: r ? r.toUpperCase() : null };
}
/** translations.translations[] → 归一化;type 决定标题键(movie: title,tv / season / episode: name) */
export function parseTranslations(raw: unknown, titleKey: "title" | "name"): TmdbTranslation[] {
const list = Array.isArray((raw as Json | null)?.translations)
? ((raw as Json).translations as Json[])
: Array.isArray(raw)
? (raw as Json[])
: [];
const out: TmdbTranslation[] = [];
for (const t of list) {
const iso639 = str(t?.iso_639_1)?.toLowerCase();
if (!iso639) continue;
const region = str(t.iso_3166_1)?.toUpperCase() ?? null;
const data = (t.data ?? {}) as Json;
out.push({
lang: region ? `${iso639}-${region}` : iso639,
iso639,
region,
title: str(data[titleKey]) ?? str(data.title) ?? str(data.name),
overview: str(data.overview),
tagline: str(data.tagline),
});
}
return out;
}
/** alternative_titles(movie: titles,tv: results)→ 归一化 */
export function parseAltTitles(raw: unknown): TmdbAltTitle[] {
const r = raw as Json | null;
const list = (Array.isArray(r?.titles) ? r.titles : Array.isArray(r?.results) ? r.results : []) as Json[];
const out: TmdbAltTitle[] = [];
for (const a of list) {
const title = str(a?.title);
if (!title) continue;
out.push({ title, region: str(a.iso_3166_1)?.toUpperCase() ?? null, type: str(a.type) });
}
return out;
}
/** 语言标签是否匹配某条译名:zh-CN 只匹配 zh/CN;只写语言(zh)时匹配该语言任意地区 */
function tagMatches(tag: string, t: Pick<TmdbTranslation, "iso639" | "region">): boolean {
const { lang, region } = splitTag(tag);
if (t.iso639 !== lang) return false;
return region === null || t.region === region;
}
export interface TitleSources {
translations: readonly TmdbTranslation[];
altTitles?: readonly TmdbAltTitle[];
originalTitle?: string | null;
originalLanguage?: string | null;
/** 请求语言下的 name/title(TMDB 缺译名时是原名);链全部落空时的兜底 */
primary?: string | null;
}
/**
* 按语言链取标题:每个语言先查 translations,再查同地区、文字相符的别名(类型为空的优先),
* 原始语言与链中语言相同时原名也算该语言的值。全部落空:primary → 原名。
*/
export function pickTitle(chain: readonly string[], src: TitleSources): PickedText | null {
const origLang = src.originalLanguage?.toLowerCase() ?? null;
for (const tag of chain) {
const tr = src.translations.find((t) => tagMatches(tag, t) && t.title);
if (tr?.title) return { value: tr.title, language: tag, via: "translation" };
const { lang, region } = splitTag(tag);
if (region && src.altTitles?.length) {
const alts = src.altTitles
.filter((a) => a.region === region && scriptMatches(lang, a.title))
.sort((a, b) => (a.type ? 1 : 0) - (b.type ? 1 : 0));
if (alts[0]) return { value: alts[0].title, language: tag, via: "alternative_title" };
}
if (origLang === lang && src.originalTitle) return { value: src.originalTitle, language: tag, via: "original" };
}
if (src.primary) return { value: src.primary, language: null, via: "primary" };
if (src.originalTitle) return { value: src.originalTitle, language: origLang, via: "original" };
return null;
}
/** 中文名:链里的中文语言按链顺序(没有中文语言时用缺省中文链),取到的值必须含汉字 */
export function pickChineseTitle(chain: readonly string[], src: TitleSources): PickedText | null {
const zh = chain.filter((t) => splitTag(t).lang === "zh");
const zhChain = zh.length ? zh : DEFAULT_METADATA_LANGUAGES.filter((t) => t.startsWith("zh"));
const p = pickTitle(zhChain, { ...src, primary: null, originalTitle: src.originalTitle });
if (p && containsChinese(p.value)) return p;
if (containsChinese(src.primary)) return { value: src.primary!, language: null, via: "primary" };
return null;
}
/** 简介 / 标语:按链取 translations 的非空值;全部落空时用 primary(请求语言下的原值) */
export function pickText(
chain: readonly string[],
translations: readonly TmdbTranslation[],
key: "overview" | "tagline",
primary?: { value: string | null; language: string | null },
): PickedText | null {
for (const tag of chain) {
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" };
return null;
}
/**
* TMDB 缺译名时给集生成的占位名("第 3 集"、"Episode 3"、"第 3 話"…),当作没有标题继续回退。
*/
export function isPlaceholderEpisodeName(name: string | null | undefined, episodeNumber?: number): boolean {
if (!name) return true;
const s = name.trim();
if (!s) return true;
const n = episodeNumber !== undefined ? String(episodeNumber) : "\\d+";
return new RegExp(
`^(第\\s*${n}\\s*[集話话回]|Episode\\s*${n}|Épisode\\s*${n}|Folge\\s*${n}|Episodio\\s*${n}|エピソード\\s*${n}|${n}\\s*화|에피소드\\s*${n})$`,
"i",
).test(s);
}
/** 季的占位名("第 1 季"、"Season 1"、"特别篇"不算占位) */
export function isPlaceholderSeasonName(name: string | null | undefined, seasonNumber?: number): boolean {
if (!name) return true;
const n = seasonNumber !== undefined ? String(seasonNumber) : "\\d+";
return new RegExp(`^(第\\s*${n}\\s*季|Season\\s*${n}|シーズン\\s*${n}|시즌\\s*${n})$`, "i").test(name.trim());
}