Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
179 lines
6.2 KiB
TypeScript
179 lines
6.2 KiB
TypeScript
/**
|
||
* 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],
|
||
};
|
||
}
|