feat(subscriptions): overview groups, coverage matrix, multi-version auto-fill, form field normalization (W9-E)

- 0035: subscription.last_progress_at, acquisition_slot.position (primary version)
- GET /subscriptions: server-side groups + counts, card fields, sort/filter, offset cursor
- GET /subscriptions/{id}/coverage: season x episode (movie: edition) x slot matrix, 30s cache
- auto-fill for non-primary slots: inherit/on/off, per-subscription daily budget, upgrade priority
- params normalization with MP/legacy aliases; duplicate 409; pause/resume/resubscribe/batch/fill/slot-order

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:54:29 +08:00
1 parent e77fdc6549
commit 410d7fd636
12 files changed
+2158 -252

No files matched your search

+200 -1
View File
@@ -79,14 +79,32 @@ export const Subscription = EntityBase.extend({
/** 上次主动搜索时间 / 下次到期(按 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>;
/**
* subscription.params 的已知键(JSON 列,其余键原样保留):
* siteIds 站点范围;episodeRange 集范围(对照 MP start/total_episode);searchIntervalHours 主动搜索周期(0 = 关);
* stopAfter 截止时间;downloaderInstanceId 指定下载器。
* stopAfter 截止时间;downloaderInstanceId 指定下载器;ruleGroupIds 规则组;keywords 追加检索词;
* include/exclude 订阅级关键词正则(作用于全部版本目标);libraryRootId 库根;downloadSavePath 下载保存路径;
* autoFill/fillBudget 多版本自动补全。
* 写入时兼容 MP 字段与旧键(sites/downloader/save_path/start_episode/filter_groups/keyword/autoFillVersions…),
* 服务端规范化后只存本表列出的键,见 docs/runbook/subscriptions.md。
*/
export const SubscriptionParams = z
.object({
@@ -103,6 +121,15 @@ export const SubscriptionParams = z
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(),
})
.passthrough();
export type SubscriptionParams = z.infer<typeof SubscriptionParams>;
@@ -233,6 +260,176 @@ export const EpisodeTotalSet = z.object({
});
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 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",
@@ -268,6 +465,8 @@ export const AcquisitionSlot = EntityBase.extend({
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 */