Files
UHD_Media_Tool/packages/contracts/src/policy.ts
T
rootandClaude Opus 5.5 fb84a71f80 style(copy): fix remaining punctuation violations and make check:copy strict
Auto-fix of 602 string literals across 74 files (half-width punctuation next to
Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow
the new full-width summaries. check:copy now runs with --strict in ci.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 21:34:51 +08:00

1045 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof PolicyKind>;
export const PolicyStatus = z.enum(["draft", "published", "superseded", "rolled_back", "archived"]);
export type PolicyStatus = z.infer<typeof PolicyStatus>;
/** 策略版本:草案 → 预览 → 授权发布 → 版本化/回滚;同一 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<typeof PolicyRevision>;
/** 新建总是草案;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<typeof PolicyRevisionCreate>;
/** 只有草案可改内容;发布/回滚走动作端点 */
export const PolicyRevisionUpdate = PolicyRevisionCreate.omit({ policyKey: true, kind: true }).partial();
export type PolicyRevisionUpdate = z.infer<typeof PolicyRevisionUpdate>;
export const SubscriptionMediaKind = z.enum(["movie", "series", "variety"]);
export type SubscriptionMediaKind = z.infer<typeof SubscriptionMediaKind>;
export const SubscriptionMode = z.enum(["once", "continuous"]);
export type SubscriptionMode = z.infer<typeof SubscriptionMode>;
/** 仅本季 / 持续后续季(REQ §6.5) */
export const SeasonScope = z.enum(["current_season", "following_seasons"]);
export type SeasonScope = z.infer<typeof SeasonScope>;
export const SubscriptionOrigin = z.enum(["user", "agent", "policy", "import"]);
export type SubscriptionOrigin = z.infer<typeof SubscriptionOrigin>;
/** 默认优先级:即时 > 追更 > 新作品 > 升级(REQ §6.8) */
export const AcquisitionPriority = z.enum(["immediate", "tracking", "new_work", "upgrade"]);
export type AcquisitionPriority = z.infer<typeof AcquisitionPriority>;
/**
* active 追踪中;paused 暂停(不评估、不搜索);pending TMDB 未就绪(可搜索/评估,不可完成,元数据就绪后转 active);
* completed 已完成(写 subscription_history,行保留可重新激活);cancelled 取消。
*/
export const SubscriptionStatus = z.enum(["active", "paused", "pending", "completed", "cancelled"]);
export type SubscriptionStatus = z.infer<typeof SubscriptionStatus>;
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,缺省 24h) */
lastSearchedAt: Timestamp.nullable(),
nextSearchAt: Timestamp.nullable(),
/** 上次进展:任一版本目标的单元转为已入库、或总集数变化时写入;总览"上次更新"另合并计划预留时间 */
lastProgressAt: Timestamp.nullable(),
/** 派生字段(列表返回):作品标题,未建档时为 null */
mediaEntityTitle: z.string().nullable().optional(),
});
export type Subscription = z.infer<typeof Subscription>;
/** 自动补全其他版本:inherit = 取引用规则组(后者优先)的 autoFill,都没设为开 */
export const AutoFillMode = z.enum(["inherit", "on", "off"]);
export type AutoFillMode = z.infer<typeof AutoFillMode>;
/** 补全预算:只约束非主版本的自动补全计划(按订阅、按自然日);同时仍计入全局每日预算 */
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<typeof FillBudget>;
/** MP 分辨率(resolution)→ 版本目标硬条件 resolutions */
export const SubscriptionResolution = z.enum(["4k", "1080p", "720p"]);
export type SubscriptionResolution = z.infer<typeof SubscriptionResolution>;
/** 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<typeof SubscriptionSourcePreset>;
/** MP 特效(effect)→ 版本目标硬条件 hdr + 软偏好 hdr 顺序;atmos 写进订阅私有规则组的包含 */
export const SubscriptionEffect = z.enum(["dv", "hdr", "sdr", "atmos"]);
export type SubscriptionEffect = z.infer<typeof SubscriptionEffect>;
/**
* 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。
*/
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<typeof SubscriptionParams>;
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<typeof SubscriptionCreate>;
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<typeof SubscriptionUpdate>;
/** 订阅完成历史:完成时写整行快照与进度;订阅行保留,可重新激活 */
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<typeof SubscriptionHistory>;
/** 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<typeof SlotDeferral>;
/** 分集质量账本: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<typeof SlotUnit>;
/** 单元状态(订阅详情"每单元状态") */
export const UnitProgressState = z.enum(["covered", "inflight", "missing", "suspected_missing", "out_of_range"]);
export type UnitProgressState = z.infer<typeof UnitProgressState>;
/** 订阅进度:按季的总集数账本 + 每集状态 + 每 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<typeof SubscriptionProgress>;
/** 手动设置季总集数(只升;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<typeof EpisodeTotalSet>;
// ── 订阅总览 / 覆盖矩阵(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<typeof SubscriptionGroup>;
export const SubscriptionSort = z.enum(["lastUpdated", "nextSearch", "missing", "created", "name"]);
export type SubscriptionSort = z.infer<typeof SubscriptionSort>;
/** 版本目标在订阅范围内的计数摘要(卡片徽标 / 矩阵行尾) */
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<typeof SubscriptionSlotSummary>;
/** 总览卡片:订阅行 + 派生字段 */
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<typeof SubscriptionOverviewItem>;
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<typeof SubscriptionOverviewPage>;
/** 覆盖矩阵格状态:have 已入库;inflight 在途;missing 缺失;skipped 不需要(范围外 / 未播出) */
export const CoverageCellState = z.enum(["have", "inflight", "missing", "skipped"]);
export type CoverageCellState = z.infer<typeof CoverageCellState>;
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<typeof CoverageCell>;
/** 矩阵列:剧集 = 集,综艺 = 集或播出日期单元,电影 = 内容版本 */
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<typeof CoverageColumn>;
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<typeof SubscriptionCoverage>;
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<typeof SubscriptionBatch>;
export const SubscriptionBatchResult = z.object({
results: z.array(z.object({ id: Id, ok: z.boolean(), error: z.string().nullable() })),
});
export type SubscriptionBatchResult = z.infer<typeof SubscriptionBatchResult>;
/** 重置进度:清在途标记(未提交/已失联的计划)与延后记录,重新评估;不删资产 */
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<typeof SubscriptionResetResult>;
/** 日历条目状态:have 已入库 / partial 部分 / missing 未入库 / upcoming 未播出 */
export const CalendarEntryState = z.enum(["have", "partial", "missing", "upcoming"]);
export type CalendarEntryState = z.infer<typeof CalendarEntryState>;
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<typeof SubscriptionCalendarEntry>;
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<typeof SubscriptionCalendar>;
/** 手动补全某版本:不受自动补全开关与补全预算限制(仍受全局预算/缓存卷阈值) */
export const SlotFillRequest = z.object({ unitKeys: z.array(z.string().min(1)).max(500).optional() });
export type SlotFillRequest = z.infer<typeof SlotFillRequest>;
export const SlotOrderSet = z.object({ slotIds: z.array(Id).min(1).max(50) });
export type SlotOrderSet = z.infer<typeof SlotOrderSet>;
/** Slot 状态(REQ §6.1) */
export const SlotStatus = z.enum([
"empty",
"reserved",
"downloading",
"verifying",
"satisfied",
"upgrade_pending",
"attention",
]);
export type SlotStatus = z.infer<typeof SlotStatus>;
/** 独立收录目标:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件 */
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<typeof AcquisitionSlot>;
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<typeof AcquisitionSlotCreate>;
/** 规则可改;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<typeof AcquisitionSlotUpdate>;
/** 偏好类型:soft 影响排序;hard 硬排除;version 版本偏好;target 独立目标配置(REQ §6.7) */
export const PreferenceKind = z.enum(["soft", "hard", "version", "target"]);
export type PreferenceKind = z.infer<typeof PreferenceKind>;
export const PreferenceOrigin = z.enum(["manual", "conversation", "agent_inferred"]);
export type PreferenceOrigin = z.infer<typeof PreferenceOrigin>;
export const PreferenceStatus = z.enum(["active", "disabled", "deleted"]);
export type PreferenceStatus = z.infer<typeof PreferenceStatus>;
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<typeof PreferenceTag>;
/** 偏好标签修订历史:取自 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<typeof PreferenceTagRevision>;
export const PreferenceTagRevisionList = z.object({ items: z.array(PreferenceTagRevision) });
export type PreferenceTagRevisionList = z.infer<typeof PreferenceTagRevisionList>;
/** 策略预览结果(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<typeof PolicyPreviewSummary>;
/** 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<typeof PreferenceTagCreate>;
export const PreferenceTagUpdate = PreferenceTagCreate.omit({ origin: true })
.partial()
.extend({ status: PreferenceStatus.optional() });
export type PreferenceTagUpdate = z.infer<typeof PreferenceTagUpdate>;
/** 手动取消标签留下的抑制记录: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<typeof PreferenceSuppression>;
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<typeof PreferenceSuppressionCreate>;
/** 唯一允许的更新是解除抑制 */
export const PreferenceSuppressionUpdate = z.object({ active: z.literal(false), reason: z.string().optional() });
export type PreferenceSuppressionUpdate = z.infer<typeof PreferenceSuppressionUpdate>;
// ── 收录策略规则与候选评估(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<typeof SlotTemplate>;
/**
* 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<typeof AcquisitionPolicyRules>;
/** 候选对某个 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<typeof CandidateEvaluation>;
// ── 规则组(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<string, unknown>;
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<RuleExpr>;
/** 优先级阶梯的一层;第 k 层(0 起)命中 pri = 100 - k */
export const RuleLayer = z.object({ name: z.string().max(40).optional(), expr: RuleExpr });
export type RuleLayer = z.infer<typeof RuleLayer>;
/** acquisition 订阅 / upgrade 洗版 / search 搜索 */
export const RuleGroupKind = z.enum(["acquisition", "upgrade", "search"]);
export type RuleGroupKind = z.infer<typeof RuleGroupKind>;
/** 来源:manual 手动 / chat 对话中用户明确说的 / agent Agent 推断 */
export const RuleGroupSource = z.enum(["manual", "chat", "agent"]);
export type RuleGroupSource = z.infer<typeof RuleGroupSource>;
/** 适用范围;空 = 全部。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<typeof RuleGroupScope>;
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<typeof RuleGroup>;
/** 层与硬排除可直接给规则串: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<typeof RuleGroupCreate>;
export const RuleGroupUpdate = RuleGroupCreate.omit({ source: true })
.partial()
.extend({ expectedRevision: z.number().int().min(1).optional() });
export type RuleGroupUpdate = z.infer<typeof RuleGroupUpdate>;
/** 规则组修订历史(审计事件回放,新→旧) */
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<typeof RuleGroupRevisionList>;
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<typeof RuleParseError>;
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<typeof RuleParseResult>;
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<typeof RulePreviewRequest>;
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<typeof RulePreviewResult>;
/** 关键字表:内置(对齐 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<typeof RuleKeyword>;
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<typeof RuleAtom>;
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<typeof RuleAtomCreate>;
export const RuleAtomUpdate = RuleAtomCreate.omit({ key: true }).partial();
export type RuleAtomUpdate = z.infer<typeof RuleAtomUpdate>;
/** 三套默认链:订阅 / 洗版(开升级时叠加)/ 搜索;按数组顺序串联 */
export const RuleDefaults = z.object({
acquisition: z.array(Id),
upgrade: z.array(Id),
search: z.array(Id),
});
export type RuleDefaults = z.infer<typeof RuleDefaults>;