import { z } from "zod"; import { ActorType, EntityBase, Id, JsonObject } from "./common.ts"; import { EvidenceLevel } from "./identity.ts"; // 集数映射规则(docs/plan/episode-resolution.md):作品 + 来源季(可空)+ 发布组(可空)的作用域, // 同一作用域同时只有一条 active。取值与 0066 迁移的 CHECK 一致(db-sync.test.ts 会查)。 export const EpisodeMappingRuleKind = z.enum(["expression", "table", "special"]); export type EpisodeMappingRuleKind = z.infer; export const EpisodeSpecialKind = z.enum(["special", "extra", "ignore"]); export type EpisodeSpecialKind = z.infer; export const EpisodeMappingRuleStatus = z.enum(["active", "disabled"]); export type EpisodeMappingRuleStatus = z.infer; const EpisodeRangeSchema = z.object({ start: z.number().int(), end: z.number().int() }); /** 表格规则的一条:来源集号区间 → 目标的一集 */ export const EpisodeMappingTableEntry = z.object({ from: EpisodeRangeSchema, to: z.object({ season: z.number().int(), episode: z.number().int() }), }); export type EpisodeMappingTableEntry = z.infer; export const EpisodeMappingRuleRow = EntityBase.extend({ mediaEntityId: Id, /** 来源季;null = 任意季 */ season: z.number().int().nullable(), /** 发布组;null = 任意发布组,比较忽略大小写 */ releaseGroup: z.string().nullable(), kind: EpisodeMappingRuleKind, /** expression 规则的偏移表达式(如 `EP-12`) */ expression: z.string().nullable(), targetSeason: z.number().int().nullable(), tableEntries: z.array(EpisodeMappingTableEntry).nullable(), special: EpisodeSpecialKind.nullable(), status: EpisodeMappingRuleStatus, evidenceLevel: EvidenceLevel, evidence: JsonObject, /** 规则失效被停用时的证据 */ disabledReason: z.string().nullable(), createdByType: ActorType, createdById: z.string().nullable(), }); export type EpisodeMappingRuleRow = z.infer; // ── 接口(批次 4):规则增删改、模拟预览、应用后的重算结果 ── export const EpisodeResolutionMethod = z.enum([ "raw", "rule", "absolute_in_season", "absolute_cross_season", "air_date", "tmdb_season", "unverified", "unresolved", "skipped", ]); export type EpisodeResolutionMethod = z.infer; /** 集数解析结果(source_release.episode_resolution 的结构);airDate 为 YYYYMMDD */ export const EpisodeResolutionView = z.object({ method: EpisodeResolutionMethod, source: z.object({ season: z.number().int().optional(), episodes: EpisodeRangeSchema.optional(), airDate: z.string().optional(), }), canonical: z.object({ season: z.number().int(), episodes: EpisodeRangeSchema }).nullable(), ruleId: z.string().optional(), ruleFailed: z.string().optional(), special: EpisodeSpecialKind.optional(), evidence: z.array(z.string()), }); export type EpisodeResolutionView = z.infer; /** 规则内容(新建 / 草稿共用):按 kind 只看对应字段,其余忽略 */ export const EpisodeMappingRuleSpec = z.object({ /** 来源季;null / 不填 = 任意季 */ season: z.number().int().min(0).max(999).nullable().optional(), /** 发布组;null / 空 = 任意发布组 */ releaseGroup: z.string().trim().max(200).nullable().optional(), kind: EpisodeMappingRuleKind, /** expression:偏移表达式,如 `EP-12`、`EP*2-1`(省略 EP 按 MP 约定补全) */ expression: z.string().trim().max(64).nullable().optional(), /** expression:目标季,缺省为来源季 */ targetSeason: z.number().int().min(0).max(999).nullable().optional(), tableEntries: z.array(EpisodeMappingTableEntry).max(500).nullable().optional(), special: EpisodeSpecialKind.nullable().optional(), }); export type EpisodeMappingRuleSpec = z.infer; export const EpisodeMappingRuleCreate = EpisodeMappingRuleSpec.extend({ /** 规则依据(如来自待处理建议的样本),原样存证 */ evidence: JsonObject.optional(), }); export type EpisodeMappingRuleCreate = z.infer; /** 修改:内容字段整体按 kind 重新校验;status 切换启用 / 停用 */ export const EpisodeMappingRuleUpdate = EpisodeMappingRuleSpec.partial().extend({ status: EpisodeMappingRuleStatus.optional(), }); export type EpisodeMappingRuleUpdate = z.infer; export const EpisodeMappingRuleList = z.object({ items: z.array(EpisodeMappingRuleRow) }); export type EpisodeMappingRuleList = z.infer; /** 按作品重算已绑定资源的结果 */ export const EpisodeReresolveResult = z.object({ /** 重算的资源数 */ releases: z.number().int().min(0), /** 方式或规范季集变化的资源数 */ changed: z.number().int().min(0), /** 由待确认转为已解决的资源数 */ resolved: z.number().int().min(0), }); export type EpisodeReresolveResult = z.infer; /** 规则写入(新建 / 修改 / 停用 / 删除)后立即按作品重算 */ export const EpisodeMappingRuleWriteResult = z.object({ rule: EpisodeMappingRuleRow, reresolve: EpisodeReresolveResult, }); export type EpisodeMappingRuleWriteResult = z.infer; export const EpisodeMappingPreviewInput = z.object({ /** 只看这个季(来源季或规范季任一匹配);不填 = 全部 */ season: z.number().int().min(0).max(999).nullable().optional(), /** 只看这个发布组(忽略大小写);不填 = 全部 */ releaseGroup: z.string().trim().max(200).nullable().optional(), /** 草稿规则:替换同作用域的已有规则后试算 */ draft: EpisodeMappingRuleSpec.nullable().optional(), /** 正在修改的规则:试算时用草稿替换它 */ replaceRuleId: Id.optional(), /** 最多看多少条近期资源(按发布时间倒序) */ limit: z.number().int().min(1).max(300).optional(), }); export type EpisodeMappingPreviewInput = z.infer; export const EpisodeValidationState = z.enum(["pass", "unverified", "fail", "special", "skipped"]); export type EpisodeValidationState = z.infer; export const EpisodeMappingPreviewItem = z.object({ sourceReleaseId: Id, title: z.string(), publishedAt: z.string(), releaseGroup: z.string().nullable(), /** 库里的解析结果;没解析过为 null */ current: EpisodeResolutionView.nullable(), /** 按原标题重解析、套用草稿规则后的结果(没有草稿时 = 当前规则重算) */ preview: EpisodeResolutionView, validation: EpisodeValidationState, /** 预览结果与库里的不同(方式、规范季集或特别篇) */ changed: z.boolean(), }); export type EpisodeMappingPreviewItem = z.infer; export const EpisodeMethodCount = z.object({ method: EpisodeResolutionMethod, count: z.number().int().min(0) }); export type EpisodeMethodCount = z.infer; export const EpisodeSuggestion = z.object({ season: z.number().int().nullable(), releaseGroup: z.string().nullable(), expression: z.string(), targetSeason: z.number().int(), samples: z.array( z.object({ source: EpisodeRangeSchema, target: z.object({ season: z.number().int(), episode: z.number().int() }), publishedAt: z.string(), airDate: z.string(), }), ), }); export type EpisodeSuggestion = z.infer; export const EpisodeMappingPreviewGroup = z.object({ releaseGroup: z.string().nullable(), total: z.number().int().min(0), /** 预览结果按方式计数(只列非零) */ methods: z.array(EpisodeMethodCount), /** 待确认数(预览结果) */ pending: z.number().int().min(0), /** 该组待确认样本推导出的建议规则 */ suggestion: EpisodeSuggestion.nullable(), }); export type EpisodeMappingPreviewGroup = z.infer; export const EpisodeSeasonSummary = z.object({ seasons: z.array( z.object({ season: z.number().int(), /** 总集数;元数据还没给出为 null */ total: z.number().int().nullable(), /** 已知播出日期的集数 */ aired: z.number().int().min(0), }), ), 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; export const EpisodeMappingPreview = z.object({ /** 作品是否参与集数解析(剧集 / 综艺) */ applicable: z.boolean(), seasons: EpisodeSeasonSummary, groups: z.array(EpisodeMappingPreviewGroup), items: z.array(EpisodeMappingPreviewItem), /** 预览结果里与当前不同的条数 */ 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;