feat(集数映射): 作品季集结构字段(迁移 0075)、剧集组接口契约与按 TMDB 默认季集换算

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 15:45:12 +08:00
1 parent 5434ae8ee8
commit 3f6d8e410f
9 files changed
+271 -1

No files matched your search

@@ -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;
+5
View File
@@ -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<typeof MediaEntity>;
export const MediaEntityCreate = MediaEntity.pick({
+119
View File
@@ -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<typeof EpisodeSeasonSummary>;
@@ -210,3 +213,119 @@ export const EpisodeMappingPreview = z.object({
changed: z.number().int().min(0),
});
export type EpisodeMappingPreview = z.infer<typeof EpisodeMappingPreview>;
// ── 剧集组(季集结构):作品可选 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<typeof EpisodeGroupSummary>;
/** 作品当前的季集结构;null = TMDB 默认季 */
export const EpisodeStructureCurrent = z.object({
groupId: z.string(),
name: z.string(),
setAt: z.string().nullable(),
/** `<actor_type>:<actor_id>` */
setBy: z.string().nullable(),
});
export type EpisodeStructureCurrent = z.infer<typeof EpisodeStructureCurrent>;
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<typeof EpisodeGroupList>;
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<typeof EpisodeStructureEpisode>;
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<typeof EpisodeStructureSeason>;
/** 某套季集结构的季卡片(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<typeof EpisodeStructureSeasons>;
export const EpisodeStructureQuery = z.object({
/** 剧集组 id;不填 = TMDB 默认季 */
groupId: z.string().trim().min(1).max(64).optional(),
});
export type EpisodeStructureQuery = z.infer<typeof EpisodeStructureQuery>;
export const EpisodeGroupSetInput = z.object({
/** 剧集组 id;null = 改回 TMDB 默认季 */
groupId: z.string().trim().min(1).max(64).nullable(),
});
export type EpisodeGroupSetInput = z.infer<typeof EpisodeGroupSetInput>;
/** 切换时对不上的集单元(保留原号、标临时) */
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<typeof EpisodeGroupUnmatchedUnit>;
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<typeof EpisodeGroupSetResult>;