feat(tmdb): 剧集组列表与详情适配(组序号即季号,保留 TMDB 原季集号)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 15:45:12 +08:00
1 parent 4fa001750b
commit 5434ae8ee8
5 files changed
+459 -1

No files matched your search

@@ -0,0 +1,178 @@
/**
* 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],
};
}