- 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]>
358 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|