Files
UHD_Media_Tool/docs/runbook/subscriptions.md
T

119 lines
11 KiB
Markdown

# 订阅运行手册(W9-E)
实现:`apps/server/src/modules/acquisition-organize/{routes/subscriptions.ts, services/coverage.ts, services/evaluate-autofill.ts, services/subscriptions-params.ts, services/subscriptions.ts}`、
`packages/domain/src/{slot/overview.ts, acquisition/fill.ts}`、contracts `policy.ts`(`SubscriptionOverviewItem` `SubscriptionCoverage` 等)、迁移 `0035_subscriptions_v2`。
设计:`docs/design/subscriptions.md` §1–§3、§5;规则组见 `docs/runbook/rules.md`;W5-C 账本/完成/搜索见 `docs/plan/05-subscription-gap.md` §4。
## 数据
| 列 | 含义 |
| --- | --- |
| `subscription.last_progress_at` | 上次进展:核验入库写分集账本时(`afterSlotVerified`)、季总集数变化时(`applyEpisodeTotal`)写入;迁移时按账本/计划时间回填 |
| `acquisition_slot.position` | 同一订阅内的顺序,最小者为**主版本**;同值按 `created_at`、`id`。模板建目标时按模板顺序编号;迁移按 `created_at` 编号 |
总览"上次更新" = max(`last_progress_at` ?? `created_at`, 该订阅版本目标最近一次非草案计划的 `created_at`)。
## 总览 `GET /subscriptions`
查询:`status mediaEntityId mediaKind q group sort order siteId ruleGroupId origin upgrading cursor limit`。
不带 `status` 时不返回 `cancelled`(只在"订阅历史"里看);`status=cancelled` 时 `group = null`。
**分组**(服务端按顺序判定,`group` 字段 + `counts`,计数是当前筛选去掉 `group` 后的数量):
| group | 条件 |
| --- | --- |
| `paused` 暂停 | `status = paused` |
| `pending` 待定 | `status = pending`,或剧集/综艺范围内任一季总集数未知(剧集没有任何季也算) |
| `completed` 已完成 | `status = completed`,或所有版本目标在范围内全覆盖且总集数已知(continuous 剧集此时 `awaitingNewSeason = true`) |
| `following` 追更中 | 库内任一版本有已入库或在途单元 |
| `waiting` 待搜索 | 没有已入库/在途单元;何时搜索看 `nextSearchAt`(`searchEnabled = false` 表示周期搜索已关) |
**排序** `sort`:`lastUpdated`(缺省,倒序)/ `nextSearch`(正序,没排期的在最后)/ `missing`(主版本缺失数倒序)/ `created`(倒序)/ `name`(正序);`order` 可反转。
分组与排序是派生的,所以在内存里做:一次最多取 2000 条订阅,游标是偏移量(不透明串)。`siteId` 筛选包含"站点范围为空 = 全部站点"的订阅。
**条目** = 订阅行 + `group year seasonNumber progress{have,total,inflight} missing slots[] lastUpdatedAt searchEnabled sites[] ruleGroups[] defaultRuleGroups autoFill{enabled,mode,source,budget} upgrading awaitingNewSeason`。
`progress` 取主版本;`total` 在任一季总集数未知时为 null。`slots[]` 每个版本目标一条:`have inflight missing total upgrading minPri targetPri autoFill primary ruleGroupIds`。
`ruleGroups` = 订阅 `params.ruleGroupIds` → 各版本目标 `ruleGroupIds` 的并集 → 订阅默认链(`defaultRuleGroups = true`)。
查询量固定:订阅 1 条 + 版本目标/作品/内容单元/资产/覆盖/分集账本/在途计划/最近计划/延后各 1 条,和订阅数无关;站点名只查当前页。
## 覆盖矩阵 `GET /subscriptions/{id}/coverage`
- `seasons[]`:剧集按季分段(`seasonNumber total totalKnown source locked columns[]`);综艺只有播出日期的单元另成一段(`seasonNumber = null`,列键 `d{YYYYMMDD}`);电影一段,列 = 内容版本(`edition:default`、`edition:Extended`…,取各版本目标 `completion.cuts` 与库内资产版本的并集)。
- 列:`unitKey episode airDate contentUnitId temporary segment edition`。`temporary` = 没有内容单元行(合成键)或内容单元仍是 provisional;`segment` = 综艺分段(上/中/下,整集为 null)。
总集数已知列到 total;未知列到已见最高集(已入库/在途/已建集单元)。
- `rows[]`:首行 `slotId = null` "库内任一版本"(作品全部资产 + 全部分集账本、全部在途),其后每个版本目标一行(按 position,`primary` 标主版本,`autoFill` 为非主版本当前是否自动补全),`summary{have,inflight,missing,skipped}`。
- 格 `cells[i]` 与展平后的列一一对应:
| state | 含义 | 附带 |
| --- | --- | --- |
| `have` | 已入库 | `assetId quality{resolution,source,label} pri upgradable`(开了升级且 pri < 目标 pri) |
| `inflight` | 在途(计划单元键与列相交,季包整季通配 `s{季}*` 覆盖该季全部未入库集) | `planId planStage`(reserved / downloading / verifying) |
| `missing` | 缺失 | `reason = suspected`(总集数未知)、`deferred`(该单元有延后资源) |
| `skipped` | 不需要 | `reason = out_of_range`(集范围外)/ `not_aired`(播出日期在今天之后)/ `not_required`(电影:该版本目标不要求这个内容版本) |
覆盖口径与完成判定一致(`slotCoveredUnits`):版本目标覆盖 = 该目标计划产出的资产 + 当前资产 + 分集账本;作品只有一个版本目标时作品下全部资产都算。
进程内缓存 30s(`?refresh=true` 强制重算);订阅写入(PATCH、暂停/恢复、排序、补全、设总集数)会清掉该订阅的缓存。`/progress` 保留一版兼容。
## 多版本自动补全
- 开关 `params.autoFill: inherit | on | off`(缺省 inherit):inherit 取订阅生效规则链(私有组 → `params.ruleGroupIds` 或订阅默认链)上**最后一个设了 `auto_fill` 的组**,都没设为开。总览 `autoFill.source` 标来源(subscription / rule_group / default)。
- 预算 `params.fillBudget {unitsPerDay?, bytesPerDay?}`:按订阅、按自然日,只计自动补全计划(`acquisition_plan.evaluation.autoFill = true`,草案/取消/失败/过期不计),同时仍受全局每日预算与缓存卷阈值约束。
- 评估(`evaluateForSlot` 建计划前 `autoFillCheck`):
- 主版本、没有订阅的版本目标、只有一个版本目标:照旧;
- 非主版本 + 开关关:不建计划,候选记 `deferred`(`deferred:auto_fill_off`),**不进** `slot_deferral`;
- 非主版本 + 开 + 计划单元都已被主版本满足(电影:主版本有当前资产):补全计划,`priority = upgrade`(低于追更与新作品),`evaluation.autoFill = true`;超出补全预算 → 延后(`slot_deferral.reason = fill_budget`),由 `slots.reevaluate` 周期重评,次日预算重置后补上;
- 非主版本 + 开 + 主版本也缺:照常追更(`priority = tracking`)。
- 评估时同一发布按 position 先评主版本(`evaluateRelease` 版本目标按 position、created_at 排序)。
- 手动补全 `POST /subscriptions/{id}/slots/{slotId}/fill {unitKeys?}`:入队 `evaluateAcquisition{acquisitionSlotId, forceFill}`,不受开关与补全预算限制(全局预算/缓存卷照常),`unitKeys` 限定只补这些单元;订阅须在追踪中(active/pending)。
- 定时:不新增任务。`slots.reevaluate`(到期 Slot 重评)覆盖补全与预算延后;`subscriptions.search` 按 `next_search_at` 到期搜索。
## 编辑表单字段(POST / PATCH `params`)
写入时规范化,库里只存规范键(未知键原样保留)。PATCH 的 `params` 仍是整体替换。
| 规范键 | 兼容写法(MP / 旧键) | 说明 |
| --- | --- | --- |
| `siteIds` | `sites`(站点 id 或 key) | 空 = 全部启用站点;不存在 400 |
| `downloaderInstanceId` | `downloader`(实例 id 或名称) | 不存在 400 |
| `downloadSavePath` | `save_path` / `savePath` | 已存,下载提交暂未使用(P2) |
| `episodeRange {start,end}` | `start_episode` / `end_episode`;旧 `episodeRange.locked` 丢弃 | end < start 400;电影丢弃 |
| —(季总集数) | `total_episode` + `manual_total_episode` | 保存后写订阅季(无季单元按第 1 季)的总集数,`manual_total_episode` 为真时锁定 |
| `include[]` / `exclude[]` | 字符串或数组;开头的 `(?i)` 去掉 | 正则白名单(`validateKeywordPattern`)+ 语法校验,不过 400;评估时并入每个版本目标的硬条件 |
| `keywords[]` | `keyword` | 已存;主动搜索任务(sources-identity)尚未追加这些检索词 |
| `ruleGroupIds` | `filter_groups`(组名或 id) | 不存在/已删 400 |
| —(版本目标升级) | `best_version` / `best_version_full` | 写到该订阅的版本目标 `upgrade_threshold`:关 → `enabled:false`;开 → `mode` = per_unit(剧集)/ full_pack(全集洗版)/ movie(电影),revision+1 |
| `autoFill` | 布尔;`autoFillVersions{enabled}` | → on / off |
| `fillBudget {unitsPerDay, bytesPerDay}` | `fillBudget{perDay, bytes}`、`autoFillVersions{budget{perDay, bytes}}` | 负数 400 |
| `libraryRootId` | — | 库根须存在;整理去向暂未使用(organize 侧待接) |
| `searchIntervalHours` `stopAfter` | — | 同 W5-C;stopAfter 须是时间 |
新建:同作品同季(`season_unit_id` 同为空也算)已有 active / pending / paused 订阅 → **409**,消息里带已有订阅 id。PATCH 把状态改回追踪、重新订阅也做同样检查。
## 状态动作
| 端点 | 说明 |
| --- | --- |
| `POST /subscriptions/{id}/pause` | active / pending → paused(`status_reason = user_paused`,清 `next_search_at`);已暂停幂等;其他 409 |
| `POST /subscriptions/{id}/resume` | paused → active(TMDB 未就绪回 pending),`next_search_at = now`(周期搜索开着时),立即重评;追踪中幂等;已完成/已取消 409 |
| `POST /subscriptions/{id}/resubscribe` | completed / cancelled → active / pending(`resubscribed`),历史保留,写审计,立即重评 + 主动搜索 |
| `POST /subscriptions/batch {ids, action}` | `search / pause / resume / delete`;逐条返回 `{id, ok, error}`;delete = 取消(`cancelled`,`user_deleted`)并写审计 `subscription.deleted` |
| `PUT /subscriptions/{id}/slot-order {slotIds}` | 必须恰好是该订阅的全部版本目标;第一个成为主版本 |
| `POST /subscriptions/{id}/search` | 同 W5-C |
PATCH `status` 仍可直接改(旧行为),完成写 `subscription_history` 的逻辑不变。
## 测试
- `apps/server/test/subscriptions/overview-domain.test.ts`:分组顺序、矩阵行格状态(通配在途、范围、未播出、suspected、可升级、延后)、自动补全取值、补全预算。
- `apps/server/test/subscriptions/subscriptions-v2.test.ts`(真实 PG):矩阵(季包 + 单集 + 双版本 + 在途 + 整季通配 + 临时单元 + 未播出 + 缓存)、集范围 / 总集数未知 / 电影版本、总览分组计数排序分页、自动补全(补全计划优先级、主版本也缺照常追更、关闭记延后、手动补全)、规则组继承与补全预算、表单兼容与校验 / 409 / best_version、暂停恢复重新订阅批量排序。
- E7 / E9 / 规则组 / 订阅增强测试保持通过。
## 遗留
- 日历(§4,P2)、`POST /subscriptions/{id}/reset`(差距 #24)未做。
- `keywords` 未接主动搜索(`sources-identity/tasks.ts searchSubscription`);`libraryRootId` / `downloadSavePath` 已存未接整理与下载提交。
- include/exclude 以订阅 params 存、评估时并入硬条件,没有建订阅私有规则组(设计 §3 的写法);分辨率/来源/特效(MP resolution/quality/effect)表单字段未映射。
- 综艺分段列(同一播出日的上/中/下拆成独立列)等身份层按分段建单元后启用;现在只在列上标 `segment`。
- 版本目标新建(`POST /acquisition-slots`)position 缺省 0,与主版本同值时按 created_at 排在后面;需要调整顺序用 slot-order。