Files
UHD_Media_Tool/packages/adapters/src/tmdb/details.ts
T
rootandClaude Opus 5.5 f9901b514b 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]>
2026-09-30 18:04:23 +08:00

358 lines
14 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),头像只存路径(前端经图片代理)。
*/
import type { MediaEntityType, TmdbClient } from "./client.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;
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;
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];
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) };
}
/**
* 富详情:一次请求 `/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations`,
* 标题 / 简介 / 标语按链从 translations(+ 同地区别名)取。不存在抛 TmdbError(notfound)。
*/
export async function fetchRichDetail(
client: Pick<TmdbClient, "getJson">,
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",
});
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 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 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);
return {
entityType: type,
externalId: String(id),
title: title?.value ?? primary ?? originalTitle ?? "",
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,
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: Array.isArray(j.origin_country)
? (j.origin_country as unknown[]).filter((x): x is string => typeof x === "string")
: [],
posterPath: s(j.poster_path),
backdropPath: s(j.backdrop_path),
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 } & 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));
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,
};
}