Files
UHD_Media_Tool/packages/adapters/src/tmdb/episode-groups.ts
T

179 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* TMDB 剧集组(episode groups):一部剧在默认季之外由编辑整理的其它季集结构(按播出、DVD、制作季、故事线…)。
* 迁移来源:MoviePilot app/modules/themoviedb/tmdbv3api/objs/tv.py episode_groups(`/tv/{id}/episode_groups`)、
* objs/group.py details(`/tv/episode_group/{id}`)、tmdbapi.py get_tv_group_seasons(组内集号按顺序从 1 重排)、
* get_tv_group_detail(按 order 取组 = 季);请求缓存同 client.ts(MP tmdbv3api request 的 @cached)。
* 调整:组内集按 TMDB 给的 order 排序后再编号(MP 直接用返回顺序,两者在 TMDB 实际数据上一致);
* 每集保留 TMDB 默认季集号(tmdbSeason / tmdbEpisode),供切换季集结构时对照迁移;
* 占位名("第 14 集"这类按默认集号起的名字)在组里位置不同,按无标题处理。
*/
import type { TmdbClient } from "./client.ts";
import type { TmdbRichEpisode, TmdbRichSeasonDetail } from "./details.ts";
import { isPlaceholderEpisodeName } 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 DATE_RE = /^\d{4}-\d{2}-\d{2}/;
/** TMDB 剧集组类型(type 1–7) */
export const EPISODE_GROUP_TYPES: Readonly<Record<number, string>> = {
1: "原始播出",
2: "绝对集号",
3: "DVD",
4: "数字发行",
5: "故事线",
6: "制作季",
7: "电视",
};
export interface TmdbEpisodeGroupSummary {
id: string;
name: string;
/** TMDB type 1–7(见 EPISODE_GROUP_TYPES);未知为 null */
type: number | null;
description: string | null;
/** 组(= 季)数 */
groupCount: number;
episodeCount: number;
network: string | null;
}
export interface TmdbEpisodeGroupEpisode extends TmdbRichEpisode {
/** TMDB 默认季集号(切换季集结构时据此对照) */
tmdbSeason: number;
tmdbEpisode: number;
}
export interface TmdbEpisodeGroupSeason {
/** 组的 TMDB id */
id: string | null;
/** 季号 = 组的 order(MP 同口径) */
seasonNumber: number;
name: string | null;
/** 首集播出日期 */
airDate: string | null;
episodes: TmdbEpisodeGroupEpisode[];
}
export interface TmdbEpisodeGroupDetail {
id: string;
name: string;
type: number | null;
description: string | null;
network: string | null;
seasons: TmdbEpisodeGroupSeason[];
language: string;
}
/** `/tv/{id}/episode_groups`:剧集组列表(TMDB 顺序) */
export async function fetchEpisodeGroups(
client: Pick<TmdbClient, "getJson">,
tvId: string | number,
language?: string,
): Promise<TmdbEpisodeGroupSummary[]> {
const j = await client.getJson<Json>(`/tv/${tvId}/episode_groups`, { language });
return ((Array.isArray(j.results) ? j.results : []) as Json[])
.filter((g) => s(g.id))
.map((g) => ({
id: s(g.id)!,
name: s(g.name) ?? s(g.id)!,
type: int(g.type),
description: s(g.description),
groupCount: int(g.group_count) ?? 0,
episodeCount: int(g.episode_count) ?? 0,
network: s((g.network as Json | null | undefined)?.name),
}));
}
function groupEpisode(e: Json, season: number, episode: number, lang: string): TmdbEpisodeGroupEpisode {
const rawName = s(e.name);
const name = rawName && !isPlaceholderEpisodeName(rawName) ? rawName : null;
const overview = s(e.overview);
const air = s(e.air_date);
return {
seasonNumber: season,
episodeNumber: episode,
name,
nameLanguage: name ? lang : null,
overview,
overviewLanguage: overview ? lang : null,
airDate: air && DATE_RE.test(air) ? air.slice(0, 10) : null,
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),
tmdbSeason: int(e.season_number) ?? 0,
tmdbEpisode: int(e.episode_number) ?? 0,
};
}
/**
* `/tv/episode_group/{id}`:组 = 季(季号取 order),组内按 order 排序后从 1 编集号。
* 同一 order 出现两次时后面的组顺延到下一个空季号(TMDB 编辑数据偶有重复)。
*/
export async function fetchEpisodeGroup(
client: Pick<TmdbClient, "getJson">,
groupId: string,
language?: string,
): Promise<TmdbEpisodeGroupDetail> {
const lang = language ?? "zh-CN";
const j = await client.getJson<Json>(`/tv/episode_group/${encodeURIComponent(groupId)}`, { language: lang });
const groups = ((Array.isArray(j.groups) ? j.groups : []) as Json[])
.map((g, i) => ({ g, order: int(g.order) ?? i }))
.sort((a, b) => a.order - b.order);
const used = new Set<number>();
const seasons: TmdbEpisodeGroupSeason[] = [];
for (const { g, order } of groups) {
let season = Math.max(0, order);
while (used.has(season)) season++;
used.add(season);
const eps = ((Array.isArray(g.episodes) ? g.episodes : []) as Json[])
.map((e, i) => ({ e, order: int(e.order) ?? i }))
.sort((a, b) => a.order - b.order)
.map(({ e }, i) => groupEpisode(e, season, i + 1, lang));
seasons.push({
id: s(g.id),
seasonNumber: season,
name: s(g.name),
airDate: eps.find((e) => e.airDate)?.airDate ?? null,
episodes: eps,
});
}
return {
id: s(j.id) ?? groupId,
name: s(j.name) ?? groupId,
type: int(j.type),
description: s(j.description),
network: s((j.network as Json | null | undefined)?.name),
seasons,
language: lang,
};
}
/** 剧集组的一季 → 季详情结构(元数据刷新写季 / 集单元时与默认季同一条路径);海报由调用方补 */
export function groupSeasonAsRichSeason(
tvId: string | number,
season: TmdbEpisodeGroupSeason,
language: string,
posterPath: string | null = null,
): TmdbRichSeasonDetail & { episodes: TmdbEpisodeGroupEpisode[] } {
return {
tvId: String(tvId),
seasonNumber: season.seasonNumber,
name: season.name,
nameLanguage: season.name ? language : null,
overview: null,
overviewLanguage: null,
airDate: season.airDate,
posterPath,
episodes: season.episodes,
fetchedLanguages: [language],
};
}