From 5434ae8ee852bcfca5eb477c8da1c8d88cece86b Mon Sep 17 00:00:00 2001
From: root
Date: Tue, 6 Oct 2026 15:45:12 +0800
Subject: [PATCH 1/7] =?UTF-8?q?feat(tmdb):=20=E5=89=A7=E9=9B=86=E7=BB=84?=
=?UTF-8?q?=E5=88=97=E8=A1=A8=E4=B8=8E=E8=AF=A6=E6=83=85=E9=80=82=E9=85=8D?=
=?UTF-8?q?(=E7=BB=84=E5=BA=8F=E5=8F=B7=E5=8D=B3=E5=AD=A3=E5=8F=B7,?=
=?UTF-8?q?=E4=BF=9D=E7=95=99=20TMDB=20=E5=8E=9F=E5=AD=A3=E9=9B=86?=
=?UTF-8?q?=E5=8F=B7)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
---
.../adapters/src/tmdb/episode-groups.test.ts | 115 +++++++++++
packages/adapters/src/tmdb/episode-groups.ts | 178 ++++++++++++++++++
packages/adapters/src/tmdb/index.ts | 1 +
packages/testkit/src/tmdb/fixtures.ts | 98 ++++++++++
packages/testkit/src/tmdb/mock-tmdb.ts | 68 ++++++-
5 files changed, 459 insertions(+), 1 deletion(-)
create mode 100644 packages/adapters/src/tmdb/episode-groups.test.ts
create mode 100644 packages/adapters/src/tmdb/episode-groups.ts
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) => {
From 3f6d8e410fc1ebacddb1dc6d8ad9077d203e9771 Mon Sep 17 00:00:00 2001
From: root
Date: Tue, 6 Oct 2026 15:45:12 +0800
Subject: [PATCH 2/7] =?UTF-8?q?feat(=E9=9B=86=E6=95=B0=E6=98=A0=E5=B0=84):?=
=?UTF-8?q?=20=E4=BD=9C=E5=93=81=E5=AD=A3=E9=9B=86=E7=BB=93=E6=9E=84?=
=?UTF-8?q?=E5=AD=97=E6=AE=B5(=E8=BF=81=E7=A7=BB=200075)=E3=80=81=E5=89=A7?=
=?UTF-8?q?=E9=9B=86=E7=BB=84=E6=8E=A5=E5=8F=A3=E5=A5=91=E7=BA=A6=E4=B8=8E?=
=?UTF-8?q?=E6=8C=89=20TMDB=20=E9=BB=98=E8=AE=A4=E5=AD=A3=E9=9B=86?=
=?UTF-8?q?=E6=8D=A2=E7=AE=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
---
packages/contracts/src/api/episode-mapping.ts | 29 +++++
packages/contracts/src/content.ts | 5 +
packages/contracts/src/episode-mapping.ts | 119 ++++++++++++++++++
.../migrations/0075_entity_episode_group.ts | 31 +++++
packages/db/src/types.ts | 4 +
.../domain/src/episode-resolution/convert.ts | 33 ++++-
.../domain/src/episode-resolution/index.ts | 1 +
.../domain/src/episode-resolution/types.ts | 14 +++
.../domain/test/episode-resolution.test.ts | 36 ++++++
9 files changed, 271 insertions(+), 1 deletion(-)
create mode 100644 packages/db/migrations/0075_entity_episode_group.ts
diff --git a/packages/contracts/src/api/episode-mapping.ts b/packages/contracts/src/api/episode-mapping.ts
index d7866091..785e1310 100644
--- a/packages/contracts/src/api/episode-mapping.ts
+++ b/packages/contracts/src/api/episode-mapping.ts
@@ -51,4 +51,33 @@ export const episodeMappingApi = {
response: em.EpisodeMappingPreview,
}),
},
+ episodeGroups: {
+ list: ep({
+ method: "get",
+ path: "/media-entities/{id}/episode-groups",
+ scope: "read",
+ summary: "作品可选的季集结构:TMDB 剧集组列表与当前选择",
+ params: IdParam,
+ response: em.EpisodeGroupList,
+ }),
+ seasons: ep({
+ method: "get",
+ path: "/media-entities/{id}/episode-structure",
+ scope: "read",
+ summary: "某套季集结构(TMDB 默认季或剧集组)的季卡片与每集,带本地已入库情况(纯读)",
+ params: IdParam,
+ query: em.EpisodeStructureQuery,
+ response: em.EpisodeStructureSeasons,
+ }),
+ set: ep({
+ method: "put",
+ path: "/media-entities/{id}/episode-group",
+ scope: "organize.execute",
+ summary:
+ "切换作品的季集结构:按 TMDB 原季集原地迁移集单元(引用不动),重建季表,按作品重算已绑定资源并刷新元数据",
+ params: IdParam,
+ body: em.EpisodeGroupSetInput,
+ response: em.EpisodeGroupSetResult,
+ }),
+ },
} as const;
diff --git a/packages/contracts/src/content.ts b/packages/contracts/src/content.ts
index 8b6663c3..473b985f 100644
--- a/packages/contracts/src/content.ts
+++ b/packages/contracts/src/content.ts
@@ -25,6 +25,11 @@ export const MediaEntity = EntityBase.extend({
lastMetadataRefreshAt: Timestamp.nullable(),
/** 作品级命名模板覆盖 {movie?, tv?, variety?}(见 settings NamingOverride);null = 不覆盖。经 PUT /media-entities/{id}/naming-override 修改 */
namingOverride: JsonObject.nullable().optional(),
+ /** 季集结构:TMDB 剧集组 id / 名称;null = TMDB 默认季。经 PUT /media-entities/{id}/episode-group 修改 */
+ episodeGroupId: z.string().nullable().optional(),
+ episodeGroupName: z.string().nullable().optional(),
+ episodeGroupSetAt: Timestamp.nullable().optional(),
+ episodeGroupSetBy: z.string().nullable().optional(),
});
export type MediaEntity = z.infer;
export const MediaEntityCreate = MediaEntity.pick({
diff --git a/packages/contracts/src/episode-mapping.ts b/packages/contracts/src/episode-mapping.ts
index fae62220..442c6ac2 100644
--- a/packages/contracts/src/episode-mapping.ts
+++ b/packages/contracts/src/episode-mapping.ts
@@ -51,6 +51,7 @@ export const EpisodeResolutionMethod = z.enum([
"absolute_in_season",
"absolute_cross_season",
"air_date",
+ "tmdb_season",
"unverified",
"unresolved",
"skipped",
@@ -197,6 +198,8 @@ export const EpisodeSeasonSummary = z.object({
),
latestSeason: z.number().int().nullable(),
ended: z.boolean(),
+ /** 季表来自哪套季集结构:null = TMDB 默认季;否则为作品选的剧集组 */
+ structure: z.object({ groupId: z.string(), name: z.string() }).nullable().optional(),
});
export type EpisodeSeasonSummary = z.infer;
@@ -210,3 +213,119 @@ export const EpisodeMappingPreview = z.object({
changed: z.number().int().min(0),
});
export type EpisodeMappingPreview = z.infer;
+
+// ── 剧集组(季集结构):作品可选 TMDB 默认季或某个剧集组,季表与季 / 集单元随之生成 ──
+
+export const EpisodeGroupSummary = z.object({
+ id: z.string(),
+ name: z.string(),
+ /** TMDB type 1–7;未知为 null */
+ type: z.number().int().nullable(),
+ /** 类型的中文名(原始播出 / 绝对集号 / DVD / 数字发行 / 故事线 / 制作季 / 电视) */
+ typeLabel: z.string().nullable(),
+ description: z.string().nullable(),
+ /** 组(= 季)数 */
+ groupCount: z.number().int().min(0),
+ episodeCount: z.number().int().min(0),
+ network: z.string().nullable(),
+});
+export type EpisodeGroupSummary = z.infer;
+
+/** 作品当前的季集结构;null = TMDB 默认季 */
+export const EpisodeStructureCurrent = z.object({
+ groupId: z.string(),
+ name: z.string(),
+ setAt: z.string().nullable(),
+ /** `:` */
+ setBy: z.string().nullable(),
+});
+export type EpisodeStructureCurrent = z.infer;
+
+export const EpisodeGroupList = z.object({
+ /** 作品是否能选剧集组(剧集 / 综艺且有 TMDB 剧集身份) */
+ applicable: z.boolean(),
+ current: EpisodeStructureCurrent.nullable(),
+ groups: z.array(EpisodeGroupSummary),
+ /** 取列表失败的原因码(TMDB 未配置 / 不可用);成功为 null */
+ error: z.string().nullable(),
+});
+export type EpisodeGroupList = z.infer;
+
+export const EpisodeStructureEpisode = z.object({
+ episodeNumber: z.number().int(),
+ name: z.string().nullable(),
+ airDate: z.string().nullable(),
+ /** TMDB 默认季集号(剧集组里才有意义;默认季时与自身相同) */
+ tmdbSeason: z.number().int(),
+ tmdbEpisode: z.number().int(),
+ /** 本地已入库(按 TMDB 原季集对照,换结构前也能看) */
+ inLibrary: z.boolean(),
+});
+export type EpisodeStructureEpisode = z.infer;
+
+export const EpisodeStructureSeason = z.object({
+ seasonNumber: z.number().int(),
+ name: z.string().nullable(),
+ episodeCount: z.number().int().min(0),
+ /** 首播日期 */
+ airDate: z.string().nullable(),
+ posterPath: z.string().nullable(),
+ /** 本地已入库集数 */
+ inLibrary: z.number().int().min(0),
+ /** 当前结构下对应的季单元(选季订阅用);不是当前结构或还没建为 null */
+ seasonUnitId: Id.nullable(),
+ episodes: z.array(EpisodeStructureEpisode),
+});
+export type EpisodeStructureSeason = z.infer;
+
+/** 某套季集结构的季卡片(TMDB 默认季或某个剧集组),带本地已入库情况 */
+export const EpisodeStructureSeasons = z.object({
+ groupId: z.string().nullable(),
+ name: z.string(),
+ /** 是不是作品当前的季集结构 */
+ current: z.boolean(),
+ seasons: z.array(EpisodeStructureSeason),
+});
+export type EpisodeStructureSeasons = z.infer;
+
+export const EpisodeStructureQuery = z.object({
+ /** 剧集组 id;不填 = TMDB 默认季 */
+ groupId: z.string().trim().min(1).max(64).optional(),
+});
+export type EpisodeStructureQuery = z.infer;
+
+export const EpisodeGroupSetInput = z.object({
+ /** 剧集组 id;null = 改回 TMDB 默认季 */
+ groupId: z.string().trim().min(1).max(64).nullable(),
+});
+export type EpisodeGroupSetInput = z.infer;
+
+/** 切换时对不上的集单元(保留原号、标临时) */
+export const EpisodeGroupUnmatchedUnit = z.object({
+ unitId: Id,
+ seasonNumber: z.number().int().nullable(),
+ episodeNumber: z.number().int().nullable(),
+ /** no_origin:求不出 TMDB 原季集;not_in_target:目标结构里没有这一集;numbering_locked:编号已人工锁定 */
+ reason: z.enum(["no_origin", "not_in_target", "numbering_locked"]),
+ /** 有资产覆盖 / 资源 / 下载等引用 */
+ referenced: z.boolean(),
+});
+export type EpisodeGroupUnmatchedUnit = z.infer;
+
+export const EpisodeGroupSetResult = z.object({
+ changed: z.boolean(),
+ current: EpisodeStructureCurrent.nullable(),
+ /** 改了季集号的集单元数 */
+ moved: z.number().int().min(0),
+ /** 季集号不变的集单元数 */
+ kept: z.number().int().min(0),
+ unmatched: z.array(EpisodeGroupUnmatchedUnit),
+ /** 新结构的季与集数 */
+ seasons: z.array(z.object({ seasonNumber: z.number().int(), episodeCount: z.number().int().min(0) })),
+ /** 不在新结构里、标为临时的季单元的季号 */
+ orphanSeasons: z.array(z.number().int()),
+ reresolve: EpisodeReresolveResult,
+ /** 切换后元数据刷新结果(refreshed / skipped / failed) */
+ refresh: z.string(),
+});
+export type EpisodeGroupSetResult = z.infer;
diff --git a/packages/db/migrations/0075_entity_episode_group.ts b/packages/db/migrations/0075_entity_episode_group.ts
new file mode 100644
index 00000000..b9e59ad0
--- /dev/null
+++ b/packages/db/migrations/0075_entity_episode_group.ts
@@ -0,0 +1,31 @@
+import type { Kysely } from "kysely";
+import { execDdl } from "../src/migration-helpers.ts";
+
+// 作品的季集结构(剧集组):null = TMDB 默认季;否则为 TMDB 剧集组 id(/tv/episode_group/{id}),季表与季 / 集单元按组生成。
+// 名称冗余存一份(界面显示不用再查 TMDB);选择时间与操作者(`:`)供审计与界面「谁改的」。
+
+// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
+export async function up(db: Kysely): Promise {
+ await execDdl(
+ db,
+ `
+alter table media_entity add column episode_group_id text;
+alter table media_entity add column episode_group_name text;
+alter table media_entity add column episode_group_set_at timestamptz;
+alter table media_entity add column episode_group_set_by text;
+`,
+ );
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: 同上
+export async function down(db: Kysely): Promise {
+ await execDdl(
+ db,
+ `
+alter table media_entity drop column if exists episode_group_set_by;
+alter table media_entity drop column if exists episode_group_set_at;
+alter table media_entity drop column if exists episode_group_name;
+alter table media_entity drop column if exists episode_group_id;
+`,
+ );
+}
diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts
index 4daaf3b8..b358b86b 100644
--- a/packages/db/src/types.ts
+++ b/packages/db/src/types.ts
@@ -952,6 +952,10 @@ export interface MediaEntityTable {
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
naming_override: Json | null;
+ episode_group_id: string | null;
+ episode_group_name: string | null;
+ episode_group_set_at: Timestamptz | null;
+ episode_group_set_by: string | null;
}
export interface MediaServerInstanceTable {
diff --git a/packages/domain/src/episode-resolution/convert.ts b/packages/domain/src/episode-resolution/convert.ts
index a27298f3..9712801b 100644
--- a/packages/domain/src/episode-resolution/convert.ts
+++ b/packages/domain/src/episode-resolution/convert.ts
@@ -3,7 +3,7 @@ import { cumulativeSeasons, seasonOf } from "./table.ts";
import type { CanonicalEpisodes, SeasonTable } from "./types.ts";
import { validateEpisodes } from "./validate.ts";
-export type ConversionMethod = "absolute_in_season" | "absolute_cross_season" | "air_date";
+export type ConversionMethod = "absolute_in_season" | "absolute_cross_season" | "air_date" | "tmdb_season";
export interface ConversionCandidate {
method: ConversionMethod;
@@ -88,6 +88,33 @@ export function airDateCandidates(airDate: string, table: SeasonTable, season: n
return hits.map((canonical) => ({ method: "air_date", canonical, evidence: `air_date:${iso}` }));
}
+/**
+ * 剧集组结构下按 TMDB 默认季集换算:把发布者的季集当作 TMDB 默认季集,经季表的 origins 对照到组内季集。
+ * 区间内每一集都要能对上,且落在同一季、组内集号连续,否则不成立。季 0 不换。
+ */
+export function tmdbSeasonCandidate(
+ season: number,
+ episodes: EpisodeRange,
+ table: SeasonTable,
+): ConversionCandidate | null {
+ const origins = table.origins;
+ if (!origins?.length || season < 1 || episodes.end - episodes.start > 200) return null;
+ const hits: { season: number; episode: number }[] = [];
+ for (let e = episodes.start; e <= episodes.end; e++) {
+ const o = origins.find((x) => x.tmdbSeason === season && x.tmdbEpisode === e);
+ if (!o) return null;
+ hits.push(o);
+ }
+ const first = hits[0]!;
+ if (hits.some((h, i) => h.season !== first.season || h.episode !== first.episode + i)) return null;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return {
+ method: "tmdb_season",
+ canonical: { season: first.season, episodes: { start: first.episode, end: hits.at(-1)!.episode } },
+ evidence: `tmdb_season:S${pad(season)}E${pad(episodes.start)}`,
+ };
+}
+
export interface ConversionOutcome {
/** 去重后、校验为 pass 的候选 */
candidates: ConversionCandidate[];
@@ -117,6 +144,10 @@ export function convertEpisodes(input: {
if (cross.kind === "split") evidence.push(cross.evidence);
}
}
+ if (episodes && season !== null) {
+ const origin = tmdbSeasonCandidate(season, episodes, table);
+ if (origin) raw.push(origin);
+ }
if (input.variety && airDate) {
raw.push(...airDateCandidates(airDate, table, seasonInferred ? null : season));
}
diff --git a/packages/domain/src/episode-resolution/index.ts b/packages/domain/src/episode-resolution/index.ts
index 4a1ea2e9..3772cadf 100644
--- a/packages/domain/src/episode-resolution/index.ts
+++ b/packages/domain/src/episode-resolution/index.ts
@@ -7,6 +7,7 @@ export {
convertEpisodes,
crossSeasonCandidate,
inSeasonCandidates,
+ tmdbSeasonCandidate,
} from "./convert.ts";
export { CONVERSION_NEAR_DAYS, resolveEpisodes } from "./resolve.ts";
export { applyRule, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts";
diff --git a/packages/domain/src/episode-resolution/types.ts b/packages/domain/src/episode-resolution/types.ts
index d3159987..5c7cc183 100644
--- a/packages/domain/src/episode-resolution/types.ts
+++ b/packages/domain/src/episode-resolution/types.ts
@@ -13,9 +13,22 @@ export interface SeasonInfo {
episodes?: SeasonEpisodeInfo[];
}
+/** 剧集组结构下一集的 TMDB 默认季集 → 组内季集 */
+export interface SeasonOrigin {
+ tmdbSeason: number;
+ tmdbEpisode: number;
+ season: number;
+ episode: number;
+}
+
/** 作品的季表(来自元数据,调用方组装)。季 0(特别篇)可以在表里,换算时忽略 */
export interface SeasonTable {
seasons: SeasonInfo[];
+ /**
+ * 作品选了 TMDB 剧集组时:每集的 TMDB 默认季集与组内季集对照。发布者按 TMDB 默认季编号(S01E14)而作品按组分季时,
+ * 换算一步按它把原号换成组内季集(方式 tmdb_season)。默认季结构不给。
+ */
+ origins?: SeasonOrigin[];
/** 最新季;null 时取表中最大季号 */
latestSeason: number | null;
/** 作品已完结:完结后不再有「元数据滞后」放行 */
@@ -58,6 +71,7 @@ export type EpisodeResolutionMethod =
| "absolute_in_season"
| "absolute_cross_season"
| "air_date"
+ | "tmdb_season"
| "unverified"
| "unresolved"
| "skipped";
diff --git a/packages/domain/test/episode-resolution.test.ts b/packages/domain/test/episode-resolution.test.ts
index 3602a541..6b60e67e 100644
--- a/packages/domain/test/episode-resolution.test.ts
+++ b/packages/domain/test/episode-resolution.test.ts
@@ -280,3 +280,39 @@ describe("resolveEpisodes 规则", () => {
expect(r).toMatchObject({ method: "unresolved", ruleFailed: "x" });
});
});
+
+describe("resolveEpisodes 剧集组:按 TMDB 默认季集换算", () => {
+ // TMDB 默认 S1 共 25 集;剧集组拆成 S1(1–12)、S2(13–25)
+ const grouped: SeasonTable = {
+ ...table([12, 13]),
+ origins: Array.from({ length: 25 }, (_, k) => ({
+ tmdbSeason: 1,
+ tmdbEpisode: k + 1,
+ season: k < 12 ? 1 : 2,
+ episode: k < 12 ? k + 1 : k - 11,
+ })),
+ };
+
+ it("S01E14(按 TMDB 默认季编号)→ 组内 S02E02", () => {
+ const r = run({ season: 1, episodes: ep(14) }, grouped);
+ expect(r).toMatchObject({ method: "tmdb_season", canonical: { season: 2, episodes: ep(2) } });
+ expect(r.evidence).toContain("tmdb_season:S01E14");
+ });
+
+ it("「- 13」季号推定:跨季绝对集号与默认季对照同解,归第 2 季第 1 集", () => {
+ const r = run({ season: 1, seasonInferred: true, episodes: ep(13) }, grouped);
+ expect(r.canonical).toEqual({ season: 2, episodes: ep(1) });
+ expect(["absolute_cross_season", "tmdb_season"]).toContain(r.method);
+ });
+
+ it("区间跨组不换;组内号本身对得上时原号优先", () => {
+ expect(run({ season: 1, episodes: ep(12, 13) }, grouped).method).toBe("unresolved");
+ expect(run({ season: 2, episodes: ep(3) }, grouped)).toMatchObject({ method: "raw" });
+ expect(run({ season: 1, episodes: ep(14, 15) }, grouped).canonical).toEqual({ season: 2, episodes: ep(2, 3) });
+ });
+
+ it("没有 origins(默认季结构)时不出这种换算", () => {
+ const { origins: _o, ...plain } = grouped;
+ expect(run({ season: 1, episodes: ep(14) }, plain).method).toBe("unresolved");
+ });
+});
From 265340751c7f0b55d424d5746208b7cdb16364ff Mon Sep 17 00:00:00 2001
From: root
Date: Tue, 6 Oct 2026 15:45:12 +0800
Subject: [PATCH 3/7] =?UTF-8?q?feat(=E5=89=A7=E9=9B=86=E7=BB=84):=20?=
=?UTF-8?q?=E4=BD=9C=E5=93=81=E5=88=87=E6=8D=A2=E5=AD=A3=E9=9B=86=E7=BB=93?=
=?UTF-8?q?=E6=9E=84,=E5=8D=95=E5=85=83=E6=8C=89=20TMDB=20=E5=8E=9F?=
=?UTF-8?q?=E5=AD=A3=E9=9B=86=E5=8E=9F=E5=9C=B0=E8=BF=81=E7=A7=BB,?=
=?UTF-8?q?=E5=AD=A3=E8=A1=A8=20/=20=E5=88=B7=E6=96=B0=20/=20=E5=88=AE?=
=?UTF-8?q?=E5=89=8A=E6=8C=89=E7=BB=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
---
.../src/modules/acquisition-organize/ports.ts | 13 +-
.../acquisition-organize/services/scrape.ts | 7 +-
.../routes/episode-mapping.ts | 9 +
.../service/episode-groups.ts | 350 +++++++++++++++++
.../service/episode-mapping-admin.ts | 10 +-
.../service/episode-resolution.ts | 7 +-
.../service/episode-structure.ts | 360 ++++++++++++++++++
.../sources-identity/service/metadata-rich.ts | 9 +-
.../sources-identity/service/metadata.ts | 61 ++-
.../sources-identity/episode-groups.test.ts | 333 ++++++++++++++++
10 files changed, 1145 insertions(+), 14 deletions(-)
create mode 100644 apps/server/src/modules/sources-identity/service/episode-groups.ts
create mode 100644 apps/server/src/modules/sources-identity/service/episode-structure.ts
create mode 100644 apps/server/test/sources-identity/episode-groups.test.ts
diff --git a/apps/server/src/modules/acquisition-organize/ports.ts b/apps/server/src/modules/acquisition-organize/ports.ts
index d783106d..21c33885 100644
--- a/apps/server/src/modules/acquisition-organize/ports.ts
+++ b/apps/server/src/modules/acquisition-organize/ports.ts
@@ -6,11 +6,13 @@ import {
type DownloaderClient,
diskUsage,
type EmbyClient,
+ fetchEpisodeGroup,
fetchRichDetail,
fetchRichSeason,
ffprobe,
getSiteAdapter,
getSiteDefinition,
+ groupSeasonAsRichSeason,
type ProbeResult,
qbAuthConfig,
type SecretResolver,
@@ -98,7 +100,8 @@ export interface AoPorts {
* TMDB 没配置返回 null;条目不存在或上游失败抛错。
*/
scrapeTmdbDetail(type: "movie" | "tv", id: string): Promise;
- scrapeTmdbSeason(tvId: string, season: number): Promise;
+ /** episodeGroupId:作品选了剧集组时按组取(季号 = 组序号,集号 = 组内顺序) */
+ scrapeTmdbSeason(tvId: string, season: number, episodeGroupId?: string | null): Promise;
options: AoOptions;
}
@@ -235,9 +238,15 @@ export function createPorts(
if (!client) return null;
return fetchRichDetail(client, type, id, await metadataLanguages(ctx), await metadataTextOptions(ctx));
},
- async scrapeTmdbSeason(tvId, season) {
+ async scrapeTmdbSeason(tvId, season, episodeGroupId) {
const client = await rt().tmdb();
if (!client) return null;
+ if (episodeGroupId) {
+ const lang = (await metadataLanguages(ctx))[0];
+ const g = await fetchEpisodeGroup(client, episodeGroupId, lang);
+ const s = g.seasons.find((x) => x.seasonNumber === season);
+ return s ? groupSeasonAsRichSeason(tvId, s, g.language) : null;
+ }
return fetchRichSeason(client, tvId, season, await metadataLanguages(ctx), await metadataTextOptions(ctx));
},
diskUsage: (p) => diskUsage(p),
diff --git a/apps/server/src/modules/acquisition-organize/services/scrape.ts b/apps/server/src/modules/acquisition-organize/services/scrape.ts
index 2f22d634..d50de135 100644
--- a/apps/server/src/modules/acquisition-organize/services/scrape.ts
+++ b/apps/server/src/modules/acquisition-organize/services/scrape.ts
@@ -970,12 +970,15 @@ export async function runScrapeRecord(
let fields: Fields | null = null;
let coverage = new Map();
let mapEpisodes: EntityEpisodeMapper | null = null;
+ /** 作品选的剧集组:缺元数据时按组取季详情(季集号是组内的) */
+ let episodeGroupId: string | null = null;
if (match.mediaEntityId) {
const e = await db
.selectFrom("media_entity")
- .select(["id", "kind", "title", "original_title", "year", "metadata", "status"])
+ .select(["id", "kind", "title", "original_title", "year", "metadata", "status", "episode_group_id"])
.where("id", "=", match.mediaEntityId)
.executeTakeFirst();
+ episodeGroupId = e?.episode_group_id ?? null;
if (e && e.status !== "merged") {
if (kind === "tv" && (e.kind === "series" || e.kind === "variety"))
mapEpisodes = await createEntityEpisodeMapper(db, e.id, e.kind);
@@ -1109,7 +1112,7 @@ export async function runScrapeRecord(
(typeof v.episode !== "number" && dated.has(v.path))),
);
if (!tmdbId || (!fromTmdb && !lacking)) continue;
- const sd = await d.ports.scrapeTmdbSeason(tmdbId, n).catch((err) => {
+ const sd = await d.ports.scrapeTmdbSeason(tmdbId, n, episodeGroupId).catch((err) => {
d.ctx.logger.warn({ recordId, season: n, err: { message: errMessage(err) } }, "刮削取 TMDB 季详情失败");
return null;
});
diff --git a/apps/server/src/modules/sources-identity/routes/episode-mapping.ts b/apps/server/src/modules/sources-identity/routes/episode-mapping.ts
index 7b1ecaf5..6b03131f 100644
--- a/apps/server/src/modules/sources-identity/routes/episode-mapping.ts
+++ b/apps/server/src/modules/sources-identity/routes/episode-mapping.ts
@@ -2,6 +2,7 @@ import { Api } from "@umt/contracts";
import type { ModuleApp } from "../../types.ts";
import { route } from "../http.ts";
import type { SourcesRuntime } from "../runtime.ts";
+import { episodeStructureSeasons, listEpisodeGroups, setEpisodeGroup } from "../service/episode-groups.ts";
import {
createRule,
deleteRule,
@@ -11,9 +12,11 @@ import {
} from "../service/episode-mapping-admin.ts";
// 集数映射(docs/plan/episode-resolution.md 批次 4):规则增删改(写入后按作品重算)、模拟预览(纯读)。
+// 剧集组:作品的季集结构(TMDB 默认季 / 某个剧集组)列表、季卡片预览、切换。
const T = ["episode-mapping"];
const E = Api.episodeMapping;
+const G = Api.episodeGroups;
export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const ctx = () => rt().ctx;
@@ -23,4 +26,10 @@ export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
route(app, { ...E.updateRule, tags: T }, ({ params, body, actor }) => updateRule(ctx(), params.id, body, actor));
route(app, { ...E.deleteRule, tags: T }, ({ params, actor }) => deleteRule(ctx(), params.id, actor));
route(app, { ...E.preview, tags: T }, ({ params, body }) => previewEpisodeMapping(ctx().db, params.id, body));
+
+ route(app, { ...G.list, tags: T }, ({ params }) => listEpisodeGroups(rt(), params.id));
+ route(app, { ...G.seasons, tags: T }, ({ params, query }) =>
+ episodeStructureSeasons(rt(), params.id, query.groupId ?? null),
+ );
+ route(app, { ...G.set, tags: T }, ({ params, body, actor }) => setEpisodeGroup(rt(), params.id, body.groupId, actor));
}
diff --git a/apps/server/src/modules/sources-identity/service/episode-groups.ts b/apps/server/src/modules/sources-identity/service/episode-groups.ts
new file mode 100644
index 00000000..012d1771
--- /dev/null
+++ b/apps/server/src/modules/sources-identity/service/episode-groups.ts
@@ -0,0 +1,350 @@
+import {
+ EPISODE_GROUP_TYPES,
+ fetchEpisodeGroup,
+ fetchEpisodeGroups,
+ fetchRichDetail,
+ type TmdbClient,
+ type TmdbEpisodeGroupDetail,
+ TmdbError,
+ type TmdbRichDetail,
+} from "@umt/adapters";
+import type {
+ EpisodeGroupList,
+ EpisodeGroupSetResult,
+ EpisodeStructureCurrent,
+ EpisodeStructureSeason,
+ EpisodeStructureSeasons,
+} from "@umt/contracts";
+import { sql } from "@umt/db";
+import { ApiException } from "../../../http/errors.ts";
+import type { SourcesRuntime } from "../runtime.ts";
+import { type Actor, audit, type Exec } from "./common.ts";
+import { resolveEntityId } from "./entities.ts";
+import { reresolveEntityEpisodes } from "./episode-resolution.ts";
+import {
+ type Coord,
+ defaultLayout,
+ entityEpisodeGroup,
+ groupLayout,
+ type Layout,
+ relayoutUnits,
+ unitOrigins,
+} from "./episode-structure.ts";
+import { refreshMetadata } from "./metadata.ts";
+import { metadataLanguages } from "./metadata-rich.ts";
+
+// 剧集组(季集结构)接口:列出 TMDB 剧集组、预览某套结构的季卡片(带本地已入库)、切换作品的季集结构。
+// 对照 MoviePilot:media/groups/{tmdbid}、media/group/seasons/{groupId}、订阅 / 整理带 episode_group(见 docs/porting/moviepilot-episode-groups.md)。
+
+const EPISODIC = new Set(["series", "variety"]);
+/** 默认季结构预览时最多拉多少季的每集信息 */
+const MAX_DEFAULT_SEASONS = 40;
+
+interface TvContext {
+ id: string;
+ tvId: string;
+ tmdb: TmdbClient;
+ lang: string;
+}
+
+async function tvContext(rt: SourcesRuntime, entityId: string): Promise {
+ const id = await resolveEntityId(rt.ctx.db, entityId);
+ if (!id) throw new ApiException("not_found", "作品不存在");
+ const e = await rt.ctx.db.selectFrom("media_entity").select(["kind"]).where("id", "=", id).executeTakeFirstOrThrow();
+ if (!EPISODIC.has(e.kind)) return { error: "not_episodic", id };
+ const ident = await rt.ctx.db
+ .selectFrom("external_identity")
+ .select(["entity_type", "external_id"])
+ .where("media_entity_id", "=", id)
+ .where("provider", "=", "tmdb")
+ .where("status", "=", "accepted")
+ .executeTakeFirst();
+ if (ident?.entity_type !== "tv") return { error: "no_tmdb_identity", id };
+ const tmdb = await rt.tmdb();
+ if (!tmdb || typeof tmdb.getJson !== "function") return { error: "tmdb_not_configured", id };
+ const lang = (await metadataLanguages(rt.ctx))[0] ?? "zh-CN";
+ return { id, tvId: ident.external_id, tmdb, lang };
+}
+
+const currentOf = (g: Awaited>): EpisodeStructureCurrent | null =>
+ g ? { groupId: g.id, name: g.name, setAt: g.setAt?.toISOString() ?? null, setBy: g.setBy } : null;
+
+/** 作品可选的剧集组与当前选择;TMDB 不可用时 groups 为空、error 给原因码 */
+export async function listEpisodeGroups(rt: SourcesRuntime, entityId: string): Promise {
+ const tv = await tvContext(rt, entityId);
+ const current = currentOf(await entityEpisodeGroup(rt.ctx.db, tv.id));
+ if ("error" in tv)
+ return {
+ applicable: tv.error !== "not_episodic" && tv.error !== "no_tmdb_identity",
+ current,
+ groups: [],
+ error: tv.error,
+ };
+ try {
+ const groups = await fetchEpisodeGroups(tv.tmdb, tv.tvId, tv.lang);
+ return {
+ applicable: true,
+ current,
+ groups: groups.map((g) => ({ ...g, typeLabel: g.type !== null ? (EPISODE_GROUP_TYPES[g.type] ?? null) : null })),
+ error: null,
+ };
+ } catch (e) {
+ if (!(e instanceof TmdbError)) throw e;
+ return { applicable: true, current, groups: [], error: `tmdb_${e.kind}` };
+ }
+}
+
+/** 默认季结构:TMDB 详情的季(含季 0 特别篇,没有集的季不列) */
+async function richDetailOf(tv: TvContext): Promise {
+ return fetchRichDetail(tv.tmdb, "tv", tv.tvId, [tv.lang]);
+}
+
+async function layoutOf(
+ tv: TvContext,
+ groupId: string | null,
+ rich?: TmdbRichDetail,
+): Promise<{ layout: Layout; detail: TmdbEpisodeGroupDetail | null; rich: TmdbRichDetail }> {
+ const r = rich ?? (await richDetailOf(tv));
+ if (!groupId) {
+ return {
+ layout: defaultLayout(
+ r.seasons
+ .filter((s) => s.episodeCount > 0)
+ .map((s) => ({
+ seasonNumber: s.seasonNumber,
+ name: s.name,
+ episodeCount: s.episodeCount,
+ airDate: s.airDate,
+ })),
+ ),
+ detail: null,
+ rich: r,
+ };
+ }
+ const detail = await fetchEpisodeGroup(tv.tmdb, groupId, tv.lang);
+ return { layout: groupLayout(detail), detail, rich: r };
+}
+
+const tmdbFailure = (e: unknown): never => {
+ if (e instanceof TmdbError) {
+ if (e.kind === "notfound") throw new ApiException("not_found", "TMDB 上没有这个剧集组");
+ throw new ApiException("upstream_unavailable", "TMDB 暂不可用,稍后再试");
+ }
+ throw e;
+};
+
+/** 已入库的集单元(直接覆盖,或所在季单元被整季覆盖) */
+async function coveredUnitIds(exec: Exec, entityId: string): Promise> {
+ const r = await sql<{ id: string }>`
+ select distinct u.id from content_unit u
+ left join content_unit s on s.id = u.parent_unit_id
+ where u.media_entity_id = ${entityId} and u.kind = 'episode' and u.status <> 'retired'
+ and exists (select 1 from asset_unit_coverage c join media_asset a on a.id = c.media_asset_id
+ where a.status = 'active' and (c.content_unit_id = u.id or c.content_unit_id = s.id))`.execute(
+ exec,
+ );
+ return new Set(r.rows.map((x) => x.id));
+}
+
+/** 某套季集结构的季卡片:季名、集数、首播、海报、本地已入库(按 TMDB 原季集对照,换结构前也能看) */
+export async function episodeStructureSeasons(
+ rt: SourcesRuntime,
+ entityId: string,
+ groupId: string | null,
+): Promise {
+ const tv = await tvContext(rt, entityId);
+ if ("error" in tv) throw new ApiException("bad_request", "作品没有 TMDB 剧集身份或 TMDB 未配置");
+ const exec = rt.ctx.db;
+ const cur = await entityEpisodeGroup(exec, tv.id);
+ const isCurrent = (cur?.id ?? null) === groupId;
+ let target: Awaited>;
+ let current: Layout;
+ try {
+ target = await layoutOf(tv, groupId);
+ current = isCurrent ? target.layout : (await layoutOf(tv, cur?.id ?? null, target.rich)).layout;
+ } catch (e) {
+ return tmdbFailure(e);
+ }
+ const units = await unitOrigins(exec, tv.id, current);
+ const covered = await coveredUnitIds(exec, tv.id);
+ const have = new Set();
+ for (const u of units) {
+ if (!covered.has(u.id)) continue;
+ const pos: Coord | null = isCurrent
+ ? { season: u.season, episode: u.episode }
+ : u.origin
+ ? target.layout.fromOrigin(u.origin.season, u.origin.episode)
+ : null;
+ if (pos) have.add(`${pos.season}:${pos.episode}`);
+ }
+ const seasonUnits = isCurrent
+ ? await exec
+ .selectFrom("content_unit")
+ .select(["id", "season_number"])
+ .where("media_entity_id", "=", tv.id)
+ .where("kind", "=", "season")
+ .where("status", "<>", "retired")
+ .orderBy("created_at")
+ .execute()
+ : [];
+ const unitOf = (n: number) => seasonUnits.find((s) => s.season_number === n)?.id ?? null;
+ const posterOf = (tmdbSeason: number | undefined) =>
+ target.rich.seasons.find((x) => x.seasonNumber === tmdbSeason)?.posterPath ?? null;
+
+ const seasons: EpisodeStructureSeason[] = [];
+ if (target.detail) {
+ for (const s of target.detail.seasons) {
+ const eps = s.episodes.map((e) => ({
+ episodeNumber: e.episodeNumber,
+ name: e.name,
+ airDate: e.airDate,
+ tmdbSeason: e.tmdbSeason,
+ tmdbEpisode: e.tmdbEpisode,
+ inLibrary: have.has(`${s.seasonNumber}:${e.episodeNumber}`),
+ }));
+ seasons.push({
+ seasonNumber: s.seasonNumber,
+ name: s.name,
+ episodeCount: eps.length,
+ airDate: s.airDate,
+ posterPath: posterOf(s.episodes[0]?.tmdbSeason),
+ inLibrary: eps.filter((e) => e.inLibrary).length,
+ seasonUnitId: unitOf(s.seasonNumber),
+ episodes: eps,
+ });
+ }
+ } else {
+ for (const s of target.rich.seasons.filter((x) => x.episodeCount > 0).slice(0, MAX_DEFAULT_SEASONS)) {
+ let list: { episodeNumber: number; name: string | null; airDate: string | null }[] = [];
+ try {
+ list = (await tv.tmdb.season(tv.tvId, s.seasonNumber)).episodes;
+ } catch (e) {
+ if (!(e instanceof TmdbError)) throw e;
+ }
+ const eps = list
+ .filter((e) => e.episodeNumber >= 1)
+ .map((e) => ({
+ episodeNumber: e.episodeNumber,
+ name: e.name,
+ airDate: e.airDate,
+ tmdbSeason: s.seasonNumber,
+ tmdbEpisode: e.episodeNumber,
+ inLibrary: have.has(`${s.seasonNumber}:${e.episodeNumber}`),
+ }));
+ const count = eps.length || s.episodeCount;
+ seasons.push({
+ seasonNumber: s.seasonNumber,
+ name: s.name,
+ episodeCount: count,
+ airDate: s.airDate,
+ posterPath: s.posterPath,
+ inLibrary: eps.length
+ ? eps.filter((e) => e.inLibrary).length
+ : [...have].filter((k) => k.startsWith(`${s.seasonNumber}:`)).length,
+ seasonUnitId: unitOf(s.seasonNumber),
+ episodes: eps,
+ });
+ }
+ }
+ return { groupId, name: target.layout.name, current: isCurrent, seasons };
+}
+
+/**
+ * 切换作品的季集结构(TMDB 默认季 ⇄ 剧集组 / 剧集组之间):
+ * 事务内按 TMDB 原季集原地迁移集单元、重建季表与总集数、记下选择,按作品全量重算已绑定资源;
+ * 提交后 full 刷新元数据(季 / 集标题、播出日期、原季集对照按新结构写入,写进 NFO 的字段变了会排重刮)。
+ */
+export async function setEpisodeGroup(
+ rt: SourcesRuntime,
+ entityId: string,
+ groupId: string | null,
+ actor: Actor,
+): Promise {
+ const tv = await tvContext(rt, entityId);
+ if ("error" in tv) {
+ throw new ApiException(
+ "bad_request",
+ tv.error === "not_episodic"
+ ? "只有剧集 / 综艺能选季集结构"
+ : tv.error === "no_tmdb_identity"
+ ? "作品还没有 TMDB 剧集身份"
+ : "TMDB 未配置",
+ );
+ }
+ const before = await entityEpisodeGroup(rt.ctx.db, tv.id);
+ const fromId = before?.id ?? null;
+ const empty = { releases: 0, changed: 0, resolved: 0 };
+ if (fromId === groupId) {
+ return {
+ changed: false,
+ current: currentOf(before),
+ moved: 0,
+ kept: 0,
+ unmatched: [],
+ seasons: [],
+ orphanSeasons: [],
+ reresolve: empty,
+ refresh: "skipped",
+ };
+ }
+ let from: Layout;
+ let to: Awaited>;
+ try {
+ to = await layoutOf(tv, groupId);
+ from = fromId ? (await layoutOf(tv, fromId, to.rich)).layout : defaultLayout([]);
+ } catch (e) {
+ return tmdbFailure(e);
+ }
+ const now = rt.now();
+ const setBy = `${actor.type}:${actor.id ?? "-"}`;
+ const moved = await rt.ctx.db.transaction().execute(async (trx) => {
+ await trx.selectFrom("media_entity").select("id").where("id", "=", tv.id).forUpdate().executeTakeFirstOrThrow();
+ const r = await relayoutUnits(trx, tv.id, from, to.layout, now, { resetTotals: true });
+ await trx
+ .updateTable("media_entity")
+ .set({
+ episode_group_id: groupId,
+ episode_group_name: groupId ? to.layout.name : null,
+ episode_group_set_at: now,
+ episode_group_set_by: setBy,
+ updated_at: now,
+ })
+ .where("id", "=", tv.id)
+ .execute();
+ await audit(
+ trx,
+ actor,
+ "media_entity.episode_group",
+ { type: "media_entity", id: tv.id },
+ {
+ from: fromId,
+ to: groupId,
+ name: groupId ? to.layout.name : null,
+ moved: r.moved,
+ kept: r.kept,
+ unmatched: r.unmatched.length,
+ orphanSeasons: r.orphanSeasons,
+ },
+ );
+ const reresolve = await reresolveEntityEpisodes(rt.ctx, trx, tv.id);
+ return { ...r, reresolve };
+ });
+ let refresh = "refreshed";
+ try {
+ refresh = (await refreshMetadata(rt, tv.id, { full: true })).status;
+ } catch (e) {
+ rt.ctx.logger.warn({ entityId: tv.id, err: { message: (e as Error).message } }, "切换季集结构后刷新元数据失败");
+ refresh = "failed";
+ }
+ return {
+ changed: true,
+ current: currentOf(await entityEpisodeGroup(rt.ctx.db, tv.id)),
+ moved: moved.moved,
+ kept: moved.kept,
+ unmatched: moved.unmatched,
+ seasons: to.layout.seasons.map((s) => ({ seasonNumber: s.seasonNumber, episodeCount: s.episodeCount })),
+ orphanSeasons: moved.orphanSeasons,
+ reresolve: moved.reresolve,
+ refresh,
+ };
+}
diff --git a/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts
index bd81215b..7f266b58 100644
--- a/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts
+++ b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts
@@ -42,6 +42,7 @@ import {
reresolveEntityEpisodes,
sameResolution,
} from "./episode-resolution.ts";
+import { entityEpisodeGroup } from "./episode-structure.ts";
// 集数映射的接口层(docs/plan/episode-resolution.md 批次 4):规则写入校验、写入后按作品重算、模拟预览、
// 待处理 episode_mapping 的「确认建议规则 / 忽略并建忽略规则」。判定语义全在 domain,这里只装配与校验。
@@ -334,8 +335,12 @@ export function validationOf(r: EpisodeResolution): EpisodeValidationState {
const sourceSeason = (r: EpisodeResolution) => r.source.season ?? (r.source.episodes ? 1 : null);
-function seasonSummary(table: SeasonTable): EpisodeMappingPreview["seasons"] {
+function seasonSummary(
+ table: SeasonTable,
+ structure: { groupId: string; name: string } | null,
+): EpisodeMappingPreview["seasons"] {
return {
+ structure,
seasons: table.seasons.map((s) => ({
season: s.season,
total: s.total,
@@ -374,7 +379,8 @@ export async function previewEpisodeMapping(
): Promise {
const entity = await loadEpisodicEntity(exec, entityId);
const table = await loadSeasonTable(exec, entityId);
- const seasons = seasonSummary(table);
+ const group = await entityEpisodeGroup(exec, entityId);
+ const seasons = seasonSummary(table, group ? { groupId: group.id, name: group.name } : null);
if (!EPISODE_KINDS.has(entity.kind)) return { applicable: false, seasons, groups: [], items: [], changed: 0 };
let rules = await loadRules(exec, entityId);
diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts
index 670fcfbd..72afde45 100644
--- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts
+++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts
@@ -24,6 +24,7 @@ import type { ServerContext } from "../../types.ts";
import { jsonb } from "../rows.ts";
import { closeDecisions, type Exec, openDecision } from "./common.ts";
import { ensureUnits } from "./entities.ts";
+import { seasonOrigins } from "./episode-structure.ts";
// 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把
// 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库:
@@ -42,11 +43,13 @@ const DECISION_LIST_LIMIT = 50;
* 作品季表:季号 ≥ 0 的季单元;最新季 = 季号 ≥ 1 的最大季单元。
* 只计元数据(tmdb)/ 人工(user)来源或已确认的季单元:资源 / 文件懒建的临时季(解析错的 S05 之类)不进季表,
* 否则它总集数为空会让该季资源一律按「元数据滞后」放行,还会抬高最新季。
+ * 作品选了剧集组时季 / 集单元本身就是按组生成的,季表随之是组的季集;另附原季集对照(origins),
+ * 发布者按 TMDB 默认季编号时换算一步据此换到组内季集。
*/
export async function loadSeasonTable(exec: Exec, entityId: string): Promise {
const entity = await exec
.selectFrom("media_entity")
- .select(["metadata"])
+ .select(["metadata", "episode_group_id"])
.where("id", "=", entityId)
.executeTakeFirst();
const units = await exec
@@ -85,10 +88,12 @@ export async function loadSeasonTable(exec: Exec, entityId: string): Promise a.season - b.season);
for (const s of list) s.episodes!.sort((a, b) => a.episode - b.episode);
const main = list.filter((s) => s.season >= 1).map((s) => s.season);
+ const origins = entity?.episode_group_id ? await seasonOrigins(exec, entityId) : [];
return {
seasons: list,
latestSeason: main.length ? Math.max(...main) : null,
ended: ENDED_STATUSES.has(airStatusOf(entity?.metadata).toLowerCase()),
+ ...(origins.length ? { origins } : {}),
};
}
diff --git a/apps/server/src/modules/sources-identity/service/episode-structure.ts b/apps/server/src/modules/sources-identity/service/episode-structure.ts
new file mode 100644
index 00000000..541f2c7a
--- /dev/null
+++ b/apps/server/src/modules/sources-identity/service/episode-structure.ts
@@ -0,0 +1,360 @@
+import type { TmdbEpisodeGroupDetail } from "@umt/adapters";
+import { sql } from "@umt/db";
+import type { SeasonOrigin } from "@umt/domain";
+import { jsonb } from "../rows.ts";
+import type { Exec } from "./common.ts";
+import { ensureSeason } from "./metadata-totals.ts";
+
+// 季集结构(TMDB 默认季 / 剧集组)与切换时的单元迁移。
+// 迁移规则(docs/runbook/identity.md「剧集组」):
+// 1. 每个未退役的集单元先求「TMDB 原季集」:单元元数据里记的原季集(按剧集组生成 / 刷新时写入的 tmdbSeason、tmdbEpisode)优先;
+// 否则按当前结构换算(默认季 = 自身季集;剧集组 = 当前组里这个位置的原季集)。
+// 2. 按目标结构把原季集换成新季集(默认季 = 原季集;剧集组 = 组序号 / 组内顺序),原地改单元的季号、集号、所属季单元。
+// 单元 id 不变,资产覆盖、资源、下载、订阅覆盖、字段覆盖等引用全部跟着走。
+// 3. 编号人工锁定的单元不动;求不出原季集、或目标结构里没有这一集的单元保留原号、标临时(来源记为资源),结果里列出;
+// 同季同集已有对上的单元时,随后的元数据刷新按重复单元合并(引用并到对上的那条,DH-1)。
+// 4. 季单元:目标结构的每季确保有季单元(同号沿用,临时的转正);不在目标结构里的季单元标临时、清总集数(引用不动)。
+
+const EPISODE_ORIGIN_FIELDS = ["tmdbSeason", "tmdbEpisode"] as const;
+
+export interface Coord {
+ season: number;
+ episode: number;
+}
+
+export interface StructureSeason {
+ seasonNumber: number;
+ name: string | null;
+ episodeCount: number;
+ airDate: string | null;
+}
+
+/** 一套季集结构:默认季(groupId null,原季集即自身)或剧集组(按组内位置对照原季集) */
+export interface Layout {
+ groupId: string | null;
+ name: string;
+ seasons: StructureSeason[];
+ toOrigin(season: number, episode: number): Coord | null;
+ fromOrigin(season: number, episode: number): Coord | null;
+}
+
+const key = (s: number, e: number) => `${s}:${e}`;
+
+export function defaultLayout(seasons: readonly StructureSeason[]): Layout {
+ return {
+ groupId: null,
+ name: "TMDB 默认季",
+ seasons: [...seasons],
+ toOrigin: (season, episode) => ({ season, episode }),
+ fromOrigin: (season, episode) => ({ season, episode }),
+ };
+}
+
+export function groupLayout(detail: TmdbEpisodeGroupDetail): Layout {
+ const toO = new Map();
+ const fromO = new Map();
+ for (const s of detail.seasons)
+ for (const e of s.episodes) {
+ toO.set(key(e.seasonNumber, e.episodeNumber), { season: e.tmdbSeason, episode: e.tmdbEpisode });
+ // 同一原集出现在多个组时取第一次出现的位置
+ if (!fromO.has(key(e.tmdbSeason, e.tmdbEpisode)))
+ fromO.set(key(e.tmdbSeason, e.tmdbEpisode), { season: e.seasonNumber, episode: e.episodeNumber });
+ }
+ return {
+ groupId: detail.id,
+ name: detail.name,
+ seasons: detail.seasons.map((s) => ({
+ seasonNumber: s.seasonNumber,
+ name: s.name,
+ episodeCount: s.episodes.length,
+ airDate: s.airDate,
+ })),
+ toOrigin: (s, e) => toO.get(key(s, e)) ?? null,
+ fromOrigin: (s, e) => fromO.get(key(s, e)) ?? null,
+ };
+}
+
+/** 作品当前选的剧集组;null = TMDB 默认季 */
+export async function entityEpisodeGroup(
+ exec: Exec,
+ entityId: string,
+): Promise<{ id: string; name: string; setAt: Date | null; setBy: string | null } | null> {
+ const e = await exec
+ .selectFrom("media_entity")
+ .select(["episode_group_id", "episode_group_name", "episode_group_set_at", "episode_group_set_by"])
+ .where("id", "=", entityId)
+ .executeTakeFirst();
+ if (!e?.episode_group_id) return null;
+ return {
+ id: e.episode_group_id,
+ name: e.episode_group_name ?? e.episode_group_id,
+ setAt: e.episode_group_set_at ?? null,
+ setBy: e.episode_group_set_by ?? null,
+ };
+}
+
+const finalNum = (metadata: unknown, field: string): number | null => {
+ const v = (metadata as Record | null)?.[field];
+ const raw = v && typeof v === "object" && "value" in v ? (v as { value: unknown }).value : null;
+ return typeof raw === "number" && Number.isInteger(raw) ? raw : null;
+};
+
+/** 单元元数据里记的 TMDB 原季集(按剧集组生成 / 刷新时写入);没有为 null */
+export function metadataOrigin(metadata: unknown): Coord | null {
+ const season = finalNum(metadata, "tmdbSeason");
+ const episode = finalNum(metadata, "tmdbEpisode");
+ return season !== null && episode !== null ? { season, episode } : null;
+}
+
+export interface UnitOriginRow {
+ id: string;
+ season: number;
+ episode: number;
+ status: string;
+ numberingLocked: boolean;
+ /** TMDB 原季集;求不出为 null */
+ origin: Coord | null;
+}
+
+/** 作品未退役集单元与其 TMDB 原季集(元数据记的优先,否则按 layout 换算) */
+export async function unitOrigins(exec: Exec, entityId: string, layout: Layout): Promise {
+ const rows = await exec
+ .selectFrom("content_unit")
+ .select(["id", "season_number", "episode_number", "status", "numbering_locked", "metadata"])
+ .where("media_entity_id", "=", entityId)
+ .where("kind", "=", "episode")
+ .where("status", "<>", "retired")
+ .where("season_number", "is not", null)
+ .where("episode_number", "is not", null)
+ .orderBy("created_at")
+ .execute();
+ return rows.map((u) => ({
+ id: u.id,
+ season: u.season_number!,
+ episode: u.episode_number!,
+ status: u.status,
+ numberingLocked: u.numbering_locked,
+ origin:
+ (layout.groupId ? metadataOrigin(u.metadata) : null) ?? layout.toOrigin(u.season_number!, u.episode_number!),
+ }));
+}
+
+/** 剧集组结构的季表对照(loadSeasonTable 用):集单元元数据里的原季集 → 单元季集 */
+export async function seasonOrigins(exec: Exec, entityId: string): Promise {
+ const r = await sql<{ season_number: number; episode_number: number; s: string | null; e: string | null }>`
+ select season_number, episode_number,
+ metadata -> 'tmdbSeason' ->> 'value' as s, metadata -> 'tmdbEpisode' ->> 'value' as e
+ from content_unit
+ where media_entity_id = ${entityId} and kind = 'episode' and status <> 'retired'
+ and season_number is not null and episode_number is not null
+ and metadata ? 'tmdbSeason' and metadata ? 'tmdbEpisode'
+ order by created_at`.execute(exec);
+ const seen = new Set();
+ const out: SeasonOrigin[] = [];
+ for (const x of r.rows) {
+ const tmdbSeason = Number(x.s);
+ const tmdbEpisode = Number(x.e);
+ if (!Number.isInteger(tmdbSeason) || !Number.isInteger(tmdbEpisode)) continue;
+ const k = key(tmdbSeason, tmdbEpisode);
+ if (seen.has(k)) continue;
+ seen.add(k);
+ out.push({ tmdbSeason, tmdbEpisode, season: x.season_number, episode: x.episode_number });
+ }
+ return out;
+}
+
+export interface UnmatchedUnit {
+ unitId: string;
+ seasonNumber: number;
+ episodeNumber: number;
+ reason: "no_origin" | "not_in_target" | "numbering_locked";
+ referenced: boolean;
+}
+
+export interface RelayoutResult {
+ moved: number;
+ kept: number;
+ unmatched: UnmatchedUnit[];
+ orphanSeasons: number[];
+}
+
+/** 单元除自身元数据外有没有被引用(资产覆盖、资源、下载、订阅覆盖…) */
+async function referencedUnits(exec: Exec, ids: readonly string[]): Promise> {
+ if (!ids.length) return new Set();
+ const r = await sql<{ id: string }>`
+ select x.id from (
+ select content_unit_id as id from asset_unit_coverage where content_unit_id = any(${[...ids]}::uuid[])
+ union select content_unit_id from source_release where content_unit_id = any(${[...ids]}::uuid[])
+ union select content_unit_id from slot_unit where content_unit_id = any(${[...ids]}::uuid[])
+ union select content_unit_id from acquisition_slot where content_unit_id = any(${[...ids]}::uuid[])
+ ) x where x.id is not null`.execute(exec);
+ return new Set(r.rows.map((x) => x.id));
+}
+
+/**
+ * 把作品的季 / 集单元从 from 结构迁到 to 结构(规则见文件头)。必须在事务里调用。
+ * resetTotals:切换结构时把季总集数直接改成新结构的集数(解除锁定);刷新时不传(总集数仍按只升规则走 applySeasonTotals)。
+ */
+export async function relayoutUnits(
+ exec: Exec,
+ entityId: string,
+ from: Layout,
+ to: Layout,
+ now: Date,
+ opts: { resetTotals?: boolean } = {},
+): Promise {
+ const out: RelayoutResult = { moved: 0, kept: 0, unmatched: [], orphanSeasons: [] };
+ const units = await unitOrigins(exec, entityId, from);
+ const seasonIds = new Map();
+ const seasonOf = async (n: number) => {
+ let id = seasonIds.get(n);
+ if (!id) {
+ id = await ensureSeason(exec, entityId, n, to.seasons.find((s) => s.seasonNumber === n)?.name ?? null);
+ seasonIds.set(n, id);
+ }
+ return id;
+ };
+ for (const s of to.seasons) await seasonOf(s.seasonNumber);
+
+ const unmatched: Omit[] = [];
+ const placed: { id: string; origin: Coord }[] = [];
+ for (const u of units) {
+ if (u.numberingLocked) {
+ unmatched.push({ unitId: u.id, seasonNumber: u.season, episodeNumber: u.episode, reason: "numbering_locked" });
+ continue;
+ }
+ const target = u.origin ? to.fromOrigin(u.origin.season, u.origin.episode) : null;
+ if (!target) {
+ unmatched.push({
+ unitId: u.id,
+ seasonNumber: u.season,
+ episodeNumber: u.episode,
+ reason: u.origin ? "not_in_target" : "no_origin",
+ });
+ continue;
+ }
+ placed.push({ id: u.id, origin: u.origin! });
+ const parent = await seasonOf(target.season);
+ if (target.season === u.season && target.episode === u.episode) {
+ out.kept++;
+ await exec
+ .updateTable("content_unit")
+ .set({ parent_unit_id: parent })
+ .where("id", "=", u.id)
+ .where(sql`parent_unit_id is distinct from ${parent}::uuid`)
+ .execute();
+ continue;
+ }
+ out.moved++;
+ await exec
+ .updateTable("content_unit")
+ .set({
+ season_number: target.season,
+ episode_number: target.episode,
+ display_order: target.episode,
+ parent_unit_id: parent,
+ updated_at: now,
+ })
+ .where("id", "=", u.id)
+ .execute();
+ }
+ // 对不上的单元标临时(来源记为资源,下次对上 TMDB 时 DH-1 再转正)
+ const demote = unmatched.filter((x) => x.reason !== "numbering_locked").map((x) => x.unitId);
+ if (demote.length)
+ await exec
+ .updateTable("content_unit")
+ .set({ status: "provisional", source: "release", updated_at: now })
+ .where("id", "in", demote)
+ .where("status", "=", "confirmed")
+ .execute();
+ const refs = await referencedUnits(
+ exec,
+ unmatched.map((x) => x.unitId),
+ );
+ out.unmatched = unmatched.map((x) => ({ ...x, referenced: refs.has(x.unitId) }));
+
+ // 原季集对照:默认季结构不记;剧集组结构给对上的集单元写上(刷新元数据时会以 tmdb 来源字段再写一遍)
+ await stripOrigins(exec, entityId, to.groupId ? demote : null);
+ if (to.groupId) for (const p of placed) await writeOrigin(exec, p.id, p.origin, now);
+
+ // 季单元:目标结构里的季总集数按新结构;不在目标结构里的季标临时
+ const keepSeasons = new Set(to.seasons.map((s) => s.seasonNumber));
+ const seasons = await exec
+ .selectFrom("content_unit")
+ .select(["id", "season_number", "status"])
+ .where("media_entity_id", "=", entityId)
+ .where("kind", "=", "season")
+ .where("status", "<>", "retired")
+ .where("season_number", "is not", null)
+ .execute();
+ for (const s of seasons) {
+ if (keepSeasons.has(s.season_number!)) continue;
+ if (!out.orphanSeasons.includes(s.season_number!)) out.orphanSeasons.push(s.season_number!);
+ await exec
+ .updateTable("content_unit")
+ .set({
+ status: "provisional",
+ source: "release",
+ episode_total: null,
+ episode_total_source: null,
+ episode_total_locked: false,
+ episode_total_at: null,
+ updated_at: now,
+ })
+ .where("id", "=", s.id)
+ .execute();
+ }
+ out.orphanSeasons.sort((a, b) => a - b);
+ if (opts.resetTotals) {
+ for (const s of to.seasons) {
+ if (s.seasonNumber < 1) continue;
+ await exec
+ .updateTable("content_unit")
+ .set({
+ episode_total: s.episodeCount > 0 ? s.episodeCount : null,
+ episode_total_source: s.episodeCount > 0 ? "tmdb" : null,
+ episode_total_locked: false,
+ episode_total_at: now,
+ title: s.name,
+ })
+ .where("media_entity_id", "=", entityId)
+ .where("kind", "=", "season")
+ .where("season_number", "=", s.seasonNumber)
+ .where("status", "<>", "retired")
+ .execute();
+ }
+ }
+ return out;
+}
+
+/** 删掉单元上的原季集记录(metadata_field 的 tmdb 行与投影出的最终值);ids = null 时清整个作品 */
+async function stripOrigins(exec: Exec, entityId: string, ids: readonly string[] | null): Promise {
+ if (ids && !ids.length) return;
+ const scope = ids
+ ? sql`id = any(${[...ids]}::uuid[])`
+ : sql`media_entity_id = ${entityId} and (metadata ? 'tmdbSeason' or metadata ? 'tmdbEpisode')`;
+ await sql`
+ delete from metadata_field f using content_unit u
+ where f.content_unit_id = u.id and f.field = any(${[...EPISODE_ORIGIN_FIELDS]}::text[])
+ and u.media_entity_id = ${entityId} ${ids ? sql`and u.id = any(${[...ids]}::uuid[])` : sql``}`.execute(exec);
+ await sql`update content_unit set metadata = metadata - 'tmdbSeason' - 'tmdbEpisode'
+ where ${scope} and media_entity_id = ${entityId}`.execute(exec);
+}
+
+/** 集单元记下 TMDB 原季集(tmdb 来源字段 + 投影值) */
+async function writeOrigin(exec: Exec, unitId: string, origin: Coord, now: Date): Promise {
+ const at = now.toISOString();
+ const fv = (value: number) => ({ value, source: "tmdb", language: null, at, locked: false });
+ for (const [field, value] of [
+ ["tmdbSeason", origin.season],
+ ["tmdbEpisode", origin.episode],
+ ] as const) {
+ await sql`insert into metadata_field (media_entity_id, content_unit_id, field, value, language, source, source_revision, fetched_at)
+ values (${null}, ${unitId}, ${field}, ${jsonb(value)}::jsonb, ${null}, 'tmdb', ${"episode_group"}, ${now})
+ on conflict (media_entity_id, content_unit_id, field, source, language) do update set
+ value = excluded.value, fetched_at = excluded.fetched_at`.execute(exec);
+ }
+ await sql`update content_unit
+ set metadata = metadata || ${jsonb({ tmdbSeason: fv(origin.season), tmdbEpisode: fv(origin.episode) })}::jsonb
+ where id = ${unitId}`.execute(exec);
+}
diff --git a/apps/server/src/modules/sources-identity/service/metadata-rich.ts b/apps/server/src/modules/sources-identity/service/metadata-rich.ts
index 34658f15..053e76e7 100644
--- a/apps/server/src/modules/sources-identity/service/metadata-rich.ts
+++ b/apps/server/src/modules/sources-identity/service/metadata-rich.ts
@@ -293,9 +293,16 @@ export async function projectUnit(
return { changedFields: changed, lockedFields };
}
-function episodeFields(e: TmdbRichEpisode): Record {
+function episodeFields(e: TmdbRichEpisode & { tmdbSeason?: number; tmdbEpisode?: number }): Record {
const air = e.airDate && DATE_RE.test(e.airDate.slice(0, 10)) ? e.airDate.slice(0, 10) : null;
return {
+ // 剧集组的集:记下 TMDB 默认季集号(切换季集结构时据此对照,季表换算也用)
+ ...(e.tmdbSeason !== undefined && e.tmdbEpisode !== undefined
+ ? {
+ tmdbSeason: { value: e.tmdbSeason, language: null },
+ tmdbEpisode: { value: e.tmdbEpisode, language: null },
+ }
+ : {}),
name: { value: e.name, language: e.nameLanguage },
overview: { value: e.overview, language: e.overviewLanguage },
airDate: { value: air, language: null },
diff --git a/apps/server/src/modules/sources-identity/service/metadata.ts b/apps/server/src/modules/sources-identity/service/metadata.ts
index 9b704e82..c2add041 100644
--- a/apps/server/src/modules/sources-identity/service/metadata.ts
+++ b/apps/server/src/modules/sources-identity/service/metadata.ts
@@ -1,6 +1,9 @@
import {
+ type CandidateSeason,
+ fetchEpisodeGroup,
fetchRichDetail,
fetchRichSeason,
+ groupSeasonAsRichSeason,
type MetadataCandidate,
type TmdbClient,
TmdbError,
@@ -23,6 +26,7 @@ import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit, type Exec } from "./common.ts";
import { resolveEntityId, seedAliases } from "./entities.ts";
import { reresolveEntityEpisodes } from "./episode-resolution.ts";
+import { entityEpisodeGroup, groupLayout, type Layout, relayoutUnits } from "./episode-structure.ts";
import {
applySeasonMetadata,
metadataIncludeSpecials,
@@ -140,7 +144,7 @@ const ENTITY_NFO_FIELDS: ReadonlySet = new Set([
/**
* 改作品的 TMDB 映射后:清掉该作品及其季 / 集单元的 tmdb 来源字段(metadata_field 与投影出的最终值),
- * 人工覆盖与锁定字段保留;单元的 title / air_date 列随 name / airDate 一起清空。之后由 full 刷新按新身份重新填,
+ * 人工覆盖与锁定字段保留;单元的 title / air_date 列随 name / airDate 一起清空;选的剧集组一并清掉。之后由 full 刷新按新身份重新填,
* 避免新身份的 NFO 混进旧作品的标题、简介、IMDb、海报(海报 / 背景图「TMDB 没有时保留旧值」不能保留旧作品的)。
*/
export async function clearTmdbMetadata(trx: Exec, entityId: string, now: Date): Promise {
@@ -177,6 +181,11 @@ export async function clearTmdbMetadata(trx: Exec, entityId: string, now: Date):
new Set(entityOverrides.map((o) => o.field)),
),
),
+ // 剧集组属于旧 TMDB 条目,换身份后回到默认季
+ episode_group_id: null,
+ episode_group_name: null,
+ episode_group_set_at: null,
+ episode_group_set_by: null,
updated_at: now,
})
.where("id", "=", entityId)
@@ -301,7 +310,46 @@ export async function refreshMetadata(
ctx.logger.debug({ entityId: id, err: { message: e.message } }, "TMDB 富详情获取失败,按基础详情刷新");
}
}
- if (rich && ident.entity_type === "tv" && detail.seasons?.length) {
+ // 作品选了剧集组:季表与季 / 集单元按组(组序号 → 季号,组内顺序 → 集号);组取不到时这次不动季集结构
+ const group = ident.entity_type === "tv" && rich ? await entityEpisodeGroup(ctx.db, id) : null;
+ let layout: Layout | null = null;
+ let groupSeasons: CandidateSeason[] | null = null;
+ if (group) {
+ try {
+ const gd = await fetchEpisodeGroup(tmdb, group.id, langs[0]);
+ layout = groupLayout(gd);
+ groupSeasons = layout.seasons.map((s) => ({
+ seasonNumber: s.seasonNumber,
+ episodeCount: s.episodeCount,
+ name: s.name,
+ airDate: s.airDate,
+ }));
+ const known = gd.seasons.map((s) => s.seasonNumber);
+ const tracked = await trackedSeasonsForAirDates(ctx.db, id, known);
+ const want = seasonsToRefresh(known, {
+ full: !!opts.full,
+ tracked,
+ inLibrary: await seasonsInLibrary(ctx.db, id),
+ specials: await metadataIncludeSpecials(ctx),
+ });
+ creatable = new Set(opts.full ? want : tracked);
+ // 组的季没有海报:取组内首集所在的 TMDB 默认季的海报
+ const posterOf = (s: (typeof gd.seasons)[number]) => {
+ const o = s.episodes[0]?.tmdbSeason;
+ return richDetail?.seasons.find((x) => x.seasonNumber === o)?.posterPath ?? null;
+ };
+ for (const s of gd.seasons)
+ if (want.includes(s.seasonNumber))
+ richSeasons.push(groupSeasonAsRichSeason(ident.external_id, s, gd.language, posterOf(s)));
+ } catch (e) {
+ if (!(e instanceof TmdbError)) throw e;
+ ctx.logger.warn(
+ { entityId: id, episodeGroup: group.id, err: { message: e.message } },
+ "剧集组获取失败,本次不刷新季集",
+ );
+ groupSeasons = [];
+ }
+ } else if (rich && ident.entity_type === "tv" && detail.seasons?.length) {
const known = detail.seasons.map((x) => x.seasonNumber);
const tracked = await trackedSeasonsForAirDates(ctx.db, id, known);
const want = seasonsToRefresh(known, {
@@ -321,7 +369,7 @@ export async function refreshMetadata(
}
// 追踪中订阅范围内的季:拉季详情写集播出日期(订阅日历);失败不影响字段刷新
const airSeasons: TmdbSeasonDetail[] = [];
- if (!rich && ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") {
+ if (!group && !rich && ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") {
const want = await trackedSeasonsForAirDates(
ctx.db,
id,
@@ -350,15 +398,16 @@ export async function refreshMetadata(
await seedAliases(trx, id, detail);
if (richDetail) await tagTitleAliases(trx, id, richDetail);
const r = await project(trx, id, now);
+ // 剧集组:TMDB 编辑调整过组内顺序时,集单元按记下的原季集挪到新位置(规则同切换季集结构)
+ if (layout) await relayoutUnits(trx, id, layout, layout, now);
// DH-1:先合并重复单元(并发懒建留下的同季同集多条),再按 TMDB 季集结构写入 / 转正
const merged = await mergeDuplicateUnits(trx, id);
if (merged.merged)
ctx.logger.info({ entityId: id, merged: merged.merged, repointed: merged.repointed }, "合并重复内容单元");
// 季总集数账本:只升;回退不低于已覆盖最高集;锁定不改(订阅完成判定据此)
+ const seasonList = groupSeasons ?? detail.seasons ?? [];
const totals =
- ident.entity_type === "tv" && detail.seasons?.length
- ? await applySeasonTotals(trx, id, detail.seasons, now)
- : null;
+ ident.entity_type === "tv" && seasonList.length ? await applySeasonTotals(trx, id, seasonList, now) : null;
for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd);
const episodes: SeasonMetadataResult[] = [];
for (const sd of richSeasons)
diff --git a/apps/server/test/sources-identity/episode-groups.test.ts b/apps/server/test/sources-identity/episode-groups.test.ts
new file mode 100644
index 00000000..8bd90162
--- /dev/null
+++ b/apps/server/test/sources-identity/episode-groups.test.ts
@@ -0,0 +1,333 @@
+import { createMockTmdb, type MockTmdb, SPLIT_COUR_FIXTURE, TMDB_FIXTURES } from "@umt/testkit";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { loadSeasonTable } from "../../src/modules/sources-identity/service/episode-resolution.ts";
+import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
+import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
+import { createHarness, EXEC_KEY, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts";
+
+// 剧集组(季集结构):TMDB 默认把两个 cour 合成一季(S1 = 25 集),剧集组「制作季」拆成 S1(1–12)、S2(13–25)。
+// 选剧集组后季表 / 单元按组生成、旧单元与资产覆盖按 TMDB 原季集原地迁移、集数映射按组校验与换算、切回默认、刷新按组。
+
+const pg = await pgAvailable();
+
+describe.skipIf(!pg)("剧集组", () => {
+ let h: Harness;
+ let tmdb: MockTmdb;
+ let siteId: string;
+ let n = 0;
+ let entityId: string;
+ let abs13: string;
+ let s01e14: string;
+ let unitE14: string;
+ let unitE30: string;
+
+ beforeAll(async () => {
+ tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SPLIT_COUR_FIXTURE] });
+ h = await createHarness("episode_groups", { tmdbUrl: tmdb.url });
+ siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id;
+ });
+ afterAll(async () => {
+ await h?.close();
+ await tmdb?.close();
+ });
+
+ const resolve = async (title: string) => {
+ const r = await h.db
+ .insertInto("source_release")
+ .values({ site_id: siteId, site_release_id: String(820000 + ++n), title, subtitle: "分割番" })
+ .returningAll()
+ .executeTakeFirstOrThrow();
+ const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
+ expect(res.outcome, JSON.stringify(res)).toBe("auto");
+ return { id: r.id, entityId: res.mediaEntityId! };
+ };
+ // biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb
+ const release = async (id: string): Promise =>
+ h.db
+ .selectFrom("source_release")
+ .select(["episode_resolution", "content_unit_id"])
+ .where("id", "=", id)
+ .executeTakeFirstOrThrow();
+ const unit = (id: string) =>
+ h.db
+ .selectFrom("content_unit")
+ .select(["season_number", "episode_number", "status", "source", "metadata", "title", "parent_unit_id"])
+ .where("id", "=", id)
+ .executeTakeFirstOrThrow();
+ const unitAt = async (season: number, episode: number) =>
+ (
+ await h.db
+ .selectFrom("content_unit")
+ .select("id")
+ .where("media_entity_id", "=", entityId)
+ .where("kind", "=", "episode")
+ .where("season_number", "=", season)
+ .where("episode_number", "=", episode)
+ .where("status", "<>", "retired")
+ .executeTakeFirstOrThrow()
+ ).id;
+ const seasonTotals = async () =>
+ (
+ await h.db
+ .selectFrom("content_unit")
+ .select(["season_number", "episode_total", "status"])
+ .where("media_entity_id", "=", entityId)
+ .where("kind", "=", "season")
+ .where("status", "<>", "retired")
+ .orderBy("season_number")
+ .execute()
+ ).map((s) => [s.season_number, s.episode_total, s.status]);
+ const call = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) =>
+ json(await h.req(path, init));
+ const setGroup = (groupId: string | null, key?: string) =>
+ call(`/media-entities/${entityId}/episode-group`, {
+ method: "PUT",
+ body: { groupId },
+ ...(key ? { key } : {}),
+ });
+
+ it("准备:默认季 S1 = 25 集;`- 13` 与 S01E14 按原号;S01E14 有入库文件;另有一条 S01E30 临时单元", async () => {
+ const r1 = await resolve("[ANi] Split Cour - 13 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]");
+ entityId = r1.entityId;
+ abs13 = r1.id;
+ expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed");
+ s01e14 = (await resolve("[Nekomoe] Split Cour S01E14 [1080p]")).id;
+ expect((await release(abs13)).episode_resolution).toMatchObject({ method: "raw", canonical: { season: 1 } });
+ expect((await release(s01e14)).episode_resolution).toMatchObject({
+ method: "raw",
+ canonical: { season: 1, episodes: { start: 14, end: 14 } },
+ });
+ expect(await seasonTotals()).toEqual([
+ [0, null, "confirmed"],
+ [1, 25, "confirmed"],
+ ]);
+ unitE14 = await unitAt(1, 14);
+ const asset = await h.db
+ .insertInto("media_asset")
+ .values({ media_entity_id: entityId, kind: "video", status: "active" })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ await h.db
+ .insertInto("asset_unit_coverage")
+ .values({ media_asset_id: asset.id, content_unit_id: unitE14 })
+ .execute();
+ const s1 = await h.db
+ .selectFrom("content_unit")
+ .select("id")
+ .where("media_entity_id", "=", entityId)
+ .where("kind", "=", "season")
+ .where("season_number", "=", 1)
+ .executeTakeFirstOrThrow();
+ unitE30 = (
+ await h.db
+ .insertInto("content_unit")
+ .values({
+ media_entity_id: entityId,
+ parent_unit_id: s1.id,
+ kind: "episode",
+ season_number: 1,
+ episode_number: 30,
+ status: "provisional",
+ source: "release",
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow()
+ ).id;
+ });
+
+ it("剧集组列表与季卡片预览:按组显示季名、集数、首播、本地已入库(换结构前按原季集对照)", async () => {
+ const list = await call(`/media-entities/${entityId}/episode-groups`, { key: READ_KEY });
+ expect(list.status).toBe(200);
+ expect(list.body.data).toMatchObject({ applicable: true, current: null, error: null });
+ expect(list.body.data.groups.map((g: { id: string; typeLabel: string }) => [g.id, g.typeLabel])).toEqual([
+ ["grp-production", "制作季"],
+ ["grp-aired", "原始播出"],
+ ]);
+
+ const preview = await call(`/media-entities/${entityId}/episode-structure?groupId=grp-production`);
+ expect(preview.status).toBe(200);
+ expect(preview.body.data).toMatchObject({ groupId: "grp-production", name: "制作季", current: false });
+ expect(
+ preview.body.data.seasons.map((s: Record) => [
+ s.seasonNumber,
+ s.name,
+ s.episodeCount,
+ s.airDate,
+ s.posterPath,
+ s.inLibrary,
+ s.seasonUnitId,
+ ]),
+ ).toEqual([
+ [1, "第一部", 12, "2024-01-07", "/split-s1.jpg", 0, null],
+ [2, "第二部", 13, "2024-07-07", "/split-s1.jpg", 1, null],
+ ]);
+ expect(preview.body.data.seasons[1].episodes[1]).toMatchObject({
+ episodeNumber: 2,
+ tmdbSeason: 1,
+ tmdbEpisode: 14,
+ inLibrary: true,
+ });
+
+ const def = await call(`/media-entities/${entityId}/episode-structure`);
+ expect(def.body.data).toMatchObject({ groupId: null, current: true });
+ const s1 = def.body.data.seasons.find((s: { seasonNumber: number }) => s.seasonNumber === 1);
+ expect(s1).toMatchObject({ episodeCount: 25, inLibrary: 1 });
+ expect(s1.seasonUnitId).toBeTruthy();
+ });
+
+ it("切到剧集组:只读 Key 不能改;集单元按原季集原地迁移(资产覆盖跟着走),季表按组,资源全量重算", async () => {
+ expect((await setGroup("grp-production", READ_KEY)).status).toBe(403);
+ const r = await setGroup("grp-production", EXEC_KEY);
+ expect(r.status, JSON.stringify(r.body)).toBe(200);
+ const d = r.body.data;
+ expect(d).toMatchObject({
+ changed: true,
+ current: { groupId: "grp-production", name: "制作季", setBy: "api_key:operator" },
+ moved: 13,
+ kept: 12,
+ seasons: [
+ { seasonNumber: 1, episodeCount: 12 },
+ { seasonNumber: 2, episodeCount: 13 },
+ ],
+ orphanSeasons: [0],
+ refresh: "refreshed",
+ });
+ expect(
+ d.unmatched.map((u: Record) => [u.seasonNumber, u.episodeNumber, u.reason, u.referenced]),
+ ).toEqual(
+ expect.arrayContaining([
+ [0, 1, "not_in_target", false],
+ [1, 30, "not_in_target", false],
+ ]),
+ );
+
+ // 同一个单元(id 不变)挪到 S02E02,资产覆盖仍指向它;标题 / 原季集对照随单元
+ const u14 = await unit(unitE14);
+ expect(u14).toMatchObject({ season_number: 2, episode_number: 2, status: "confirmed", title: "下篇第2话" });
+ expect(u14.metadata).toMatchObject({ tmdbSeason: { value: 1 }, tmdbEpisode: { value: 14 } });
+ const cov = await h.db
+ .selectFrom("asset_unit_coverage")
+ .select("content_unit_id")
+ .where("content_unit_id", "=", unitE14)
+ .execute();
+ expect(cov).toHaveLength(1);
+ expect(await unit(unitE30)).toMatchObject({ season_number: 1, episode_number: 30, status: "provisional" });
+
+ expect(await seasonTotals()).toEqual([
+ [0, null, "provisional"],
+ [1, 12, "confirmed"],
+ [2, 13, "confirmed"],
+ ]);
+ const s2Unit = await h.db
+ .selectFrom("content_unit")
+ .select(["id", "title"])
+ .where("media_entity_id", "=", entityId)
+ .where("kind", "=", "season")
+ .where("season_number", "=", 2)
+ .executeTakeFirstOrThrow();
+ expect(u14.parent_unit_id).toBe(s2Unit.id);
+ expect(s2Unit.title).toBe("第二部");
+
+ // 季表按组;带 25 条原季集对照
+ const table = await loadSeasonTable(h.db, entityId);
+ expect(table.seasons.filter((s) => s.season >= 1).map((s) => [s.season, s.total])).toEqual([
+ [1, 12],
+ [2, 13],
+ ]);
+ expect(table.origins).toHaveLength(25);
+
+ // `- 13`(季号推定)归第 2 季第 1 集;按默认季编号的 S01E14 换到 S02E02,资源指向迁移后的同一个单元
+ expect((await release(abs13)).episode_resolution).toMatchObject({
+ canonical: { season: 2, episodes: { start: 1, end: 1 } },
+ });
+ const r14 = await release(s01e14);
+ expect(r14.episode_resolution).toMatchObject({
+ method: "tmdb_season",
+ canonical: { season: 2, episodes: { start: 2, end: 2 } },
+ });
+ expect(r14.content_unit_id).toBe(unitE14);
+ expect(d.reresolve).toMatchObject({ releases: 2, changed: 2 });
+
+ const ent = await call(`/media-entities/${entityId}`);
+ expect(ent.body.data).toMatchObject({ episodeGroupId: "grp-production", episodeGroupName: "制作季" });
+ const audit = await h.db
+ .selectFrom("audit_event")
+ .select("payload")
+ .where("action", "=", "media_entity.episode_group")
+ .execute();
+ expect(audit).toHaveLength(1);
+ });
+
+ it("集数映射按组:规则目标是组内季集,按组的季表校验;新资源按组解析", async () => {
+ const rule = await call(`/media-entities/${entityId}/episode-mapping-rules`, {
+ body: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-12", targetSeason: 2 },
+ });
+ expect(rule.status, JSON.stringify(rule.body)).toBe(200);
+ const lil = (await resolve("[Lilith-Raws] Split Cour - 20 [Baha][WEB-DL][1080p]")).id;
+ expect((await release(lil)).episode_resolution).toMatchObject({
+ method: "rule",
+ canonical: { season: 2, episodes: { start: 8, end: 8 } },
+ });
+ expect((await release(lil)).content_unit_id).toBe(await unitAt(2, 8));
+ // 越过组内 S2 的 13 集:规则校验不过 → 待确认、规则停用
+ const bad = (await resolve("[Lilith-Raws] Split Cour - 27 [Baha][WEB-DL][1080p]")).id;
+ expect((await release(bad)).episode_resolution).toMatchObject({ method: "unresolved" });
+
+ const preview = await call(`/media-entities/${entityId}/episode-mapping/preview`, { body: {} });
+ expect(preview.body.data.seasons.structure).toEqual({ groupId: "grp-production", name: "制作季" });
+ });
+
+ it("剧集组下刷新元数据按组拉取:不改季集结构,单元保持组内季集", async () => {
+ const r = await refreshMetadata(h.rt, entityId, { full: true });
+ expect(r.status).toBe("refreshed");
+ expect(r.seasons?.map((s) => [s.season, s.total])).toEqual([
+ [1, 12],
+ [2, 13],
+ ]);
+ expect(await unit(unitE14)).toMatchObject({ season_number: 2, episode_number: 2, title: "下篇第2话" });
+ expect(tmdb.callsTo(/\/3\/tv\/episode_group\/grp-production/).length).toBeGreaterThan(0);
+ });
+
+ it("剧集组之间切换:按原季集对照到另一组(全部 25 集一季)", async () => {
+ const r = await setGroup("grp-aired");
+ expect(r.status, JSON.stringify(r.body)).toBe(200);
+ expect(r.body.data).toMatchObject({ moved: 13, kept: 12, orphanSeasons: [0, 2] });
+ expect(await unit(unitE14)).toMatchObject({ season_number: 1, episode_number: 14 });
+ expect((await release(s01e14)).episode_resolution).toMatchObject({ method: "raw" });
+ });
+
+ it("切回 TMDB 默认季:单元回到原号,原季集对照清掉,特别篇季转正", async () => {
+ await setGroup("grp-production");
+ expect(await unit(unitE14)).toMatchObject({ season_number: 2, episode_number: 2 });
+ const r = await setGroup(null);
+ expect(r.status, JSON.stringify(r.body)).toBe(200);
+ expect(r.body.data).toMatchObject({ changed: true, current: null, orphanSeasons: [2] });
+ const u14 = await unit(unitE14);
+ expect(u14).toMatchObject({ season_number: 1, episode_number: 14, status: "confirmed" });
+ expect(u14.metadata).not.toHaveProperty("tmdbSeason");
+ expect(await seasonTotals()).toEqual([
+ [0, null, "confirmed"],
+ [1, 25, "confirmed"],
+ [2, null, "provisional"],
+ ]);
+ expect((await loadSeasonTable(h.db, entityId)).origins).toBeUndefined();
+ expect((await release(abs13)).episode_resolution).toMatchObject({
+ method: "raw",
+ canonical: { season: 1, episodes: { start: 13, end: 13 } },
+ });
+ expect((await release(s01e14)).content_unit_id).toBe(unitE14);
+ // 同一个结构再选一次:不变
+ expect((await setGroup(null)).body.data).toMatchObject({ changed: false });
+ });
+
+ it("剧集组不存在 404,不改作品设置", async () => {
+ const r = await setGroup("nope");
+ expect(r.status).toBe(404);
+ const e = await h.db
+ .selectFrom("media_entity")
+ .select("episode_group_id")
+ .where("id", "=", entityId)
+ .executeTakeFirstOrThrow();
+ expect(e.episode_group_id).toBeNull();
+ });
+});
From 7d5ba35280ea7f0cfb5e894e8c138b024caacbbf Mon Sep 17 00:00:00 2001
From: root
Date: Tue, 6 Oct 2026 15:45:12 +0800
Subject: [PATCH 4/7] =?UTF-8?q?feat(web):=20=E4=BD=9C=E5=93=81=E9=A1=B5?=
=?UTF-8?q?=E4=B8=8E=E8=AE=A2=E9=98=85=E9=80=89=E5=AD=A3=E7=9A=84=E5=89=A7?=
=?UTF-8?q?=E9=9B=86=E7=BB=84=E8=BD=A8=E9=81=93=E5=92=8C=E5=AD=A3=E5=8D=A1?=
=?UTF-8?q?=E7=89=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
---
apps/web/app/api/episode-mapping.ts | 54 +++
.../episode-mapping/mapping-panel.tsx | 5 +-
.../components/episode-mapping/model.test.ts | 1 +
.../app/components/episode-mapping/model.ts | 1 +
.../library/episode-structure-model.ts | 67 ++++
.../library/episode-structure.test.ts | 177 +++++++++
.../components/library/episode-structure.tsx | 366 ++++++++++++++++++
.../subscriptions/subscription-form.tsx | 55 ++-
apps/web/app/routes/console/library.$id.tsx | 239 ++++++------
9 files changed, 827 insertions(+), 138 deletions(-)
create mode 100644 apps/web/app/components/library/episode-structure-model.ts
create mode 100644 apps/web/app/components/library/episode-structure.test.ts
create mode 100644 apps/web/app/components/library/episode-structure.tsx
diff --git a/apps/web/app/api/episode-mapping.ts b/apps/web/app/api/episode-mapping.ts
index f1da0756..74557600 100644
--- a/apps/web/app/api/episode-mapping.ts
+++ b/apps/web/app/api/episode-mapping.ts
@@ -1,11 +1,14 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
+ EpisodeGroupList,
+ EpisodeGroupSetResult,
EpisodeMappingPreview,
type EpisodeMappingPreviewInput,
type EpisodeMappingRuleCreate,
EpisodeMappingRuleList,
type EpisodeMappingRuleUpdate,
EpisodeMappingRuleWriteResult,
+ EpisodeStructureSeasons,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { decisionKeys } from "./decisions.ts";
@@ -18,6 +21,9 @@ export const episodeMappingKeys = {
rules: (entityId: string) => ["episode-mapping", "rules", entityId] as const,
preview: (entityId: string, input: EpisodeMappingPreviewInput) =>
["episode-mapping", "preview", entityId, input] as const,
+ groups: (entityId: string) => ["episode-mapping", "groups", entityId] as const,
+ structure: (entityId: string, groupId: string | null) =>
+ ["episode-mapping", "structure", entityId, groupId ?? "default"] as const,
};
export function useEpisodeMappingRules(entityId: string | undefined) {
@@ -73,3 +79,51 @@ export function useEpisodeMappingWrite(entityId: string) {
},
});
}
+
+// ── 剧集组(季集结构) ──
+
+/** 作品可选的 TMDB 剧集组与当前选择 */
+export function useEpisodeGroups(entityId: string | undefined, enabled = true) {
+ return useQuery({
+ queryKey: episodeMappingKeys.groups(entityId ?? ""),
+ enabled: !!entityId && enabled,
+ staleTime: 5 * 60_000,
+ queryFn: ({ signal }) =>
+ getParsed(EpisodeGroupList, `/media-entities/${entityId}/episode-groups`, undefined, signal),
+ });
+}
+
+/** 某套季集结构(null = TMDB 默认季)的季卡片与每集,带本地已入库 */
+export function useEpisodeStructure(entityId: string | undefined, groupId: string | null, enabled = true) {
+ return useQuery({
+ queryKey: episodeMappingKeys.structure(entityId ?? "", groupId),
+ enabled: !!entityId && enabled,
+ placeholderData: keepPreviousData,
+ queryFn: ({ signal }) =>
+ getParsed(
+ EpisodeStructureSeasons,
+ `/media-entities/${entityId}/episode-structure`,
+ groupId ? { groupId } : undefined,
+ signal,
+ ),
+ });
+}
+
+/** 切换作品的季集结构:成功后刷新剧集组、预览、规则、待处理与作品详情(季集都变了) */
+export function useSetEpisodeGroup(entityId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (groupId: string | null) =>
+ parseOrThrow(
+ EpisodeGroupSetResult,
+ await api.put(`/media-entities/${entityId}/episode-group`, { groupId }),
+ "/media-entities/{id}/episode-group",
+ ),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: episodeMappingKeys.all });
+ void qc.invalidateQueries({ queryKey: decisionKeys.all });
+ void qc.invalidateQueries({ queryKey: libraryKeys.all });
+ void qc.invalidateQueries({ queryKey: ["subscriptions"] });
+ },
+ });
+}
diff --git a/apps/web/app/components/episode-mapping/mapping-panel.tsx b/apps/web/app/components/episode-mapping/mapping-panel.tsx
index 99cd777d..861ea9bb 100644
--- a/apps/web/app/components/episode-mapping/mapping-panel.tsx
+++ b/apps/web/app/components/episode-mapping/mapping-panel.tsx
@@ -65,7 +65,10 @@ function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
{s.season === p.latestSeason ? " · 最新" : ""}
))}
- {p.ended ? "已完结" : "未完结"}
+
+ {p.ended ? "已完结" : "未完结"}
+ {p.structure ? ` · 按剧集组「${p.structure.name}」分季` : ""}
+
);
}
diff --git a/apps/web/app/components/episode-mapping/model.test.ts b/apps/web/app/components/episode-mapping/model.test.ts
index c6350228..ea492b19 100644
--- a/apps/web/app/components/episode-mapping/model.test.ts
+++ b/apps/web/app/components/episode-mapping/model.test.ts
@@ -28,6 +28,7 @@ describe("集数映射文案", () => {
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
+ tmdb_season: "按 TMDB 默认季集换算",
unverified: "元数据未覆盖,先按原号",
unresolved: "待确认",
skipped: "不适用",
diff --git a/apps/web/app/components/episode-mapping/model.ts b/apps/web/app/components/episode-mapping/model.ts
index f6a556f3..d3062756 100644
--- a/apps/web/app/components/episode-mapping/model.ts
+++ b/apps/web/app/components/episode-mapping/model.ts
@@ -22,6 +22,7 @@ export const METHOD_LABEL: Record = {
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
+ tmdb_season: "按 TMDB 默认季集换算",
unverified: "元数据未覆盖,先按原号",
unresolved: "待确认",
skipped: "不适用",
diff --git a/apps/web/app/components/library/episode-structure-model.ts b/apps/web/app/components/library/episode-structure-model.ts
new file mode 100644
index 00000000..d4e59103
--- /dev/null
+++ b/apps/web/app/components/library/episode-structure-model.ts
@@ -0,0 +1,67 @@
+import type { EpisodeGroupList, EpisodeStructureSeason } from "@umt/contracts";
+
+// 季集结构(TMDB 默认季 / 剧集组)的界面纯函数:轨道选项、季卡片文案、切换确认文案。学 MoviePilot 的订阅选季对话框:
+// 横向轨道选「TMDB 默认 / 各剧集组」,下面按所选结构显示季卡片(海报、季名、首播年、集数、已入库)。
+
+export interface StructureOption {
+ /** 剧集组 id;null = TMDB 默认季 */
+ groupId: string | null;
+ label: string;
+ /** 第二行:类型 · 季数 · 集数 */
+ meta: string | null;
+ current: boolean;
+}
+
+export const DEFAULT_STRUCTURE_LABEL = "TMDB 默认季";
+
+/** 轨道选项:TMDB 默认季在最前,其后按 TMDB 顺序列剧集组;当前选择打标 */
+export function structureOptions(list: EpisodeGroupList | undefined): StructureOption[] {
+ const cur = list?.current?.groupId ?? null;
+ const out: StructureOption[] = [{ groupId: null, label: DEFAULT_STRUCTURE_LABEL, meta: null, current: cur === null }];
+ for (const g of list?.groups ?? []) {
+ out.push({
+ groupId: g.id,
+ label: g.name,
+ meta: [g.typeLabel, `${g.groupCount} 季`, `${g.episodeCount} 集`].filter(Boolean).join(" · "),
+ current: cur === g.id,
+ });
+ }
+ // 当前选的剧集组已从 TMDB 删除:仍列出来,免得界面上看不到当前状态
+ if (cur && !out.some((o) => o.groupId === cur))
+ out.push({ groupId: cur, label: list?.current?.name ?? cur, meta: "TMDB 上已不存在", current: true });
+ return out;
+}
+
+/** 有剧集组可选时才显示轨道 */
+export const hasStructureChoice = (list: EpisodeGroupList | undefined) =>
+ !!list?.applicable && (list.groups.length > 0 || !!list.current);
+
+export function seasonTitle(s: Pick): string {
+ return s.name ?? (s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`);
+}
+
+/** 季卡片第二行:首播年 · 集数 */
+export function seasonLine(s: Pick): string {
+ return [s.airDate ? s.airDate.slice(0, 4) : null, `${s.episodeCount} 集`].filter(Boolean).join(" · ");
+}
+
+/** 已入库 x/y;一集都没有时返回 null(只列非零项) */
+export function inLibraryText(s: Pick): string | null {
+ return s.inLibrary > 0 ? `已入库 ${s.inLibrary}/${s.episodeCount}` : null;
+}
+
+const pad = (n: number) => String(n).padStart(2, "0");
+
+/** 剧集组里的集与 TMDB 默认季集号不同时给「原 S01E14」对照 */
+export function originText(
+ seasonNumber: number,
+ e: { episodeNumber: number; tmdbSeason: number; tmdbEpisode: number },
+): string | null {
+ if (e.tmdbSeason === seasonNumber && e.tmdbEpisode === e.episodeNumber) return null;
+ return `原 S${pad(e.tmdbSeason)}E${pad(e.tmdbEpisode)}`;
+}
+
+/** 切换确认的说明:改的是作品设置,影响季表、集数映射、订阅与刮削 */
+export function switchDescription(target: string): string {
+ return `作品改按「${target}」分季:季与集按 TMDB 原季集迁到新位置,已入库文件跟着走;集数映射、订阅和刮削都按新季集,已绑定的资源会重新换算。之后可以再切回来。`;
+}
diff --git a/apps/web/app/components/library/episode-structure.test.ts b/apps/web/app/components/library/episode-structure.test.ts
new file mode 100644
index 00000000..ff814ad3
--- /dev/null
+++ b/apps/web/app/components/library/episode-structure.test.ts
@@ -0,0 +1,177 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { EpisodeGroupList, EpisodeStructureSeasons } from "@umt/contracts";
+import { createElement, type ReactElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { episodeMappingKeys } from "~/api/episode-mapping.ts";
+import {
+ EpisodeStructureSwitcher,
+ SeasonCardGrid,
+ StructureTrack,
+ SubscriptionSeasonPicker,
+} from "./episode-structure.tsx";
+import { hasStructureChoice, inLibraryText, originText, seasonLine, structureOptions } from "./episode-structure-model.ts";
+
+const ENTITY = "6b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
+
+const groups = (current: string | null): EpisodeGroupList => ({
+ applicable: true,
+ current: current ? { groupId: current, name: "制作季", setAt: null, setBy: null } : null,
+ groups: [
+ {
+ id: "grp-production",
+ name: "制作季",
+ type: 6,
+ typeLabel: "制作季",
+ description: null,
+ groupCount: 2,
+ episodeCount: 25,
+ network: null,
+ },
+ {
+ id: "grp-aired",
+ name: "播出顺序",
+ type: 1,
+ typeLabel: "原始播出",
+ description: null,
+ groupCount: 1,
+ episodeCount: 25,
+ network: null,
+ },
+ ],
+ error: null,
+});
+
+const structure: EpisodeStructureSeasons = {
+ groupId: "grp-production",
+ name: "制作季",
+ current: true,
+ seasons: [
+ {
+ seasonNumber: 1,
+ name: "第一部",
+ episodeCount: 12,
+ airDate: "2024-01-07",
+ posterPath: null,
+ inLibrary: 0,
+ seasonUnitId: "1b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
+ episodes: [],
+ },
+ {
+ seasonNumber: 2,
+ name: "第二部",
+ episodeCount: 13,
+ airDate: "2024-07-07",
+ posterPath: null,
+ inLibrary: 1,
+ seasonUnitId: "2b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
+ episodes: [
+ {
+ episodeNumber: 2,
+ name: "下篇第2话",
+ airDate: "2024-07-14",
+ tmdbSeason: 1,
+ tmdbEpisode: 14,
+ inLibrary: true,
+ },
+ ],
+ },
+ ],
+};
+
+function withCache(el: ReactElement, list: EpisodeGroupList | null, seasons?: EpisodeStructureSeasons) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } } });
+ if (list) qc.setQueryData(episodeMappingKeys.groups(ENTITY), list);
+ if (seasons) qc.setQueryData(episodeMappingKeys.structure(ENTITY, seasons.groupId), seasons);
+ return renderToStaticMarkup(createElement(QueryClientProvider, { client: qc }, el));
+}
+
+describe("季集结构:纯函数", () => {
+ it("轨道选项:TMDB 默认季在前,剧集组带类型 · 季数 · 集数,当前选择打标", () => {
+ const opts = structureOptions(groups("grp-production"));
+ expect(opts.map((o) => [o.groupId, o.label, o.meta, o.current])).toEqual([
+ [null, "TMDB 默认季", null, false],
+ ["grp-production", "制作季", "制作季 · 2 季 · 25 集", true],
+ ["grp-aired", "播出顺序", "原始播出 · 1 季 · 25 集", false],
+ ]);
+ // 当前剧集组已从 TMDB 删除:仍列出
+ const gone = structureOptions({ ...groups("old"), groups: [] });
+ expect(gone.at(-1)).toMatchObject({ groupId: "old", current: true, meta: "TMDB 上已不存在" });
+ expect(hasStructureChoice({ ...groups(null), groups: [] })).toBe(false);
+ expect(hasStructureChoice(groups(null))).toBe(true);
+ });
+
+ it("季卡片文案与原季集对照", () => {
+ expect(seasonLine({ airDate: "2024-07-07", episodeCount: 13 })).toBe("2024 · 13 集");
+ expect(inLibraryText({ inLibrary: 0, episodeCount: 13 })).toBeNull();
+ expect(inLibraryText({ inLibrary: 3, episodeCount: 13 })).toBe("已入库 3/13");
+ expect(originText(2, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 14 })).toBe("原 S01E14");
+ expect(originText(1, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 2 })).toBeNull();
+ });
+});
+
+describe("季集结构:组件", () => {
+ it("剧集组轨道:每项一张小卡,选中的 aria-pressed", () => {
+ const html = renderToStaticMarkup(
+ createElement(StructureTrack, {
+ options: structureOptions(groups(null)),
+ value: "grp-aired",
+ onChange: () => {},
+ }),
+ );
+ expect(html).toContain("TMDB 默认季");
+ expect(html).toContain("制作季 · 2 季 · 25 集");
+ expect(html).toMatch(/aria-pressed="true"[^>]*>.*播出顺序/);
+ expect(html).toContain('aria-label="当前"');
+ });
+
+ it("季卡片:季名、首播年 · 集数、已入库 x/y,选中的高亮", () => {
+ const html = renderToStaticMarkup(
+ createElement(SeasonCardGrid, { seasons: structure.seasons, selected: 2, onPick: () => {} }),
+ );
+ expect(html).toContain("第一部");
+ expect(html).toContain("2024 · 12 集");
+ expect(html).toContain("已入库 1/13");
+ expect(html).not.toContain("已入库 0/12");
+ expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected/);
+ });
+
+ it("订阅选季:有剧集组时显示轨道 + 全部季 + 季卡片,当前结构下按季单元回显选中", () => {
+ const html = withCache(
+ createElement(SubscriptionSeasonPicker, {
+ entityId: ENTITY,
+ value: "2b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
+ onChange: () => {},
+ }),
+ groups("grp-production"),
+ structure,
+ );
+ expect(html).toContain("季集结构");
+ expect(html).toContain("全部季");
+ expect(html).toContain("第二部");
+ expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected[^"]*"[^>]*>.*?第二部/);
+ expect(html).not.toContain("不是作品当前的季集结构");
+ });
+
+ it("没有剧集组时:订阅选季不出现(表单用下拉),作品页只显示原来的季与集", () => {
+ const empty = { ...groups(null), groups: [] };
+ expect(
+ withCache(createElement(SubscriptionSeasonPicker, { entityId: ENTITY, value: "", onChange: () => {} }), empty),
+ ).toBe("");
+ const html = withCache(
+ createElement(EpisodeStructureSwitcher, { entityId: ENTITY, children: createElement("p", null, "原季列表") }),
+ empty,
+ );
+ expect(html).toBe("原季列表
");
+ });
+
+ it("作品页:有剧集组时轨道在季列表上方,当前结构显示原来的季与集", () => {
+ const html = withCache(
+ createElement(EpisodeStructureSwitcher, { entityId: ENTITY, children: createElement("p", null, "原季列表") }),
+ groups("grp-production"),
+ );
+ expect(html).toContain("季集结构");
+ expect(html).toContain("原季列表");
+ expect(html).not.toContain("改用这套结构");
+ });
+});
diff --git a/apps/web/app/components/library/episode-structure.tsx b/apps/web/app/components/library/episode-structure.tsx
new file mode 100644
index 00000000..3b3ab782
--- /dev/null
+++ b/apps/web/app/components/library/episode-structure.tsx
@@ -0,0 +1,366 @@
+import type { EpisodeGroupSetResult, EpisodeStructureSeason } from "@umt/contracts";
+import { Check, Layers } from "lucide-react";
+import { type ReactNode, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { useEpisodeGroups, useEpisodeStructure, useSetEpisodeGroup } from "~/api/episode-mapping.ts";
+import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
+import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
+import { PaginatedList } from "~/components/console/paginated-list.tsx";
+import { StatusBadge } from "~/components/console/status-badge.tsx";
+import { CoverPlaceholder } from "~/components/library/parts.tsx";
+import { EpisodeRow, SeasonSection } from "~/components/media/media-detail.tsx";
+import { Button } from "~/components/ui/button.tsx";
+import { errorMessage } from "~/lib/api.ts";
+import { cn } from "~/lib/utils.ts";
+import {
+ DEFAULT_STRUCTURE_LABEL,
+ hasStructureChoice,
+ inLibraryText,
+ originText,
+ type StructureOption,
+ seasonLine,
+ seasonTitle,
+ structureOptions,
+ switchDescription,
+} from "./episode-structure-model.ts";
+
+// 季集结构选择(学 MoviePilot SubscribeSeasonDialog):横向轨道选「TMDB 默认季 / 各剧集组」,
+// 下面按所选结构显示季卡片;选了非当前结构时可以「改用这套结构」(改的是作品设置,先确认)。
+
+/** 横向轨道:每个选项一张小卡(名称 + 类型 · 季数 · 集数),当前结构带勾 */
+export function StructureTrack({
+ options,
+ value,
+ onChange,
+}: {
+ options: StructureOption[];
+ value: string | null;
+ onChange: (groupId: string | null) => void;
+}) {
+ return (
+
+ );
+}
+
+/** 季卡片网格(订阅选季):海报、季名、首播年 · 集数、已入库;可选中 */
+export function SeasonCardGrid({
+ seasons,
+ selected,
+ onPick,
+ disabled,
+}: {
+ seasons: EpisodeStructureSeason[];
+ /** 选中的季号 */
+ selected: number | null;
+ onPick: (s: EpisodeStructureSeason) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ {seasons.map((s) => {
+ const on = s.seasonNumber === selected;
+ const lib = inLibraryText(s);
+ return (
+ -
+
+
+ );
+ })}
+
+ );
+}
+
+/** 某套结构的季折叠列表(作品页预览):季头 + 每集(集号、标题、播出日期、入库状态、原季集对照) */
+export function StructurePreview({ seasons }: { seasons: EpisodeStructureSeason[] }) {
+ const [open, setOpen] = useState>(new Set());
+ const toggle = (n: number) => {
+ const next = new Set(open);
+ if (next.has(n)) next.delete(n);
+ else next.add(n);
+ setOpen(next);
+ };
+ return (
+
+ {(s) => (
+ = s.episodeCount ? "success" : "neutral"}>
+ 已入库 {s.inLibrary}/{s.episodeCount}
+
+ ),
+ }}
+ open={open.has(s.seasonNumber)}
+ onToggle={() => toggle(s.seasonNumber)}
+ >
+ {s.episodes.length === 0 ? (
+ TMDB 上这一季还没有集信息。
+ ) : (
+
+ {(e) => (
+
+ {originText(s.seasonNumber, e)}
+
+ ) : null,
+ }}
+ />
+ )}
+
+ )}
+
+ )}
+
+ );
+}
+
+function resultToast(r: EpisodeGroupSetResult) {
+ const name = r.current?.name ?? DEFAULT_STRUCTURE_LABEL;
+ const parts = [`改按「${name}」分季`];
+ if (r.moved) parts.push(`${r.moved} 集换了季集号`);
+ if (r.unmatched.length) parts.push(`${r.unmatched.length} 集对不上、已标临时`);
+ if (r.reresolve.changed) parts.push(`${r.reresolve.changed} 条资源重新换算`);
+ toast.success(parts.join(","));
+}
+
+/**
+ * 作品页「季与集」顶部的季集结构切换:只有 TMDB 有剧集组(或作品已选了剧集组)时出现。
+ * 选中当前结构时显示 children(现有的季与集);选中其它结构时显示该结构的季卡片预览与「改用这套结构」。
+ */
+export function EpisodeStructureSwitcher({ entityId, children }: { entityId: string; children: ReactNode }) {
+ const groups = useEpisodeGroups(entityId);
+ const options = structureOptions(groups.data);
+ const current = groups.data?.current?.groupId ?? null;
+ const [picked, setPicked] = useState(undefined);
+ const selected = picked === undefined ? current : picked;
+ const viewingOther = selected !== current;
+ const structure = useEpisodeStructure(entityId, selected, viewingOther);
+ const setGroup = useSetEpisodeGroup(entityId);
+ const [confirm, setConfirm] = useState(false);
+ if (!hasStructureChoice(groups.data)) return <>{children}>;
+ const target = options.find((o) => o.groupId === selected);
+ return (
+
+
+ {!viewingOther ? (
+ children
+ ) : (
+
+
+
+ 预览「{target?.label ?? DEFAULT_STRUCTURE_LABEL}」的分季,已入库按 TMDB 原季集对照。
+
+
+
+ {structure.isPending ? (
+
+ ) : structure.error ? (
+
structure.refetch()} />
+ ) : (
+
+ )}
+
+ )}
+
{
+ try {
+ resultToast(await setGroup.mutateAsync(selected));
+ setPicked(undefined);
+ } catch (err) {
+ toast.error(errorMessage(err, "切换失败"));
+ throw err;
+ }
+ }}
+ />
+
+ );
+}
+
+/**
+ * 订阅表单的选季(学 MP):作品有剧集组时用轨道 + 季卡片代替下拉;选了非当前结构的季要先确认改作品设置,
+ * 改完按新结构的同号季单元回填。没有剧集组时返回 null,表单照用下拉。
+ */
+export function SubscriptionSeasonPicker({
+ entityId,
+ value,
+ onChange,
+ disabled,
+}: {
+ entityId: string;
+ /** 选中的季单元 id;"" = 全部季 */
+ value: string;
+ onChange: (seasonUnitId: string) => void;
+ disabled?: boolean;
+}) {
+ const groups = useEpisodeGroups(entityId);
+ const current = groups.data?.current?.groupId ?? null;
+ const [picked, setPicked] = useState(undefined);
+ const selected = picked === undefined ? current : picked;
+ const structure = useEpisodeStructure(entityId, selected, hasStructureChoice(groups.data));
+ const setGroup = useSetEpisodeGroup(entityId);
+ const [ask, setAsk] = useState(null);
+ const [after, setAfter] = useState(null);
+ const currentData = structure.data?.current ? structure.data : null;
+ // 改完结构后,按新结构里同号季的季单元回填
+ useEffect(() => {
+ if (after === null || !currentData) return;
+ const hit = currentData.seasons.find((s) => s.seasonNumber === after);
+ if (hit?.seasonUnitId) onChange(hit.seasonUnitId);
+ setAfter(null);
+ }, [after, currentData, onChange]);
+ if (!hasStructureChoice(groups.data)) return null;
+ const options = structureOptions(groups.data);
+ const seasons = structure.data?.seasons ?? [];
+ const selectedSeason = structure.data?.current
+ ? (seasons.find((s) => s.seasonUnitId && s.seasonUnitId === value)?.seasonNumber ?? null)
+ : null;
+ const target = options.find((o) => o.groupId === selected);
+ const pick = (s: EpisodeStructureSeason) => {
+ if (structure.data?.current) {
+ onChange(s.seasonUnitId && s.seasonUnitId !== value ? s.seasonUnitId : "");
+ return;
+ }
+ setAsk(s);
+ };
+ return (
+
+
+
+
+ {!structure.data?.current && structure.data ? (
+
+ 不是作品当前的季集结构,选季时会先确认改用「{target?.label}」
+
+ ) : null}
+
+ {structure.isPending ? (
+
+ ) : structure.error ? (
+
structure.refetch()} />
+ ) : (
+ s.seasonNumber > 0 || s.episodeCount > 0)}
+ selected={selectedSeason}
+ onPick={pick}
+ disabled={disabled || setGroup.isPending}
+ />
+ )}
+ !o && setAsk(null)}
+ title={`改按「${target?.label ?? DEFAULT_STRUCTURE_LABEL}」分季?`}
+ description={`订阅「${ask ? seasonTitle(ask) : ""}」要先改作品设置。${switchDescription(target?.label ?? DEFAULT_STRUCTURE_LABEL)}`}
+ confirmLabel="改用并选季"
+ onConfirm={async () => {
+ const s = ask;
+ try {
+ resultToast(await setGroup.mutateAsync(selected));
+ setPicked(undefined);
+ if (s) setAfter(s.seasonNumber);
+ setAsk(null);
+ } catch (err) {
+ toast.error(errorMessage(err, "切换失败"));
+ throw err;
+ }
+ }}
+ />
+
+ );
+}
diff --git a/apps/web/app/components/subscriptions/subscription-form.tsx b/apps/web/app/components/subscriptions/subscription-form.tsx
index ec52f86c..e9286ae4 100644
--- a/apps/web/app/components/subscriptions/subscription-form.tsx
+++ b/apps/web/app/components/subscriptions/subscription-form.tsx
@@ -13,6 +13,7 @@ import { ExternalLink, Search, X } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
+import { useEpisodeGroups } from "~/api/episode-mapping.ts";
import { useEntityFromCandidate } from "~/api/files.ts";
import { downloaders, useSiteOptions } from "~/api/instances.ts";
import { useEntityDetail, useLibraryRoots } from "~/api/library.ts";
@@ -29,6 +30,8 @@ import {
import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { AbsoluteNumberingHint } from "~/components/episode-mapping/subscription-summary.tsx";
+import { SubscriptionSeasonPicker } from "~/components/library/episode-structure.tsx";
+import { hasStructureChoice } from "~/components/library/episode-structure-model.ts";
import { PRIORITY, SEASON_SCOPE, SUB_MODE } from "~/components/library/labels.ts";
import { ChipToggleGroup } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -333,6 +336,9 @@ export function SubscriptionFormDialog({
const isMovie = kind === "movie";
const detail = useEntityDetail(entityId && !isMovie ? entityId : undefined);
+ // 作品有剧集组可选时,新建订阅用「季集结构轨道 + 季卡片」选季(学 MP);编辑时季不能改,仍是下拉
+ const epGroups = useEpisodeGroups(entityId && !isMovie && !editing ? entityId : undefined);
+ const seasonCards = !editing && !!entityId && hasStructureChoice(epGroups.data);
const seasons = (detail.data?.contentUnits ?? [])
.filter((u) => u.kind === "season" && u.seasonNumber !== null)
.sort((a, b) => (a.seasonNumber ?? 0) - (b.seasonNumber ?? 0));
@@ -537,27 +543,38 @@ export function SubscriptionFormDialog({
/>
+ {!isMovie && seasonCards && entityId ? (
+
+ patch({ seasonUnitId: v })}
+ />
+
+ ) : null}
{isMovie ? null : (
-
- patch({ seasonUnitId: v })}
- placeholder="全部季"
- options={seasons.map((s) => ({
- value: s.id,
- label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`,
- }))}
- disabled={editing || !entityId}
- />
-
+ {seasonCards ? null : (
+
+ patch({ seasonUnitId: v })}
+ placeholder="全部季"
+ options={seasons.map((s) => ({
+ value: s.id,
+ label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`,
+ }))}
+ disabled={editing || !entityId}
+ />
+
+ )}
}
>
- {rows.length > 1 ? (
-
- {(r, i) => (
-
- )}
-
- ) : null}
- {seasonNums.length === 0 ? (
-
- ) : (
-
- {(n) => {
- const su = seasonUnitOf(n);
- const eps = episodes.filter((u) => u.seasonNumber === n);
- const total = su?.episodeTotal ?? (eps.length || null);
- const nameV = su ? finalOf(su, "name")?.value : null;
- const posterV = su ? finalOf(su, "posterPath")?.value : null;
- const ovV = su ? finalOf(su, "overview")?.value : null;
- const airV = su ? (finalOf(su, "airDate")?.value ?? su.airDate) : null;
- const have = haveIn(n);
- return (
- {
- const vs = versionSeasons.find((x) => x.seasonNumber === n);
- return vs ? seasonLine(vs, !anyVersion) : null;
- })(),
- summary: (
- = total ? "success" : "neutral"}>
- 已入库 {have}/{total ?? "?"}
-
- ),
- }}
- open={openSet.has(n)}
- onToggle={() => toggle(n)}
+
+ {rows.length > 1 ? (
+
+ {(r, i) => (
+
- );
- }}
-
- )}
-
+ {r.name}
+
+ )}
+
+ ) : null}
+ {seasonNums.length === 0 ? (
+
+ ) : (
+
+ {(n) => {
+ const su = seasonUnitOf(n);
+ const eps = episodes.filter((u) => u.seasonNumber === n);
+ const total = su?.episodeTotal ?? (eps.length || null);
+ const nameV = su ? finalOf(su, "name")?.value : null;
+ const posterV = su ? finalOf(su, "posterPath")?.value : null;
+ const ovV = su ? finalOf(su, "overview")?.value : null;
+ const airV = su ? (finalOf(su, "airDate")?.value ?? su.airDate) : null;
+ const have = haveIn(n);
+ return (
+ {
+ const vs = versionSeasons.find((x) => x.seasonNumber === n);
+ return vs ? seasonLine(vs, !anyVersion) : null;
+ })(),
+ summary: (
+ = total ? "success" : "neutral"}>
+ 已入库 {have}/{total ?? "?"}
+
+ ),
+ }}
+ open={openSet.has(n)}
+ onToggle={() => toggle(n)}
+ >
+ {eps.length === 0 ? (
+ 这一季还没有集信息。
+ ) : (
+
+ {(u) => {
+ const v = (f: string) => finalOf(u, f)?.value;
+ const str = (x: unknown) => (typeof x === "string" && x ? x : null);
+ const num = (x: unknown) => (typeof x === "number" ? x : null);
+ return (
+ 0,
+ onEdit: () => setEditing(u),
+ extra: (() => {
+ const ev = versionsOf(u);
+ return ev ? (
+
+ ) : null;
+ })(),
+ }}
+ />
+ );
+ }}
+
+ )}
+
+ );
+ }}
+
+ )}
+
+
!o && setEditing(null)} />
);
From 9c4268ad2223ffebf8cacb17f55326e19d24b1c0 Mon Sep 17 00:00:00 2001
From: root
Date: Tue, 6 Oct 2026 15:49:17 +0800
Subject: [PATCH 5/7] =?UTF-8?q?fix(web):=20=E8=AE=A2=E9=98=85=E9=80=89?=
=?UTF-8?q?=E5=AD=A3=E3=80=8C=E5=85=A8=E9=83=A8=E5=AD=A3=E3=80=8D=E4=B8=8E?=
=?UTF-8?q?=E8=BD=A8=E9=81=93=E9=97=B4=E8=B7=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
---
apps/web/app/components/library/episode-structure.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/app/components/library/episode-structure.tsx b/apps/web/app/components/library/episode-structure.tsx
index 3b3ab782..f27ce556 100644
--- a/apps/web/app/components/library/episode-structure.tsx
+++ b/apps/web/app/components/library/episode-structure.tsx
@@ -312,7 +312,7 @@ export function SubscriptionSeasonPicker({
return (
-
+