Files
UHD_Media_Tool/docs/runbook/subscriptions.md
T

176 lines
18 KiB
Markdown
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.
# 订阅运行手册(W9-E、C1-A)
实现:`apps/server/src/modules/acquisition-organize/{routes/subscriptions.ts, services/coverage.ts, services/evaluate-autofill.ts, services/subscriptions-params.ts, services/subscriptions.ts, services/subscription-reset.ts, services/subscription-calendar.ts}`、
`packages/domain/src/{slot/overview.ts, acquisition/fill.ts}`、contracts `policy.ts`(`SubscriptionOverviewItem` `SubscriptionCoverage` `SubscriptionCalendar` `SubscriptionResetResult` 等)、迁移 `0035_subscriptions_v2`、`0036_subscription_private_rules`;
集播出日期写入在 `sources-identity/service/metadata-totals.ts`(`applyEpisodeAirDates`),关键词检索在 `sources-identity/tasks.ts`(`searchSubscription`)。
前端:`apps/web/app/routes/console/subscriptions.tsx`(`?view=calendar`)、`components/subscriptions/{calendar.tsx, actions.tsx, subscription-form.tsx, model.ts}`。
设计:`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` | 下载提交用它做 qB savepath,见下"下载提交与整理去向" |
| `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;保存时同步为订阅私有规则组(见下),params 里保留原值供表单回显 |
| `keywords[]` | `keyword` | 主动搜索时追加在标题/原名/别名之后(去重,不占标题检索词的 3 个上限),每个站点每个词搜一次 |
| `resolution` | `resolution`(MP 标签 `4K`/`1080P` 或正则值 `4K\|2160p\|x2160`) | `4k / 1080p / 720p`;无法识别 400;空或"全部"= 不设 |
| `source` | `quality` / `resource_type`(蓝光原盘 / Remux / BluRay / UHD / WEB-DL / HDTV / H265 / H264,标签或正则值) | `bluray_disc remux bluray uhd web hdtv h265 h264` |
| `effect` | `effect`(杜比视界 / 杜比全景声 / HDR / SDR) | `dv atmos hdr sdr` |
| `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` | — | 库根须存在;作品还没有路径绑定时整理用它(首次建绑定) |
| `searchIntervalHours` `stopAfter` | — | 同 W5-C;stopAfter 须是时间 |
新建:同作品同季(`season_unit_id` 同为空也算)已有 active / pending / paused 订阅 → **409**,消息里带已有订阅 id。PATCH 把状态改回追踪、重新订阅也做同样检查。
## 订阅私有规则组(include / exclude)
- `rule_group.hard`(迁移 0036):规则组的结构化硬条件,目前只用 `include[]` / `exclude[]`。评估时 `resolveRuleChain` 按链合并(`chainHard`):各组的包含都要满足(多组合成一条前瞻正则),排除取并集;再与版本目标自己的硬条件合并(`mergeHard`,两边的包含取 AND)。
- 订阅保存(新建 / PATCH params / 改状态)后 `syncPrivateRuleGroup`:`scope = {subscriptionId}`、`kind = acquisition`、`ladder_required = false`、名称 `订阅私有 <id 前 13 位>`;内容 = 表单包含(至少命中一条)AND 质量预设里只能用正则表达的 UHD / Atmos,排除原样。有变化 revision+1;条件清空或订阅取消(删除)→ 软删(`deleted_at`);重新订阅按 params 重建。
- 私有组不在规则页 `GET /rules` 列出;它排在引用链最前,不影响订阅默认链 / 显式规则组的选择。
- 迁移为存量订阅(未取消、params 有 include / exclude)各建一个私有组。评估不再读 params 的 include / exclude(`withSubscriptionFilters` 已删)。
## 质量预设(MP resolution / quality / effect)
保存时写到**该订阅自己的**版本目标(`subscription_id = 订阅`;作品共享的无主版本目标不动):
| 预设 | 硬条件 | 软偏好 |
| --- | --- | --- |
| 4k / 1080p / 720p | `resolutions` = `[2160p]` / `[1080p, 1080i]` / `[720p]` | |
| bluray_disc / remux | `sources` = `[BluRay-Disc]` / `[REMUX]` | |
| bluray | `sources` = 蓝光族 `[BluRay-Disc, REMUX, BluRay]` | `sources` 同序 |
| web / hdtv | `[WEB-DL, WEBRip]` / `[HDTV]` | web:`sources` 同序 |
| h265 / h264 | `codecs` = `[H.265]` / `[H.264]` | |
| uhd | 私有组包含 `UHD\|Ultra-?HD` | |
| dv / hdr / sdr | `hdr.anyOf = [DV]` / `hdr.anyOf = [HDR10, HDR10+, HLG, HDR Vivid]` / `hdr.requireSdr` | dv / hdr:`hdr` 偏好顺序 |
| atmos | 私有组包含 `Atmos` | |
改预设(`applySlotPreset`):新预设给出的字段覆盖;旧预设给过、新预设不再给的字段,只有版本目标上的值仍等于旧预设生成的值时才删(手改过的保留)。有变化 revision+1。
新建订阅时作品还没有版本目标:收录策略有 `defaultSlots` 按模板建;没有模板但给了预设 → 建一个"默认版本"承载预设;都没有则不建(同以前)。
## 下载提交与整理去向
- 下载器:`params.downloaderInstanceId` 指定实例(须启用且有保存路径),否则取第一个可用实例;计划 `evaluation.instanceId` 记下,`submitDownload` 用它。
- 保存路径:`params.downloadSavePath` 在评估闸门(`gates`)换成下载器侧路径(`subscriptionSavePath`):实例没配路径映射 → 原样;是本机路径且落在某条映射里 → 换成下载器路径;本身就是下载器侧路径(能反向映射回本机)→ 原样;都不成立 → 不建计划,候选记 `download_save_path_unmapped`。结果写进计划 `evaluation.savePath`,提交时作为 qB `savepath`(没有时用实例默认)。
- 库根:整理 `chooseRoot` 的顺序是 请求指定 → 作品已有路径绑定 → **订阅 `libraryRootId`**(下载计划所属版本目标的订阅优先,否则该作品最近的未取消订阅;库根须启用)→ 按类型默认。已有绑定时订阅库根不生效(不搬家)。
## 重置 `POST /subscriptions/{id}/reset`
对照 MP "重置订阅"(清 note)。我们的 note 是在途计划:
- 已预留未提交(`reserved`)的计划、已提交但下载任务不在(被移除、失败、丢失)的计划 → `cancelled`(`status_reason = subscription_reset`),版本目标走 `release` 迁移;
- 下载器里仍在跑的计划保留(`keptPlans`),避免重复下载;
- 清掉订阅关联版本目标的 `slot_deferral`,`next_evaluate_at = now`,追踪中的订阅立即入队重评;
- 已入库资产、分集账本、总集数(含锁定)不动。写审计 `subscription.reset`。已完成 / 已取消 409(用重新订阅)。
- 前端:卡片 / 详情"更多"菜单"重置",确认后提示释放和保留的计划数。
## 日历 `GET /subscriptions/calendar?from&to&mediaKind`
- 范围 `YYYY-MM-DD`,跨度 ≤ 62 天(月视图 6 周 = 42 天),`to < from` 400。只看追踪中(active / pending)订阅,最多 2000 条。
- 剧集 / 综艺:与覆盖矩阵同一模型(`buildSubscriptionModels`),列的 `airDate` 在范围内的集按 日期 + 季 聚合成一条(`S01E03–E04`,不连续时 `S01E03、E05`);范围外(`out_of_range`)的集不出现。状态取主版本那一行(没有版本目标时取"库内任一版本"):全已入库 `have`、部分 `partial`、都未入库 `missing`、都在今天之后 `upcoming`。
- 电影:日期 = 元数据 `releaseDate`,已入库 `have`,今天之后 `upcoming`,否则 `missing`;没有上映日期列进 `unknown`。
- `unknown`:范围内没有播出日期、且还没入库的集(每季最多 50 个),以及没有任何季列的订阅。界面标"播出日期未知"。
- 数据源:`refreshMetadata`(TMDB)对**追踪中订阅**范围内的季拉季详情(订阅季;接续后续季时含之后各季;没指定季取最新两季;最多 6 季),按(作品, 季, 集)建或补集单元 `air_date`(新建为 confirmed,挂季单元;已有单元只补日期、标题只补空)。没有追踪订阅的作品不额外请求 TMDB。`subscriptions.metadata` 定时任务照旧驱动刷新。
- 前端:`/console/subscriptions?view=calendar&month=YYYY-MM`,类型页签同样筛选;周一开头的月网格,条目链到订阅详情;下方列"播出日期未知"。
## 状态动作
| 端点 | 说明 |
| --- | --- |
| `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}/reset` | 见上"重置" |
| `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、暂停恢复重新订阅批量排序。
- `apps/server/test/subscriptions/subscriptions-c1a.test.ts`(纯函数 + 真实 PG + mock qB):MP 质量字段识别与映射、包含取 AND、保存路径映射;私有规则组(规则页不列、修订、软删、删除订阅与重新订阅)、评估链纳入私有组(包含没中 / 排除命中 / 与版本目标包含取 AND)、预设写版本目标与改预设保留手改、下载器与 qB savepath、映射不到不建计划、订阅库根用于首次整理、重置(释放 / 保留 / 清延后 / 审计 / 409)、日历(聚合、部分、未播出、未知、电影、跨度校验)。
- `apps/server/test/subscriptions/subscriptions-c1a-sources.test.ts`(mock 站点 + mock TMDB):关键词追加检索、集播出日期写入与幂等、无追踪订阅不拉季详情。
- 前端 `components/subscriptions/model.test.ts`:质量预设草稿 ↔ params、月网格与分桶。
- E7 / E9 / 规则组 / 订阅增强测试保持通过。
## 遗留
- 规则页不能编辑 `rule_group.hard`(只有订阅私有组用);多个组都带包含时合成的前瞻正则可能超过 200 字符上限,此时评估记 `invalid_pattern`。
- 日历不做 MP 的"日历缓存预热"任务;季详情只在元数据刷新时拉。综艺按播出日期的分段单元仍只在矩阵列上标 `segment`。
- 综艺分段列(同一播出日的上/中/下拆成独立列)等身份层按分段建单元后启用;现在只在列上标 `segment`。
- 版本目标新建(`POST /acquisition-slots`)position 缺省 0,与主版本同值时按 created_at 排在后面;需要调整顺序用 slot-order。