Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
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<typeof EpisodeMappingRuleKind>;
|
||
export const EpisodeSpecialKind = z.enum(["special", "extra", "ignore"]);
|
||
export type EpisodeSpecialKind = z.infer<typeof EpisodeSpecialKind>;
|
||
export const EpisodeMappingRuleStatus = z.enum(["active", "disabled"]);
|
||
export type EpisodeMappingRuleStatus = z.infer<typeof EpisodeMappingRuleStatus>;
|
||
|
||
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<typeof EpisodeMappingTableEntry>;
|
||
|
||
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<typeof EpisodeMappingRuleRow>;
|
||
|
||
// ── 接口(批次 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<typeof EpisodeResolutionMethod>;
|
||
|
||
/** 集数解析结果(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<typeof EpisodeResolutionView>;
|
||
|
||
/** 规则内容(新建 / 草稿共用):按 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<typeof EpisodeMappingRuleSpec>;
|
||
|
||
export const EpisodeMappingRuleCreate = EpisodeMappingRuleSpec.extend({
|
||
/** 规则依据(如来自待处理建议的样本),原样存证 */
|
||
evidence: JsonObject.optional(),
|
||
});
|
||
export type EpisodeMappingRuleCreate = z.infer<typeof EpisodeMappingRuleCreate>;
|
||
|
||
/** 修改:内容字段整体按 kind 重新校验;status 切换启用 / 停用 */
|
||
export const EpisodeMappingRuleUpdate = EpisodeMappingRuleSpec.partial().extend({
|
||
status: EpisodeMappingRuleStatus.optional(),
|
||
});
|
||
export type EpisodeMappingRuleUpdate = z.infer<typeof EpisodeMappingRuleUpdate>;
|
||
|
||
export const EpisodeMappingRuleList = z.object({ items: z.array(EpisodeMappingRuleRow) });
|
||
export type EpisodeMappingRuleList = z.infer<typeof EpisodeMappingRuleList>;
|
||
|
||
/** 按作品重算已绑定资源的结果 */
|
||
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<typeof EpisodeReresolveResult>;
|
||
|
||
/** 规则写入(新建 / 修改 / 停用 / 删除)后立即按作品重算 */
|
||
export const EpisodeMappingRuleWriteResult = z.object({
|
||
rule: EpisodeMappingRuleRow,
|
||
reresolve: EpisodeReresolveResult,
|
||
});
|
||
export type EpisodeMappingRuleWriteResult = z.infer<typeof EpisodeMappingRuleWriteResult>;
|
||
|
||
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<typeof EpisodeMappingPreviewInput>;
|
||
|
||
export const EpisodeValidationState = z.enum(["pass", "unverified", "fail", "special", "skipped"]);
|
||
export type EpisodeValidationState = z.infer<typeof EpisodeValidationState>;
|
||
|
||
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<typeof EpisodeMappingPreviewItem>;
|
||
|
||
export const EpisodeMethodCount = z.object({ method: EpisodeResolutionMethod, count: z.number().int().min(0) });
|
||
export type EpisodeMethodCount = z.infer<typeof EpisodeMethodCount>;
|
||
|
||
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<typeof EpisodeSuggestion>;
|
||
|
||
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<typeof EpisodeMappingPreviewGroup>;
|
||
|
||
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<typeof EpisodeSeasonSummary>;
|
||
|
||
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<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>;
|