/** * 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; 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> = { 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, tvId: string | number, language?: string, ): Promise { const j = await client.getJson(`/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, groupId: string, language?: string, ): Promise { const lang = language ?? "zh-CN"; const j = await client.getJson(`/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(); 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], }; }