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]>
1045 lines
41 KiB
TypeScript
1045 lines
41 KiB
TypeScript
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>;
|