import { z } from "zod"; import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common.ts"; // 规则域:订阅、策略版本、收录 Slot、偏好标签与抑制记录(REQ §6) export const PolicyKind = z.enum(["acquisition", "preference", "retention", "cleanup", "instance", "automation"]); export type PolicyKind = z.infer; export const PolicyStatus = z.enum(["draft", "published", "superseded", "rolled_back", "archived"]); export type PolicyStatus = z.infer; /** 策略版本:草案 → 预览 → 授权发布 → 版本化/回滚;同一 policyKey 只有一个 published */ export const PolicyRevision = EntityBase.extend({ policyKey: z.string().min(1), revision: z.number().int().min(1), kind: PolicyKind, status: PolicyStatus, title: z.string().min(1), summary: z.string().nullable(), scope: JsonObject, /** 结构化规则数据;不是可执行脚本或工作流 DSL */ rules: JsonObject, preview: JsonObject.nullable(), basedOnRevisionId: Id.nullable(), createdBy: z.string().nullable(), publishedAt: Timestamp.nullable(), publishedBy: z.string().nullable(), }); export type PolicyRevision = z.infer; /** 新建总是草案;revision 由服务端分配 */ export const PolicyRevisionCreate = PolicyRevision.pick({ policyKey: true, kind: true, title: true, summary: true, scope: true, rules: true, basedOnRevisionId: true, }).partial({ summary: true, scope: true, basedOnRevisionId: true }); export type PolicyRevisionCreate = z.infer; /** 只有草案可改内容;发布/回滚走动作端点 */ export const PolicyRevisionUpdate = PolicyRevisionCreate.omit({ policyKey: true, kind: true }).partial(); export type PolicyRevisionUpdate = z.infer; export const SubscriptionMediaKind = z.enum(["movie", "series", "variety"]); export type SubscriptionMediaKind = z.infer; export const SubscriptionMode = z.enum(["once", "continuous"]); export type SubscriptionMode = z.infer; /** 仅本季 / 持续后续季(REQ §6.5) */ export const SeasonScope = z.enum(["current_season", "following_seasons"]); export type SeasonScope = z.infer; export const SubscriptionOrigin = z.enum(["user", "agent", "policy", "import"]); export type SubscriptionOrigin = z.infer; /** 默认优先级:即时 > 追更 > 新作品 > 升级(REQ §6.8) */ export const AcquisitionPriority = z.enum(["immediate", "tracking", "new_work", "upgrade"]); export type AcquisitionPriority = z.infer; /** * active 追踪中;paused 暂停(不评估、不搜索);pending TMDB 未就绪(可搜索/评估,不可完成,元数据就绪后转 active); * completed 已完成(写 subscription_history,行保留可重新激活);cancelled 取消。 */ export const SubscriptionStatus = z.enum(["active", "paused", "pending", "completed", "cancelled"]); export type SubscriptionStatus = z.infer; export const Subscription = EntityBase.extend({ mediaEntityId: Id.nullable(), seasonUnitId: Id.nullable(), /** 作品尚未建档时的检索词 */ titleQuery: z.string().nullable(), mediaKind: SubscriptionMediaKind, mode: SubscriptionMode, seasonScope: SeasonScope, origin: SubscriptionOrigin, priority: AcquisitionPriority, status: SubscriptionStatus, policyRevisionId: Id.nullable(), params: JsonObject, statusReason: z.string().nullable(), lastCheckedAt: Timestamp.nullable(), createdBy: z.string().nullable(), /** 上次主动搜索时间 / 下次到期(按 params.searchIntervalHours,缺省不周期搜索) */ lastSearchedAt: Timestamp.nullable(), nextSearchAt: Timestamp.nullable(), /** 上次进展:任一收录版本的单元转为已入库、或总集数变化时写入;总览"上次更新"另合并计划预留时间 */ lastProgressAt: Timestamp.nullable(), /** 派生字段(列表返回):作品标题,未建档时为 null */ mediaEntityTitle: z.string().nullable().optional(), }); export type Subscription = z.infer; /** 自动补全其他版本:inherit = 取引用规则组(后者优先)的 autoFill,都没设为开 */ export const AutoFillMode = z.enum(["inherit", "on", "off"]); export type AutoFillMode = z.infer; /** 补全预算:只约束非主版本的自动补全计划(按订阅、按自然日);同时仍计入全局每日预算 */ export const FillBudget = z .object({ bytesPerDay: z.number().int().min(0).optional(), unitsPerDay: z.number().int().min(0).optional(), }) .strict(); export type FillBudget = z.infer; /** MP 分辨率(resolution)→ 收录版本硬条件 resolutions */ export const SubscriptionResolution = z.enum(["4k", "1080p", "720p"]); export type SubscriptionResolution = z.infer; /** MP 资源质量(quality / resource_type)→ 收录版本硬条件 sources / codecs;uhd 写进订阅私有规则组的包含 */ export const SubscriptionSourcePreset = z.enum([ "bluray_disc", "remux", "bluray", "uhd", "web", "hdtv", "h265", "h264", ]); export type SubscriptionSourcePreset = z.infer; /** MP 特效(effect)→ 收录版本硬条件 hdr + 软偏好 hdr 顺序;atmos 写进订阅私有规则组的包含 */ export const SubscriptionEffect = z.enum(["dv", "hdr", "sdr", "atmos"]); export type SubscriptionEffect = z.infer; /** * subscription.params 的已知键(JSON 列,其余键原样保留): * siteIds 站点范围;episodeRange 集范围(对照 MP start/total_episode);searchIntervalHours 主动搜索周期(0 = 关); * stopAfter 截止时间;downloaderInstanceId 指定下载器;ruleGroupIds 规则组;keywords 追加检索词; * include/exclude 订阅级关键词正则(作用于全部收录版本);libraryRootId 库根;downloadSavePath 下载保存路径; * autoFill/fillBudget 多版本自动补全;resolution/source/effect 质量预设(MP resolution/quality/effect)。 * include/exclude 保存时同步为订阅私有规则组(scope.subscriptionId,随订阅删除),评估只读规则组。 * 写入时兼容 MP 字段与旧键(sites/downloader/save_path/start_episode/filter_groups/keyword/autoFillVersions…), * 服务端规范化后只存本表列出的键,见 docs/runbook/subscriptions.md。 */ /** 周期主动搜索缺省关闭(对照 MP:订阅默认靠站点 RSS / 首页刷新匹配);params.searchIntervalHours > 0 才按周期搜 */ export const DEFAULT_SEARCH_INTERVAL_HOURS = 0; export const SubscriptionParams = z .object({ siteIds: z.array(Id).optional(), episodeRange: z .object({ start: z.number().int().min(1).optional(), end: z.number().int().min(1).optional() }) .optional(), searchIntervalHours: z .number() .min(0) .max(24 * 30) .optional(), stopAfter: z.string().optional(), downloaderInstanceId: Id.optional(), /** 引用的规则组(有序,串联取交集,pri 取最后一个带层的组);空 = 订阅默认组 */ ruleGroupIds: z.array(Id).optional(), /** 追加到标题/别名之后的检索词(MP keyword) */ keywords: z.array(z.string().min(1).max(100)).max(10).optional(), /** 订阅级包含/排除正则(MP include/exclude;不区分大小写,匹配 标题+副标题+标签) */ include: z.array(z.string().min(1).max(200)).max(20).optional(), exclude: z.array(z.string().min(1).max(200)).max(20).optional(), libraryRootId: Id.optional(), downloadSavePath: z.string().min(1).max(1000).optional(), autoFill: AutoFillMode.optional(), fillBudget: FillBudget.optional(), /** MP resolution / quality / effect:保存时写到该订阅自己的收录版本(硬条件 + 软偏好) */ resolution: SubscriptionResolution.optional(), source: SubscriptionSourcePreset.optional(), effect: SubscriptionEffect.optional(), }) .passthrough(); export type SubscriptionParams = z.infer; export const SubscriptionCreate = Subscription.pick({ mediaEntityId: true, seasonUnitId: true, titleQuery: true, mediaKind: true, mode: true, seasonScope: true, origin: true, priority: true, policyRevisionId: true, params: true, }) .partial({ mediaEntityId: true, seasonUnitId: true, titleQuery: true, mode: true, seasonScope: true, origin: true, priority: true, policyRevisionId: true, params: true, }) .refine((v) => !!v.mediaEntityId || !!v.titleQuery, { message: "mediaEntityId 与 titleQuery 至少给一个" }); export type SubscriptionCreate = z.infer; export const SubscriptionUpdate = Subscription.pick({ mode: true, seasonScope: true, priority: true, status: true, policyRevisionId: true, params: true, statusReason: true, }).partial(); export type SubscriptionUpdate = z.infer; /** 订阅完成历史:完成时写整行快照与进度;订阅行保留,可重新激活 */ export const SubscriptionHistory = EntityBase.extend({ subscriptionId: Id.nullable(), mediaEntityId: Id.nullable(), seasonUnitId: Id.nullable(), mediaKind: z.string(), mode: z.string(), reason: z.string(), snapshot: JsonObject, progress: JsonObject, completedAt: Timestamp, }); export type SubscriptionHistory = z.infer; /** Slot 忙时到达、待 Slot 状态变化后重评的发布 */ export const SlotDeferral = EntityBase.extend({ slotId: Id, sourceReleaseId: Id, reason: z.string(), unitKeys: z.array(z.string()), attempts: z.number().int().min(1), }); export type SlotDeferral = z.infer; /** 分集质量账本:Slot × 内容单元的当前资产与质量摘要(分集升级只替换该单元) */ export const SlotUnit = EntityBase.extend({ slotId: Id, contentUnitId: Id, mediaAssetId: Id.nullable(), planId: Id.nullable(), quality: JsonObject, pri: z.number().int().nullable(), }); export type SlotUnit = z.infer; /** 单元状态(订阅详情"每单元状态") */ export const UnitProgressState = z.enum(["covered", "inflight", "missing", "suspected_missing", "out_of_range"]); export type UnitProgressState = z.infer; /** 订阅进度:按季的总集数账本 + 每集状态 + 每 Slot 在途/延后 */ export const SubscriptionProgress = z.object({ subscriptionId: Id, status: z.string(), /** 能否完成:总集数都已知且订阅不是 pending */ completable: z.boolean(), seasons: z.array( z.object({ seasonUnitId: Id.nullable(), seasonNumber: z.number().int(), episodeTotal: z.number().int().nullable(), totalKnown: z.boolean(), totalSource: z.string().nullable(), totalLocked: z.boolean(), units: z.array( z.object({ episode: z.number().int(), state: UnitProgressState, contentUnitId: Id.nullable(), /** 该集当前版本(分集质量账本) */ quality: z .object({ resolution: z.string().nullable(), source: z.string().nullable(), pri: z.number().nullable() }) .nullable(), }), ), missing: z.array(z.number().int()), suspectedMissing: z.array(z.number().int()), complete: z.boolean(), }), ), slots: z.array( z.object({ slotId: Id, name: z.string(), status: z.string(), inflightPlans: z.array(z.object({ planId: Id, status: z.string(), unitKeys: z.array(z.string()) })), deferred: z.number().int(), nextEvaluateAt: Timestamp.nullable(), }), ), }); export type SubscriptionProgress = z.infer; /** 手动设置季总集数(只升;locked=true 锁定后元数据刷新不再改写,可用于调低) */ export const EpisodeTotalSet = z.object({ seasonNumber: z.number().int().min(0), total: z.number().int().min(1).max(5000), locked: z.boolean().optional(), }); export type EpisodeTotalSet = z.infer; // ── 订阅总览 / 覆盖矩阵(W9-E,docs/design/subscriptions.md §1–§2) ── /** * 总览分组(派生,按顺序判定):paused 暂停 → pending 待定(未建档/无 TMDB 身份,或范围内有季总集数未知) * → completed 已完成(含 continuous 订阅范围内全覆盖且总集数已知 = 等待新季)→ following 追更中(有已入库或在途单元) * → waiting 待搜索(没有任何已入库/在途单元)。cancelled 不进总览分组。 */ export const SubscriptionGroup = z.enum(["following", "waiting", "completed", "paused", "pending"]); export type SubscriptionGroup = z.infer; export const SubscriptionSort = z.enum(["lastUpdated", "nextSearch", "missing", "created", "name"]); export type SubscriptionSort = z.infer; /** 收录版本在订阅范围内的计数摘要(卡片徽标 / 矩阵行尾) */ export const SubscriptionSlotSummary = z.object({ slotId: Id, name: z.string(), /** 同订阅内顺序最小的收录版本 */ primary: z.boolean(), status: z.string(), have: z.number().int(), inflight: z.number().int(), missing: z.number().int(), /** 范围内单元总数;任一季总集数未知为 null */ total: z.number().int().nullable(), /** 开了升级且有已入库单元低于目标 pri */ upgrading: z.boolean(), /** 已入库单元的最低 pri 与目标 pri(未开升级 / 无阶梯为 null) */ minPri: z.number().int().nullable(), targetPri: z.number().int().nullable(), /** 非主版本是否自动补全(主版本恒为 true) */ autoFill: z.boolean(), ruleGroupIds: z.array(Id), }); export type SubscriptionSlotSummary = z.infer; /** 总览卡片:订阅行 + 派生字段 */ export const SubscriptionOverviewItem = Subscription.extend({ group: SubscriptionGroup.nullable(), /** 作品年份 */ year: z.number().int().nullable(), /** 剧集季:季单元季号;following_seasons 显示"S{n} 起";null = 全部季 / 电影 */ seasonNumber: z.number().int().nullable(), /** 主版本进度:已入库/总(总未知为 null)与在途 */ progress: z.object({ have: z.number().int(), total: z.number().int().nullable(), inflight: z.number().int() }), /** 主版本范围内缺失单元数(排序"缺集数"用) */ missing: z.number().int(), slots: z.array(SubscriptionSlotSummary), /** 上次更新 = max(last_progress_at ?? created_at, 最近计划预留时间) */ lastUpdatedAt: Timestamp, /** 周期搜索关闭(searchIntervalHours = 0)时为 false */ searchEnabled: z.boolean(), /** 站点范围;空 = 全部启用站点 */ sites: z.array(z.object({ id: Id, name: z.string() })), /** 生效的规则组;defaultRuleGroups = true 表示取的是订阅默认链 */ ruleGroups: z.array(z.object({ id: Id, name: z.string() })), defaultRuleGroups: z.boolean(), /** 订阅级自动补全(有效值与来源) */ autoFill: z.object({ enabled: z.boolean(), mode: AutoFillMode, source: z.enum(["subscription", "rule_group", "default"]), budget: FillBudget, }), /** 任一收录版本开了升级且未达顶档 */ upgrading: z.boolean(), /** 已完成分组里 continuous 订阅在等下一季 */ awaitingNewSeason: z.boolean(), }); export type SubscriptionOverviewItem = z.infer; export const SubscriptionOverviewPage = z.object({ items: z.array(SubscriptionOverviewItem), nextCursor: z.string().nullable(), /** 当前筛选(不含 group)下各分组数量 */ counts: z.record(SubscriptionGroup, z.number().int()), total: z.number().int(), }); export type SubscriptionOverviewPage = z.infer; /** 覆盖矩阵格状态:have 已入库;inflight 在途;missing 缺失;skipped 不需要(范围外 / 未播出) */ export const CoverageCellState = z.enum(["have", "inflight", "missing", "skipped"]); export type CoverageCellState = z.infer; export const CoverageQuality = z.object({ resolution: z.string().nullable(), source: z.string().nullable(), /** 如 "2160p REMUX" */ label: z.string().nullable(), }); export const CoverageCell = z.object({ unitKey: z.string(), state: CoverageCellState, assetId: Id.nullable(), quality: CoverageQuality.nullable(), pri: z.number().int().nullable(), planId: Id.nullable(), /** 在途阶段 reserved / downloading / verifying */ planStage: z.string().nullable(), /** skipped 的原因:out_of_range / not_aired;missing 且总集数未知为 suspected */ reason: z.string().nullable(), /** 已入库但低于目标 pri(开了升级) */ upgradable: z.boolean(), /** 该单元有延后的资源(slot_deferral) */ deferred: z.boolean(), }); export type CoverageCell = z.infer; /** 矩阵列:剧集 = 集,综艺 = 集或播出日期单元,电影 = 内容版本 */ export const CoverageColumn = z.object({ unitKey: z.string(), episode: z.number().int().nullable(), airDate: z.string().nullable(), contentUnitId: Id.nullable(), /** 临时单元:没有内容单元行(合成键 / 资产上记的旧单元键),或内容单元仍是 provisional */ temporary: z.boolean(), /** 综艺分段 upper / middle / lower(整集为 null;分段单元的独立列待身份层按分段建单元后启用) */ segment: z.string().nullable(), /** 电影内容版本名 */ edition: z.string().nullable(), }); export type CoverageColumn = z.infer; export const SubscriptionCoverage = z.object({ subscriptionId: Id, mediaKind: SubscriptionMediaKind, computedAt: Timestamp, /** 列分段:剧集/综艺按季;电影一段(seasonNumber = null,列 = 内容版本) */ seasons: z.array( z.object({ seasonNumber: z.number().int().nullable(), seasonUnitId: Id.nullable(), total: z.number().int().nullable(), totalKnown: z.boolean(), source: z.string().nullable(), locked: z.boolean(), columns: z.array(CoverageColumn), }), ), /** 首行 slotId = null 为"库内任一版本";其后每个收录版本一行,按 position */ rows: z.array( z.object({ slotId: Id.nullable(), name: z.string(), primary: z.boolean(), autoFill: z.boolean(), summary: z.object({ have: z.number().int(), inflight: z.number().int(), missing: z.number().int(), skipped: z.number().int(), }), /** 与 seasons[].columns 展平后的顺序一致 */ cells: z.array(CoverageCell), }), ), }); export type SubscriptionCoverage = z.infer; export const SubscriptionBatch = z.object({ ids: z.array(Id).min(1).max(200), action: z.enum(["search", "pause", "resume", "delete"]), }); export type SubscriptionBatch = z.infer; export const SubscriptionBatchResult = z.object({ results: z.array(z.object({ id: Id, ok: z.boolean(), error: z.string().nullable() })), }); export type SubscriptionBatchResult = z.infer; /** 重置进度:清在途标记(未提交/已失联的计划)与延后记录,重新评估;不删资产 */ export const SubscriptionResetResult = z.object({ subscriptionId: Id, /** 被取消的计划(已预留未提交,或下载任务已不在) */ releasedPlans: z.array(Id), /** 下载器里仍在下载的计划:保留 */ keptPlans: z.array(Id), clearedDeferrals: z.number().int(), reevaluated: z.boolean(), }); export type SubscriptionResetResult = z.infer; /** 日历条目状态:have 已入库 / partial 部分 / missing 未入库 / upcoming 未播出 */ export const CalendarEntryState = z.enum(["have", "partial", "missing", "upcoming"]); export type CalendarEntryState = z.infer; export const SubscriptionCalendarEntry = z.object({ /** YYYY-MM-DD */ date: z.string(), subscriptionId: Id, mediaEntityId: Id.nullable(), title: z.string(), mediaKind: SubscriptionMediaKind, /** 电影为 null */ seasonNumber: z.number().int().nullable(), episodes: z.array(z.number().int()), /** 如 "S01E03–E04";电影 "上映" */ label: z.string(), state: CalendarEntryState, have: z.number().int(), total: z.number().int(), }); export type SubscriptionCalendarEntry = z.infer; export const SubscriptionCalendar = z.object({ from: z.string(), to: z.string(), entries: z.array(SubscriptionCalendarEntry), /** 范围内季集没有播出日期(TMDB 未给或未刷新)的订阅:播出日期未知 */ unknown: z.array( z.object({ subscriptionId: Id, title: z.string(), mediaKind: SubscriptionMediaKind, seasonNumber: z.number().int().nullable(), /** 没有播出日期的集;电影为空 */ episodes: z.array(z.number().int()), }), ), }); export type SubscriptionCalendar = z.infer; /** 手动补全某版本:不受自动补全开关与补全预算限制(仍受全局预算/缓存卷阈值) */ export const SlotFillRequest = z.object({ unitKeys: z.array(z.string().min(1)).max(500).optional() }); export type SlotFillRequest = z.infer; export const SlotOrderSet = z.object({ slotIds: z.array(Id).min(1).max(50) }); export type SlotOrderSet = z.infer; /** Slot 状态(REQ §6.1) */ export const SlotStatus = z.enum([ "empty", "reserved", "downloading", "verifying", "satisfied", "upgrade_pending", "attention", ]); export type SlotStatus = z.infer; /** 独立收录目标:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件 */ export const AcquisitionSlot = EntityBase.extend({ mediaEntityId: Id, contentUnitId: Id.nullable(), subscriptionId: Id.nullable(), policyRevisionId: Id.nullable(), /** 用户命名,如 archival / streaming */ name: z.string().min(1).max(64), status: SlotStatus, hardConditions: JsonObject, softPreferences: JsonObject, fallback: JsonObject, upgradeThreshold: JsonObject, completion: JsonObject, /** 同组 Slot 在 requireDistinctAsset=true 时必须由不同物理资产满足(M2 校验) */ distinctGroup: z.string().nullable(), requireDistinctAsset: z.boolean(), currentAssetId: Id.nullable(), reservedPlanId: Id.nullable(), statusReason: z.string().nullable(), lastEvaluatedAt: Timestamp.nullable(), nextEvaluateAt: Timestamp.nullable(), revision: z.number().int().min(1), /** 同一订阅内的顺序;最小者为主版本(自动补全只约束非主版本),改顺序用 PUT /subscriptions/{id}/slot-order */ position: z.number().int().min(0), /** 引用的规则组(有序);空 = 订阅的 ruleGroupIds,再空 = 订阅默认组(开升级时叠加洗版默认组) */ ruleGroupIds: z.array(Id), /** 派生字段(列表返回):当前资产的交付特征摘要,无资产为 null */ currentAsset: z .object({ id: Id, resolution: z.string().nullable(), sourceType: z.string().nullable(), releaseGroup: z.string().nullable(), sizeBytes: z.number().int().nullable(), status: z.string(), }) .nullable() .optional(), }); export type AcquisitionSlot = z.infer; export const AcquisitionSlotCreate = AcquisitionSlot.pick({ mediaEntityId: true, contentUnitId: true, subscriptionId: true, policyRevisionId: true, name: true, hardConditions: true, softPreferences: true, fallback: true, upgradeThreshold: true, completion: true, distinctGroup: true, requireDistinctAsset: true, ruleGroupIds: true, }).partial({ contentUnitId: true, subscriptionId: true, policyRevisionId: true, hardConditions: true, softPreferences: true, fallback: true, upgradeThreshold: true, completion: true, distinctGroup: true, requireDistinctAsset: true, ruleGroupIds: true, }); export type AcquisitionSlotCreate = z.infer; /** 规则可改;status/currentAsset/reservedPlan 只由状态机推进,不经 Update */ export const AcquisitionSlotUpdate = AcquisitionSlotCreate.omit({ mediaEntityId: true, contentUnitId: true, name: true, }) .partial() .extend({ expectedRevision: z.number().int().min(1) }); export type AcquisitionSlotUpdate = z.infer; /** 偏好类型:soft 影响排序;hard 硬排除;version 版本偏好;target 独立目标配置(REQ §6.7) */ export const PreferenceKind = z.enum(["soft", "hard", "version", "target"]); export type PreferenceKind = z.infer; export const PreferenceOrigin = z.enum(["manual", "conversation", "agent_inferred"]); export type PreferenceOrigin = z.infer; export const PreferenceStatus = z.enum(["active", "disabled", "deleted"]); export type PreferenceStatus = z.infer; export const PreferenceTag = EntityBase.extend({ label: z.string().min(1).max(80), kind: PreferenceKind, scope: JsonObject, rule: JsonObject, /** 语义指纹(kind+scope+rule 规范化哈希),用于抑制匹配 */ fingerprint: z.string().min(1), origin: PreferenceOrigin, description: z.string().nullable(), status: PreferenceStatus, revision: z.number().int().min(1), evidence: JsonObject, policyRevisionId: Id.nullable(), }); export type PreferenceTag = z.infer; /** 偏好标签修订历史:取自 audit_event(target_type=preference_tag),新→旧 */ export const PreferenceTagRevision = z.object({ /** audit_event id */ id: Id, at: Timestamp, action: z.enum(["created", "updated", "deleted"]), actorType: z.string(), actorId: z.string().nullable(), /** 该次写入后的 revision */ revision: z.number().int().min(1).nullable(), /** 本次改动的字段(created/deleted 为空) */ changedFields: z.array(z.string()), /** 写入后的快照 */ snapshot: z.object({ label: z.string(), kind: z.string(), status: z.string(), scope: JsonObject, rule: JsonObject, description: z.string().nullable(), }), }); export type PreferenceTagRevision = z.infer; export const PreferenceTagRevisionList = z.object({ items: z.array(PreferenceTagRevision) }); export type PreferenceTagRevisionList = z.infer; /** 策略预览结果(policy_revision.preview):最近 200 条来源资源按模板回放 */ export const PolicyPreviewSummary = z.object({ evaluatedAt: z.string(), sampleSize: z.number(), templates: z.number(), matched: z.number(), excluded: z.number(), samples: z.array( z.object({ sourceReleaseId: z.string(), /** 资源标题(W4 起提供) */ title: z.string().optional(), sizeBytes: z.number().nullable().optional(), template: z.string(), decision: z.string(), /** 拒绝/接受理由摘要 */ reasons: z.array(z.string()).optional(), }), ), valid: z.boolean(), }); export type PolicyPreviewSummary = z.infer; /** fingerprint 由服务端计算 */ export const PreferenceTagCreate = PreferenceTag.pick({ label: true, kind: true, scope: true, rule: true, origin: true, description: true, evidence: true, }).partial({ scope: true, rule: true, origin: true, description: true, evidence: true }); export type PreferenceTagCreate = z.infer; export const PreferenceTagUpdate = PreferenceTagCreate.omit({ origin: true }) .partial() .extend({ status: PreferenceStatus.optional() }); export type PreferenceTagUpdate = z.infer; /** 手动取消标签留下的抑制记录:Agent 不得据同一旧证据自动加回 */ export const PreferenceSuppression = EntityBase.extend({ preferenceTagId: Id.nullable(), /** 被停用/删除的规则组(偏好标签迁移后与 preferenceTagId 相同) */ ruleGroupId: Id.nullable().optional(), fingerprint: z.string().min(1), reason: z.string().nullable(), evidence: JsonObject, suppressedBy: z.string().min(1), active: z.boolean(), liftedAt: Timestamp.nullable(), liftedBy: z.string().nullable(), }); export type PreferenceSuppression = z.infer; export const PreferenceSuppressionCreate = PreferenceSuppression.omit({ ...SERVER_MANAGED, active: true, liftedAt: true, liftedBy: true, suppressedBy: true, }).partial({ preferenceTagId: true, reason: true, evidence: true }); export type PreferenceSuppressionCreate = z.infer; /** 唯一允许的更新是解除抑制 */ export const PreferenceSuppressionUpdate = z.object({ active: z.literal(false), reason: z.string().optional() }); export type PreferenceSuppressionUpdate = z.infer; // ── 收录策略规则与候选评估(W2-C) ── /** Slot 模板:策略规则里用于自动建 Slot(新作品收录 / 订阅缺省 Slot);字段同 AcquisitionSlot 规则列 */ export const SlotTemplate = z.object({ name: z.string().min(1).max(64), /** 建收录版本时写入 ruleGroupIds */ ruleGroupIds: z.array(Id).optional(), hardConditions: JsonObject.optional(), softPreferences: JsonObject.optional(), fallback: JsonObject.optional(), upgradeThreshold: JsonObject.optional(), completion: JsonObject.optional(), }); export type SlotTemplate = z.infer; /** * kind=acquisition 的 policy_revision.rules 结构(结构化数据,不是可执行脚本)。 * 发布即对 scope 范围内的正常下载/追更/整理生成持续授权 automation_grant(REQ §6.4)。 */ export const AcquisitionPolicyRules = z.object({ /** 新作品收录:发行/首播在窗口内且库里没有的作品,按模板建订阅与 Slot */ newTitle: z .object({ enabled: z.boolean(), windowDays: z.number().int().min(1).max(3650).optional(), mediaKinds: z.array(SubscriptionMediaKind).optional(), slots: z.array(SlotTemplate), }) .optional(), /** 订阅没有 Slot 时套用的缺省 Slot */ defaultSlots: z.array(SlotTemplate).optional(), /** 整理方式覆盖(缺省读 settings.organize.defaultMode) */ organizeMode: z.enum(["copy", "move"]).optional(), /** Move 在 H&R 未满足时经授权改用 Copy */ allowCopyWhenHnrPending: z.boolean().optional(), }); export type AcquisitionPolicyRules = z.infer; /** 候选对某个 Slot 的确定性评估(供作品详情"为什么选/不选") */ export const CandidateEvaluation = z.object({ slotId: Id, sourceReleaseId: Id, title: z.string(), /** accept / upgrade / reject / unknown_field / skipped(被模式、预算、阈值、总开关等挡住) */ decision: z.enum(["accept", "upgrade", "reject", "unknown_field", "skipped", "deferred"]), mode: z.enum(["new_title", "catch_up", "upgrade"]).nullable(), rejected: z.array(z.object({ code: z.string(), message: z.string() })), reasons: z.array(z.string()), planId: Id.nullable(), evaluatedAt: Timestamp, /** 优先级阶梯 pri(0–100;未配置阶梯时为 100) */ pri: z.number().int().nullable().optional(), /** 可读评估说明(逐条违规 + 逐层阶梯),对齐 MP trace */ trace: z.array(z.string()).optional(), /** 本次计划/延后涉及的内容单元 */ unitKeys: z.array(z.string()).optional(), }); export type CandidateEvaluation = z.infer; // ── 规则组(W9-D):收录策略的过滤条件 + 偏好标签合一 ── /** 条件表达式:内置关键字 / 自定义条件的 AND / OR / NOT 树(与 @umt/domain RuleExpr 同形) */ export type RuleExpr = | { op: "atom"; id: string } | { op: "not"; arg: RuleExpr } | { op: "and"; args: RuleExpr[] } | { op: "or"; args: RuleExpr[] }; function isRuleExpr(v: unknown, depth = 0): v is RuleExpr { if (depth > 32 || !v || typeof v !== "object") return false; const o = v as Record; switch (o.op) { case "atom": return typeof o.id === "string" && /^[A-Za-z0-9]{1,32}$/.test(o.id); case "not": return isRuleExpr(o.arg, depth + 1); case "and": case "or": return Array.isArray(o.args) && o.args.length >= 1 && o.args.every((a) => isRuleExpr(a, depth + 1)); default: return false; } } /** 递归结构在 refine 里校验(OpenAPI 只展开一层) */ export const RuleExpr = z .object({ op: z.enum(["atom", "not", "and", "or"]), /** op=atom:关键字或自定义条件 id */ id: z .string() .regex(/^[A-Za-z0-9]{1,32}$/) .optional(), /** op=not */ arg: JsonObject.optional(), /** op=and/or */ args: z.array(JsonObject).optional(), }) .refine(isRuleExpr, { message: "条件表达式结构不对:{op:atom,id} / {op:not,arg} / {op:and|or,args[]}", }) as unknown as z.ZodType; /** 优先级阶梯的一层;第 k 层(0 起)命中 pri = 100 - k */ export const RuleLayer = z.object({ name: z.string().max(40).optional(), expr: RuleExpr }); export type RuleLayer = z.infer; /** acquisition 订阅 / upgrade 洗版 / search 搜索 */ export const RuleGroupKind = z.enum(["acquisition", "upgrade", "search"]); export type RuleGroupKind = z.infer; /** 来源:manual 手动 / chat 对话中用户明确说的 / agent Agent 推断 */ export const RuleGroupSource = z.enum(["manual", "chat", "agent"]); export type RuleGroupSource = z.infer; /** 适用范围;空 = 全部。subscriptionId = 订阅私有组 */ export const RuleGroupScope = z .object({ mediaTypes: z.array(SubscriptionMediaKind).optional(), entityIds: z.array(Id).optional(), siteIds: z.array(Id).optional(), subscriptionId: Id.optional(), }) .passthrough(); export type RuleGroupScope = z.infer; export const RuleGroup = EntityBase.extend({ name: z.string().min(1).max(80), description: z.string().nullable(), kind: RuleGroupKind, scope: RuleGroupScope, layers: z.array(RuleLayer), /** 任一命中即拒绝 */ hardExcludes: z.array(RuleExpr), /** 一层都不命中即拒绝(MP 语义);false = 软偏好,不中只是 pri 低 */ ladderRequired: z.boolean(), /** layers 的规范化规则串(无层为 null) */ ruleString: z.string().nullable(), source: RuleGroupSource, enabled: z.boolean(), revision: z.number().int().min(1), /** 语义指纹(kind + scope + 层 + 硬排除),停用/删除后写抑制记录 */ fingerprint: z.string(), evidence: JsonObject, /** 结构化硬条件(include / exclude 正则等),评估时并入收录版本硬条件;订阅私有组用它存表单的包含 / 排除 */ hard: JsonObject.optional(), /** 非 null = 属于该类默认链,按此排序 */ defaultOrder: z.number().int().nullable(), /** 引用本组的订阅缺省是否自动补全其他版本;null = 不表态 */ autoFill: z.boolean().nullable(), /** 由偏好标签迁来/经旧端点写入时的旧 kind */ legacyKind: PreferenceKind.nullable(), suppressedAt: Timestamp.nullable(), deletedAt: Timestamp.nullable(), }); export type RuleGroup = z.infer; /** 层与硬排除可直接给规则串:ruleString 覆盖 layers;hardExcludes 的元素可以是表达式或单层规则串 */ export const RuleGroupCreate = z.object({ name: z.string().min(1).max(80), description: z.string().max(500).nullable().optional(), kind: RuleGroupKind.optional(), scope: RuleGroupScope.optional(), layers: z.array(RuleLayer).max(50).optional(), ruleString: z.string().max(2000).optional(), hardExcludes: z .array(z.union([z.string().min(1).max(500), RuleExpr])) .max(50) .optional(), ladderRequired: z.boolean().optional(), source: RuleGroupSource.optional(), enabled: z.boolean().optional(), evidence: JsonObject.optional(), autoFill: z.boolean().nullable().optional(), }); export type RuleGroupCreate = z.infer; export const RuleGroupUpdate = RuleGroupCreate.omit({ source: true }) .partial() .extend({ expectedRevision: z.number().int().min(1).optional() }); export type RuleGroupUpdate = z.infer; /** 规则组修订历史(审计事件回放,新→旧) */ export const RuleGroupRevisionList = z.object({ items: z.array( z.object({ id: Id, at: Timestamp, action: z.string(), actorType: z.string(), actorId: z.string().nullable(), revision: z.number().int().nullable(), changedFields: z.array(z.string()), snapshot: JsonObject, }), ), }); export type RuleGroupRevisionList = z.infer; export const RuleParseError = z.object({ code: z.enum([ "empty", "empty_layer", "unknown_atom", "unsupported_atom", "unbalanced_paren", "unexpected_token", "invalid_char", ]), message: z.string(), /** 在整个规则串里的字符偏移(0 起) */ position: z.number().int(), /** 第几层(0 起) */ layer: z.number().int(), atom: z.string().optional(), suggestions: z.array(z.string()).optional(), }); export type RuleParseError = z.infer; export const RuleParseRequest = z.object({ ruleString: z.string().max(2000) }); export const RuleParseResult = z.object({ layers: z.array(RuleLayer), /** 规范化后的规则串(有错误时为 null) */ ruleString: z.string().nullable(), errors: z.array(RuleParseError), }); export type RuleParseResult = z.infer; export const RuleSerializeRequest = z.object({ layers: z.array(RuleLayer).max(50) }); export const RuleSerializeResult = z.object({ ruleString: z.string() }); /** 试算:缺省取最近 N 条已识别资源;也可指定资源或粘贴标题;draft 覆盖未保存的修改 */ export const RulePreviewRequest = z.object({ limit: z.number().int().min(1).max(200).optional(), sourceReleaseIds: z.array(Id).max(200).optional(), titles: z .array(z.object({ title: z.string().min(1).max(500), subtitle: z.string().max(500).optional() })) .max(50) .optional(), draft: RuleGroupCreate.pick({ layers: true, ruleString: true, hardExcludes: true, ladderRequired: true }) .partial() .optional(), }); export type RulePreviewRequest = z.infer; export const RulePreviewItem = z.object({ sourceReleaseId: Id.nullable(), title: z.string(), subtitle: z.string().nullable(), passed: z.boolean(), /** 命中层的 pri;被拒绝为 null;无层为 100 */ pri: z.number().int().nullable(), /** 命中的层(0 起) */ level: z.number().int().nullable(), excludedBy: z.array(z.string()), /** 拒绝原因摘要:hard_exclude / no_layer_matched */ reasons: z.array(z.string()), trace: z.array(z.string()), }); export const RulePreviewResult = z.object({ ruleGroupId: Id, revision: z.number().int(), evaluatedAt: Timestamp, sampleSize: z.number().int(), passed: z.number().int(), rejected: z.number().int(), /** 各 pri 的条数(pri=null 为被拒绝) */ priHistogram: z.array(z.object({ pri: z.number().int().nullable(), count: z.number().int() })), items: z.array(RulePreviewItem), }); export type RulePreviewResult = z.infer; /** 关键字表:内置(对齐 MP)+ 自定义条件 */ export const RuleKeyword = z.object({ id: z.string(), name: z.string(), description: z.string(), builtin: z.boolean(), /** false = 音乐等未迁移条件,规则串里出现会报错 */ supported: z.boolean(), /** 我们的判定依据:field 解析字段 / regex MP 正则 / site 站点事实 / custom 自定义条件 */ basis: z.enum(["field", "regex", "site", "custom"]), /** MP 上游定义(include/exclude/match/tmdb/downloadvolumefactor) */ mp: JsonObject.nullable(), }); export type RuleKeyword = z.infer; export const RuleKeywordList = z.object({ items: z.array(RuleKeyword) }); /** 自定义条件(对齐 MP 自定义规则;不允许覆盖内置关键字) */ export const RuleAtom = EntityBase.extend({ /** 大写存储;写入时不区分大小写 */ key: z .string() .max(32) .regex(/^(?:[A-Za-z][A-Za-z0-9]*|[0-9]+[A-Za-z][A-Za-z0-9]*)$/), name: z.string().nullable(), description: z.string().nullable(), include: z.array(z.string()), exclude: z.array(z.string()), matchFields: z.array(z.enum(["title", "subtitle", "labels"])), /** MB,剧集按每集 */ unitSizeMb: z.object({ min: z.number().min(0).optional(), max: z.number().min(0).optional() }).nullable(), minSeeders: z.number().int().min(0).nullable(), publishDelayMinutes: z.object({ min: z.number().min(0).optional(), max: z.number().min(0).optional() }).nullable(), free: z.boolean(), }); export type RuleAtom = z.infer; export const RuleAtomCreate = RuleAtom.pick({ key: true, name: true, description: true, include: true, exclude: true, matchFields: true, unitSizeMb: true, minSeeders: true, publishDelayMinutes: true, free: true, }).partial({ name: true, description: true, include: true, exclude: true, matchFields: true, unitSizeMb: true, minSeeders: true, publishDelayMinutes: true, free: true, }); export type RuleAtomCreate = z.infer; export const RuleAtomUpdate = RuleAtomCreate.omit({ key: true }).partial(); export type RuleAtomUpdate = z.infer; /** 三套默认链:订阅 / 洗版(开升级时叠加)/ 搜索;按数组顺序串联 */ export const RuleDefaults = z.object({ acquisition: z.array(Id), upgrade: z.array(Id), search: z.array(Id), }); export type RuleDefaults = z.infer;