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

+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) => {