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,115 @@
import { createMockTmdb, type MockTmdb, SPLIT_COUR_FIXTURE, TMDB_FIXTURES } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import { createTmdbClient, fetchEpisodeGroup, fetchEpisodeGroups, groupSeasonAsRichSeason } from "./index.ts";
let mock: MockTmdb | null = null;
afterEach(async () => {
await mock?.close();
mock = null;
});
async function setup() {
mock = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SPLIT_COUR_FIXTURE] });
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 });
return { mock, tmdb };
}
describe("TMDB 剧集组", () => {
it("列表:id、名称、类型、组数、集数、播出平台", async () => {
const { tmdb } = await setup();
const groups = await fetchEpisodeGroups(tmdb, 990001, "zh-CN");
expect(groups).toEqual([
{
id: "grp-production",
name: "制作季",
type: 6,
description: null,
groupCount: 2,
episodeCount: 25,
network: "TOKYO MX",
},
{
id: "grp-aired",
name: "播出顺序",
type: 1,
description: null,
groupCount: 1,
episodeCount: 25,
network: null,
},
]);
});
it("详情:组序号 = 季号,组内顺序从 1 编集号,保留 TMDB 默认季集号、标题、播出日期", async () => {
const { tmdb } = await setup();
const g = await fetchEpisodeGroup(tmdb, "grp-production", "zh-CN");
expect(g.name).toBe("制作季");
expect(g.seasons.map((s) => [s.seasonNumber, s.name, s.episodes.length, s.airDate])).toEqual([
[1, "第一部", 12, "2024-01-07"],
[2, "第二部", 13, "2024-07-07"],
]);
const s2e1 = g.seasons[1]!.episodes[0]!;
expect(s2e1).toMatchObject({
seasonNumber: 2,
episodeNumber: 1,
tmdbSeason: 1,
tmdbEpisode: 13,
name: "下篇第1话",
nameLanguage: "zh-CN",
airDate: "2024-07-07",
});
expect(g.seasons[1]!.episodes.at(-1)).toMatchObject({ episodeNumber: 13, tmdbEpisode: 25 });
});
it("组内按 order 排序;重复的组序号顺延;占位名按无标题处理", async () => {
const calls: string[] = [];
const client = {
getJson: async <T>(path: string) => {
calls.push(path);
return {
id: "x",
name: "X",
type: 3,
groups: [
{
id: "b",
name: "B",
order: 1,
episodes: [{ order: 0, season_number: 2, episode_number: 1, name: "Opening", air_date: "2020-01-01" }],
},
{
id: "a",
name: "A",
order: 1,
episodes: [
{ order: 1, season_number: 1, episode_number: 5, name: "第 5 集", air_date: "2019-02-01" },
{ order: 0, season_number: 1, episode_number: 9, name: "Nine", air_date: "2019-01-01" },
],
},
],
} as T;
},
};
const g = await fetchEpisodeGroup(client, "x", "zh-CN");
expect(calls).toEqual(["/tv/episode_group/x"]);
expect(g.seasons.map((s) => [s.seasonNumber, s.name])).toEqual([
[1, "B"],
[2, "A"],
]);
expect(g.seasons[1]!.episodes.map((e) => [e.episodeNumber, e.tmdbSeason, e.tmdbEpisode, e.name])).toEqual([
[1, 1, 9, "Nine"],
[2, 1, 5, null],
]);
const rich = groupSeasonAsRichSeason(42, g.seasons[1]!, "zh-CN", "/p.jpg");
expect(rich).toMatchObject({ tvId: "42", seasonNumber: 2, name: "A", posterPath: "/p.jpg", airDate: "2019-01-01" });
});
it("剧集组不存在抛 notfound;请求走缓存", async () => {
const { tmdb, mock } = await setup();
await expect(fetchEpisodeGroup(tmdb, "nope")).rejects.toMatchObject({ kind: "notfound" });
await fetchEpisodeGroups(tmdb, 990001, "zh-CN");
const before = mock.calls.length;
await fetchEpisodeGroups(tmdb, 990001, "zh-CN");
expect(mock.calls.length).toBe(before);
});
});
@@ -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],
};
}
+1
View File
@@ -1,6 +1,7 @@
export { RequestLimiter, TtlLruCache } from "./cache.ts";
export * from "./client.ts";
export * from "./details.ts";
export * from "./episode-groups.ts";
export * from "./match.ts";
export * from "./recall.ts";
export * from "./translations.ts";
+98
View File
@@ -56,6 +56,17 @@ export interface TmdbFixture {
seasons?: TmdbFixtureSeason[];
/** 海报路径(拼 image.tmdb.org);缺省 null */
poster_path?: string | null;
/** 剧集组(`/tv/{id}/episode_groups`、`/tv/episode_group/{id}`);组内集按 TMDB 默认季集号引用 seasons 里的集 */
episode_groups?: TmdbFixtureEpisodeGroup[];
}
export interface TmdbFixtureEpisodeGroup {
id: string;
name: string;
type: number;
description?: string;
network?: string | null;
groups: { id?: string; name: string; order: number; episodes: { season_number: number; episode_number: number }[] }[];
}
function eps(n: number, start: string, zh: (i: number) => string, en: (i: number) => string): TmdbFixtureEpisode[] {
@@ -374,3 +385,90 @@ export const HEART_SIGNAL_FIXTURE: TmdbFixture = {
},
],
};
/**
* 两个 cour 被 TMDB 默认季合成一季的番剧(剧集组测试用):默认 S1 共 25 集(含季 0 特别篇 1 集);
* 剧集组「制作季」把它拆成 S1(1–12)、S2(13–25),不含特别篇;剧集组「播出顺序」把全部 25 集放在一季。
* 不在 TMDB_FIXTURES 里,用到的测试自行拼进 fixtures。
*/
export const SPLIT_COUR_FIXTURE: TmdbFixture = {
type: "tv",
id: 990001,
original_title: "Split Cour",
original_language: "ja",
origin_country: ["JP"],
date: "2024-01-07",
titles: { "zh-CN": "分割番", "en-US": "Split Cour" },
overviews: { "zh-CN": "两个 cour 的番剧。" },
popularity: 30,
alternative_titles: [],
external_ids: { imdb_id: null, tvdb_id: null },
rich: { genres: ["动画"], status: "Ended" },
poster_path: "/split.jpg",
seasons: [
{
season_number: 0,
air_date: "2024-12-25",
names: { "zh-CN": "特别篇", "en-US": "Specials" },
episodes: [{ episode_number: 1, air_date: "2024-12-25", names: { "zh-CN": "圣诞特别篇", "en-US": "Xmas" } }],
},
{
season_number: 1,
air_date: "2024-01-07",
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
poster_path: "/split-s1.jpg",
episodes: [
...eps(
12,
"2024-01-07",
(i) => `上篇第${i}话`,
(i) => `Part1 Ep${i}`,
),
...eps(
13,
"2024-07-07",
(i) => `下篇第${i}话`,
(i) => `Part2 Ep${i}`,
).map((e, k) => ({
...e,
episode_number: 13 + k,
})),
],
},
],
episode_groups: [
{
id: "grp-production",
name: "制作季",
type: 6,
network: "TOKYO MX",
groups: [
{
id: "g1",
name: "第一部",
order: 1,
episodes: Array.from({ length: 12 }, (_, k) => ({ season_number: 1, episode_number: k + 1 })),
},
{
id: "g2",
name: "第二部",
order: 2,
episodes: Array.from({ length: 13 }, (_, k) => ({ season_number: 1, episode_number: 13 + k })),
},
],
},
{
id: "grp-aired",
name: "播出顺序",
type: 1,
groups: [
{
id: "a1",
name: "全集",
order: 1,
episodes: Array.from({ length: 25 }, (_, k) => ({ season_number: 1, episode_number: k + 1 })),
},
],
},
],
};
+67 -1
View File
@@ -19,7 +19,7 @@ const notFound: MockReply = {
json: { success: false, status_code: 34, status_message: "The resource you requested could not be found." },
};
/** mock TMDB v3:/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n */
/** mock TMDB v3:/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n、/3/tv/:id/episode_groups、/3/tv/episode_group/:id */
export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTmdb> {
const fx = opts.fixtures ?? TMDB_FIXTURES;
const apiKey = opts.apiKey ?? "test-tmdb-key";
@@ -101,6 +101,72 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
};
},
],
[
/^\/3\/tv\/(\d+)\/episode_groups$/,
(m) => {
const f = find("tv", m[1]!);
if (!f) return notFound;
return {
json: {
id: f.id,
results: (f.episode_groups ?? []).map((g) => ({
id: g.id,
name: g.name,
type: g.type,
description: g.description ?? "",
group_count: g.groups.length,
episode_count: g.groups.reduce((a, x) => a + x.episodes.length, 0),
network: g.network ? { id: 1, name: g.network } : null,
})),
},
};
},
],
[
/^\/3\/tv\/episode_group\/([\w-]+)$/,
(m, q) => {
const owner = fx.find((f) => f.episode_groups?.some((g) => g.id === m[1]));
const g = owner?.episode_groups?.find((x) => x.id === m[1]);
if (!owner || !g) return notFound;
const lang = q.language ?? "en-US";
const zh = lang.startsWith("zh");
return {
json: {
id: g.id,
name: g.name,
type: g.type,
description: g.description ?? "",
group_count: g.groups.length,
episode_count: g.groups.reduce((a, x) => a + x.episodes.length, 0),
network: g.network ? { id: 1, name: g.network } : null,
groups: g.groups.map((x, gi) => ({
id: x.id ?? `${g.id}-${gi}`,
name: x.name,
order: x.order,
locked: true,
episodes: x.episodes.map((ref, order) => {
const s = owner.seasons?.find((y) => y.season_number === ref.season_number);
const e = s?.episodes.find((y) => y.episode_number === ref.episode_number);
return {
id: owner.id * 10000 + ref.season_number * 1000 + ref.episode_number,
show_id: owner.id,
order,
season_number: ref.season_number,
episode_number: ref.episode_number,
name: e?.names[lang] ?? (zh ? `第 ${ref.episode_number} 集` : `Episode ${ref.episode_number}`),
overview: e?.overviews?.[lang] ?? "",
air_date: e?.air_date ?? null,
runtime: e?.runtime ?? null,
still_path: e?.still_path ?? null,
vote_average: e?.vote_average ?? 0,
vote_count: e?.vote_count ?? 0,
};
}),
})),
},
};
},
],
[
/^\/3\/tv\/(\d+)\/season\/(\d+)$/,
(m, q) => {