Files
UHD_Media_Tool/packages/adapters/src/tmdb/details.ts
T
rootandClaude Opus 5.5 6549ab92c3 fix(media): 详情页标语只显示简体中文,简中没有时不显示
- adapters 新增 simplifiedChineseTagline:取值语言为繁中 / 非中文、或文字是繁体特有字时不显示;原语言为中文(zh / cn)的作品按简中处理
- TMDB 富详情带回 taglineLanguage;发现页详情的 tagline 按上述口径给出
- 媒体库作品详情新增 displayTagline(存储的标语语言为 null,只能按文字判断);metadata.tagline 存储不变
- 补 adapters 单测与作品详情接口测试

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:32:00 +08:00

445 lines
18 KiB
TypeScript

/**
* TMDB 富详情与季集详情(MD-1,媒体详情页 / 字段级元数据用)。
* 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8)
* __get_movie_detail / __get_tv_detail(append_to_response=credits,alternative_titles,translations,external_ids …)→ fetchRichDetail;
* get_tv_season_detail(季名、简介、海报、episodes[]:name/overview/air_date/runtime/still_path/vote_average)→ fetchRichSeason;
* 请求缓存语义同 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,
isPlaceholderSeasonName,
overviewLanguages,
parseAltTitles,
parseTranslations,
pickChineseTitle,
pickText,
pickTitle,
type TmdbAltTitle,
type TmdbTranslation,
} from "./translations.ts";
type Json = Record<string, unknown>;
const s = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
const int = (v: unknown): number | null => {
const x = n(v);
return x === null ? null : Math.round(x);
};
const yearOf = (d: string | null) => (d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : null);
export interface TmdbCredit {
id: number;
name: string;
originalName: string | null;
/** 演员:角色名;幕后:职务 */
role: string | null;
profilePath: string | null;
}
export interface TmdbRichSeason {
seasonNumber: number;
episodeCount: number;
name: string | null;
airDate: string | null;
overview: string | null;
posterPath: string | null;
}
export interface TmdbRichDetail {
entityType: MediaEntityType;
externalId: string;
/** 回退链取到的标题及语言(null = 链全落空后用的请求语言值 / 原名) */
title: string;
titleLanguage: string | null;
/** 中文名(链中第一个中文值,zh-CN 优先);没有为 null */
titleZh: string | null;
titleZhLanguage: string | null;
originalTitle: string | null;
originalLanguage: string | null;
overview: string | null;
overviewLanguage: string | null;
tagline: string | null;
/** 标语实际取到的语言(链内语言标签;落空为 null) */
taglineLanguage: string | null;
year: number | null;
releaseDate: string | null;
lastAirDate: string | null;
genres: string[];
voteAverage: number | null;
voteCount: number | null;
/** TMDB status 原文 */
status: string | null;
/** 分钟:电影片长 / 剧集单集时长(episode_run_time 首项) */
runtime: number | null;
seasonCount: number | null;
episodeCount: number | null;
networks: string[];
originCountry: string[];
posterPath: string | null;
backdropPath: string | null;
/** 标志(透明 PNG,刮削写 clearlogo.png);语言优先 原语言 → 中文 → 英文 → 无文字;没有为 null(旧数据可缺省) */
logoPath?: string | null;
cast: TmdbCredit[];
crew: TmdbCredit[];
externalIds: { imdb: string | null; tvdb: string | null; wikidata: string | null };
translations: TmdbTranslation[];
alternativeTitles: TmdbAltTitle[];
seasons: TmdbRichSeason[];
/** 本次使用的语言链(按作品收敛后) */
languages: string[];
}
export interface TmdbRichEpisode {
seasonNumber: number;
episodeNumber: number;
name: string | null;
nameLanguage: string | null;
overview: string | null;
overviewLanguage: string | null;
airDate: string | null;
runtime: number | null;
stillPath: string | null;
voteAverage: number | null;
voteCount: number | null;
}
export interface TmdbRichSeasonDetail {
tvId: string;
seasonNumber: number;
name: string | null;
nameLanguage: string | null;
overview: string | null;
overviewLanguage: string | null;
airDate: string | null;
posterPath: string | null;
episodes: TmdbRichEpisode[];
/** 实际请求过的语言(首选 + 补拉) */
fetchedLanguages: string[];
}
/** MD-2 取值选项 */
export interface RichTextOptions {
/** 简介 / 标语是否允许回退到非中文(设置 metadata.overviewNonChineseFallback,缺省否) */
overviewNonChineseFallback?: boolean;
}
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[])
.filter((x) => n(x.id) !== null && s(x.name))
.sort((a, b) => (n(a.order) ?? 999) - (n(b.order) ?? 999))
.slice(0, 10)
.map((x) => ({
id: n(x.id)!,
name: s(x.name)!,
originalName: s(x.original_name),
role: s(x.character),
profilePath: s(x.profile_path),
}));
const crew: TmdbCredit[] = [];
const seen = new Set<number>();
const push = (x: Json, role: string) => {
const id = n(x.id);
if (id === null || seen.has(id) || !s(x.name)) return;
seen.add(id);
crew.push({ id, name: s(x.name)!, originalName: s(x.original_name), role, profilePath: s(x.profile_path) });
};
if (type === "tv") for (const x of (Array.isArray(createdBy) ? createdBy : []) as Json[]) push(x, "Creator");
for (const x of (Array.isArray(c.crew) ? c.crew : []) as Json[]) {
if (x.job === "Director") push(x, "Director");
}
return { cast, crew: crew.slice(0, 6) };
}
/** 详情请求带回的图片语言:中文、英文、无文字;作品原语言不在其中时另取一次 /images */
export const DETAIL_IMAGE_LANGUAGES = "zh,en,null";
interface LogoRow {
path: string;
lang: string | null;
vote: number;
count: number;
}
const logoRows = (images: unknown): LogoRow[] => {
const logos = ((images ?? {}) as Json).logos;
return ((Array.isArray(logos) ? logos : []) as Json[]).flatMap((x) => {
const path = s(x.file_path);
// 只要 PNG:Kodi / Emby / Jellyfin 的 clearlogo 都是 png,TMDB 的 svg 标志不用
if (!path || !/\.png$/i.test(path)) return [];
return [{ path, lang: s(x.iso_639_1), vote: n(x.vote_average) ?? 0, count: n(x.vote_count) ?? 0 }];
});
};
/**
* 选一张标志:语言优先 作品原语言 → 中文 → 英文 → 无文字;同语言按评分、票数从高到低。没有 PNG 标志返回 null。
* images 可以是多份(详情带回的 + 原语言单独取的)。
*/
export function pickLogo(images: unknown[], originalLanguage: string | null): string | null {
const rows = images.flatMap(logoRows);
const orig = originalLanguage?.toLowerCase().split("-")[0] ?? null;
const order = [orig, "zh", "en", null].filter((x, i, a) => a.indexOf(x) === i && (x !== null || i === a.length - 1));
for (const lang of order) {
const hit = rows
.filter((r) => (r.lang?.toLowerCase() ?? null) === lang)
.sort((a, b) => b.vote - a.vote || b.count - a.count)[0];
if (hit) return hit.path;
}
return null;
}
/**
* 富详情:一次请求 `/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations,images`
* (`include_image_language=zh,en,null`),标题 / 简介 / 标语按链从 translations(+ 同地区别名)取。
* 作品原语言不是中文 / 英文时另取 `/{type}/{id}/images?include_image_language=<原语言>` 选标志(失败忽略)。
* 不存在抛 TmdbError(notfound)。
*/
export async function fetchRichDetail(
client: Pick<TmdbClient, "getJson">,
type: MediaEntityType,
id: string | number,
languages?: readonly string[],
opts: RichTextOptions = {},
): Promise<TmdbRichDetail> {
const requested = chainOf(languages);
const j = await client.getJson<Json>(`/${type}/${id}`, {
language: requested[0],
append_to_response: "alternative_titles,credits,external_ids,translations,images",
include_image_language: DETAIL_IMAGE_LANGUAGES,
});
const titleKey = type === "movie" ? "title" : "name";
const originalTitle = s(type === "movie" ? j.original_title : j.original_name);
const originalLanguage = s(j.original_language);
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: 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[]) : [];
const { cast, crew } = credits(j.credits, type, j.created_by);
const logoSources: unknown[] = [j.images];
const origLang = originalLanguage?.toLowerCase().split("-")[0] ?? null;
if (origLang && origLang !== "zh" && origLang !== "en") {
try {
logoSources.unshift(await client.getJson<Json>(`/${type}/${id}/images`, { include_image_language: origLang }));
} catch {
// 原语言标志取不到就用中文 / 英文 / 无文字的
}
}
return {
entityType: type,
externalId: String(id),
title: title?.value ?? primary ?? originalTitle ?? s(j[titleKey]) ?? "",
titleLanguage: title?.language ?? null,
titleZh: zh?.value ?? null,
titleZhLanguage: zh?.language ?? null,
originalTitle,
originalLanguage,
overview: overview?.value ?? null,
overviewLanguage: overview?.language ?? null,
tagline: tagline?.value ?? null,
taglineLanguage: tagline?.language ?? null,
year: yearOf(date),
releaseDate: date,
lastAirDate: s(j.last_air_date),
genres: ((Array.isArray(j.genres) ? j.genres : []) as Json[]).map((g) => s(g.name)).filter((x): x is string => !!x),
voteAverage: n(j.vote_average) !== null ? Math.round(n(j.vote_average)! * 10) / 10 : null,
voteCount: int(j.vote_count),
status: s(j.status),
runtime: type === "movie" ? int(j.runtime) : int(runtimes[0]),
seasonCount: type === "tv" ? int(j.number_of_seasons) : null,
episodeCount: type === "tv" ? int(j.number_of_episodes) : null,
networks: ((Array.isArray(j.networks) ? j.networks : []) as Json[])
.map((x) => s(x.name))
.filter((x): x is string => !!x),
originCountry,
posterPath: s(j.poster_path),
backdropPath: s(j.backdrop_path),
logoPath: pickLogo(logoSources, originalLanguage),
cast,
crew,
externalIds: {
imdb: s(ext.imdb_id),
tvdb: ext.tvdb_id != null && ext.tvdb_id !== "" ? String(ext.tvdb_id) : null,
wikidata: s(ext.wikidata_id),
},
translations,
alternativeTitles: altTitles,
seasons: ((Array.isArray(j.seasons) ? j.seasons : []) as Json[]).map((x) => ({
seasonNumber: int(x.season_number) ?? 0,
episodeCount: int(x.episode_count) ?? 0,
name: s(x.name),
airDate: s(x.air_date),
overview: s(x.overview),
posterPath: s(x.poster_path),
})),
languages: chain,
};
}
function episodeOf(e: Json, seasonNumber: number, lang: string): TmdbRichEpisode {
const num = int(e.episode_number) ?? 0;
const name = s(e.name);
const overview = s(e.overview);
const named = name && !isPlaceholderEpisodeName(name, num);
return {
seasonNumber: int(e.season_number) ?? seasonNumber,
episodeNumber: num,
name: named ? name : null,
nameLanguage: named ? lang : null,
overview,
overviewLanguage: overview ? lang : null,
airDate: s(e.air_date),
runtime: int(e.runtime),
stillPath: s(e.still_path),
voteAverage:
n(e.vote_average) !== null && (int(e.vote_count) ?? 0) > 0 ? Math.round(n(e.vote_average)! * 10) / 10 : null,
voteCount: int(e.vote_count),
};
}
/**
* 季详情:先按链首请求 `/tv/{id}/season/{n}`;有集缺标题(TMDB 占位名"第 N 集")或缺简介时,
* 按链逐个语言补拉同一端点(各自走请求缓存),补齐即停。补不到标题的集保留链首的占位名。
*/
export async function fetchRichSeason(
client: Pick<TmdbClient, "getJson">,
tvId: string | number,
seasonNumber: number,
languages?: readonly string[],
opts: { now?: Date; originalLanguage?: string | null } & RichTextOptions = {},
): Promise<TmdbRichSeasonDetail> {
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);
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));
const placeholders = new Map(rawEps.map((e) => [int(e.episode_number) ?? 0, s(e.name)]));
let seasonName = s(j.name);
let seasonNameLang: string | null = seasonName ? first : null;
let seasonOverview = s(j.overview);
let seasonOverviewLang: string | null = seasonOverview ? first : null;
const fetched = [first];
// 只为已播出的集补拉(未播出的集各语言通常都空,补拉只会白白耗请求);季名"第 N 季"各语言都是占位,不触发补拉
const today = (opts.now ?? new Date()).toISOString().slice(0, 10);
const aired = (e: TmdbRichEpisode) => !!e.airDate && e.airDate <= today;
// 缺简介只在剩下的语言里还有可取简介的语言时才值得补拉
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 });
} catch {
// 补拉失败不影响首选语言结果
break;
}
fetched.push(lang);
const byNum = new Map(
((Array.isArray(k.episodes) ? k.episodes : []) as Json[]).map((e) => [int(e.episode_number) ?? 0, e]),
);
for (const e of eps) {
const alt = byNum.get(e.episodeNumber);
if (!alt) continue;
const other = episodeOf(alt, seasonNumber, lang);
if (!e.name && other.name) {
e.name = other.name;
e.nameLanguage = lang;
}
if (textOk && !e.overview && other.overview) {
e.overview = other.overview;
e.overviewLanguage = lang;
}
}
if (textOk && !seasonOverview && s(k.overview)) {
seasonOverview = s(k.overview);
seasonOverviewLang = lang;
}
if (
isPlaceholderSeasonName(seasonName, seasonNumber) &&
s(k.name) &&
!isPlaceholderSeasonName(s(k.name), seasonNumber)
) {
seasonName = s(k.name);
seasonNameLang = lang;
}
}
// 链走完仍没有标题:保留首选语言的占位名(界面显示"第 N 集"比空白好)
for (const e of eps) {
if (!e.name) {
const p = placeholders.get(e.episodeNumber);
if (p) {
e.name = p;
e.nameLanguage = null;
}
}
}
return {
tvId: String(tvId),
seasonNumber: int(j.season_number) ?? seasonNumber,
name: seasonName,
nameLanguage: seasonNameLang,
overview: seasonOverview,
overviewLanguage: seasonOverviewLang,
airDate: s(j.air_date),
posterPath: s(j.poster_path),
episodes: eps,
fetchedLanguages: fetched,
};
}