185 lines
8.1 KiB
TypeScript
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());
|
|
}
|