diff --git a/packages/adapters/src/tmdb/episode-groups.test.ts b/packages/adapters/src/tmdb/episode-groups.test.ts new file mode 100644 index 00000000..9b501f79 --- /dev/null +++ b/packages/adapters/src/tmdb/episode-groups.test.ts @@ -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 (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); + }); +}); diff --git a/packages/adapters/src/tmdb/episode-groups.ts b/packages/adapters/src/tmdb/episode-groups.ts new file mode 100644 index 00000000..4438ad70 --- /dev/null +++ b/packages/adapters/src/tmdb/episode-groups.ts @@ -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; +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], + }; +} diff --git a/packages/adapters/src/tmdb/index.ts b/packages/adapters/src/tmdb/index.ts index 9cd5e2c2..4676f6bd 100644 --- a/packages/adapters/src/tmdb/index.ts +++ b/packages/adapters/src/tmdb/index.ts @@ -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"; diff --git a/packages/testkit/src/tmdb/fixtures.ts b/packages/testkit/src/tmdb/fixtures.ts index 76f21bc0..af0454c8 100644 --- a/packages/testkit/src/tmdb/fixtures.ts +++ b/packages/testkit/src/tmdb/fixtures.ts @@ -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 })), + }, + ], + }, + ], +}; diff --git a/packages/testkit/src/tmdb/mock-tmdb.ts b/packages/testkit/src/tmdb/mock-tmdb.ts index 83aebc59..8ad5272c 100644 --- a/packages/testkit/src/tmdb/mock-tmdb.ts +++ b/packages/testkit/src/tmdb/mock-tmdb.ts @@ -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 { const fx = opts.fixtures ?? TMDB_FIXTURES; const apiKey = opts.apiKey ?? "test-tmdb-key"; @@ -101,6 +101,72 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { + 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) => {