- 删除 docs/plan 下 00–18 的计划、评审、验收、发布记录与升级证据,docs/spikes 与 spikes 验证代码
(保留 spikes/movieclaw-src 本地克隆),以及 4 份一次性审查 / 调研;全部可在 ceb66f8 查到
- MP 对照移到 docs/porting/moviepilot-v3-comparison.md;经验教训改为 docs/plan/lessons.md(补第 25 条)
- docs/plan/README.md:文档放哪、当前状态、待办
- 引用改写:代码注释指向在维护的文档,其余注明已删除与 git 位置;清掉原有 6 个失效链接
- README 开头改为当前状态与入口;CHANGELOG 补齐 0.3.8–0.3.14 与工程变更
Co-Authored-By: Claude Opus 5.5 <[email protected]>
19 KiB
订阅运行手册(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 账本/完成/搜索见 05-subscription-gap(已删除,见 git ceb66f8) §4。
入口(SEARCH-2)
订阅只从作品详情页发起:本地作品 /console/library/:id、仅 TMDB 的 /console/media/tmdb/:type/:id,「订阅」打开订阅表单并预填作品(SubscriptionFormDialog initialTarget)。
TMDB 条目保存时先调 POST /files/entity-from-candidate 建档(同一 TMDB id 复用),再按作品建订阅。搜索结果与 ⌘K 面板不再有订阅按钮;订阅页「新建订阅」仍可搜作品。
数据
| 列 | 含义 |
|---|---|
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,提交时作为 qBsavepath(没有时用实例默认)。 - 库根:整理
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 < from400。只看追踪中(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。
本轮完成口径调整(已部署 v0.3.10)
普通订阅按下载器确认接收和目标覆盖判断完成,完成原因 download_accepted;Slot 与下载/整理任务仍继续记录实际下载、核验和入库状态。多目标必须全部满足,剧集只有明确季集范围才能计覆盖,未知总集数不按整个季的通配预留伪造完成。洗版订阅仍按入库结果判断。
手动搜索、工作台和原站详情下载独立于订阅,不创建假订阅,也不会占用原订阅 Slot。要对现有订阅补全仍使用上面的 Slot 补全或自动评估入口。来源与验证见 MP 对照 §5。