- 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
445 lines
18 KiB
TypeScript
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,
|
|
};
|
|
}
|