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:
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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user