docs: MoviePilot subscription ledger and gap analysis

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 22:36:17 +08:00
1 parent 6e01497b3a
commit 3665daaf07
2 files changed
+387

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
# 05 订阅业务差距与增强清单(对标 MoviePilot v3 @ e6820a8)
> 上游行为见 `docs/porting/moviepilot-subscribe.md`(下称"账本",§ 号指账本章节)。本文只比对行为,不含代码。
> 我们的实现范围:`packages/domain/src/{slot,acquisition}`、`apps/server/src/modules/acquisition-organize/{services,handlers.ts,routes}`、`packages/contracts/src/policy.ts`、迁移 `0004_content`/`0008_policy`;对照需求 REQ §5.4–5.5、§6,任务书 00 §3.1 第 5 条。
> 核验日期 2026-09-27,基于当前工作区(含未提交改动)。
## 0. 结论摘要
- **架构差异是有意的**:MP 是"订阅行 = 身份+季+过滤参数+下载事实(note)"的单表模型,完成即删除;我们是"订阅(追踪意图)+ Slot(收录目标/条件)+ 内容单元账本 + 资产覆盖"的分层模型,事件驱动(`release.resolved → evaluateAcquisition`)。REQ 要求的分层(多 Slot、升级不覆盖、确定性评估、预算)已具备骨架。
- **可比语义 34 项**(另 2 项不迁移):已覆盖/等价 11 项(32%),部分 7 项(21%),缺失 16 项(47%)。
- **最大风险不在"缺功能",而在两处会导致错误结果的缺陷**:①剧集完成判定固定 `totalKnown: true` 且只按"已见过的单元"算,`once` 订阅会在 TMDB/发布只露出前几集时提前完成(违反 REQ §5.4);②Slot 同时只允许一个在途计划,忙时到达的发布被记 `slot_busy` 后**永不重评**(`next_evaluate_at` 未使用),连续更新的剧集会漏集。
## 1. MP 语义 → 我们现状 → 处置
状态:✅ 相同/等价 · ◐ 部分 · ✗ 缺失 · ↔ 有意不同(REQ 已覆盖)。优先级 P0(错误结果/主链路)> P1(常用能力)> P2(增强)。工作量 S ≤0.5 天,M 1–2 天,L 3 天+。
### 1.1 创建与目标范围
| # | MP 语义(账本 §) | 现状(文件) | 状态 | 处置 |
| --- | --- | --- | --- | --- |
| 1 | 订阅 = (source,id,季) 一行;多季拆多行(§1、§2) | 订阅指向本地实体 + `season_unit_id` + `season_scope`;条件在 Slot(`routes/rules.ts` subscriptions.create,`0008_policy`) | ↔ | REQ 增强已覆盖,不照抄 |
| 2 | 创建时补 TMDB 季集表,算 `total_episode`,插件可只升(§2.3) | 创建不取季集;`content_unit` 只在解析发布时懒建(`sources-identity/service/entities.ts` findOrCreateUnit);TMDB 适配器已有 `seasons[].episodeCount`(`adapters/src/tmdb/client.ts`)但无人消费 | ✗ | **需补 P0 M**:`refreshMetadata` 写季单元 `metadata.episodeTotal`+来源,并按集数预建 provisional 集单元;未知则记 `totalKnown=false` |
| 3 | 同身份同季查重;历史存在则 Follow 不再建(§2.5、§10) | 无查重,同一作品可建多条 active 订阅 | ✗ | 需补 P2 S:创建时同实体+同季 active 订阅返回 409 或幂等返回旧行 |
| 4 | 标题识别失败则创建失败(§2.2) | `titleQuery` 订阅可建,但全链路只按 `media_entity_id` 取订阅,**titleQuery 订阅永远不被处理**(`services/acquisition.ts` evaluateRelease) | ✗ | 需补 P1 M:创建时同步 `catalog.lookup`/`identity.resolve`,需确认的进待处理中心;确认后回填 `media_entity_id` |
| 5 | `start_episode`/`total_episode` 裁剪范围(§3.3) | 只有季粒度(`season_unit_id`);无起始集、无手动总集数锁 | ✗ | 需补 P1 S:`subscription.params.episodeRange {start,end?, locked}`,评估与完成都按范围裁剪 |
| 6 | 默认订阅配置按类型回落(§2.4) | 已发布 acquisition 策略的 `defaultSlots` 自动建 Slot(`routes/rules.ts`、`AcquisitionPolicyRules`) | ✅ | 照现状 |
| 7 | 订阅级下载器/保存路径(§1) | `params.downloaderInstanceId`;保存路径取实例(`services/acquisition.ts` gates) | ✅ | 照现状 |
### 1.2 缺集与匹配
| # | MP 语义 | 现状 | 状态 | 处置 |
| --- | --- | --- | --- | --- |
| 8 | 缺集 = 媒体库缺集 ∩ 订阅范围 − note(含在途)(§3) | `satisfiedUnits`=该 Slot 计划产出资产+当前资产的 `asset_unit_coverage`(`services/acquisition.ts`);在途计划不算单元而是锁整个 Slot | ◐ | 需补 P0(并入 #22):domain 新增 `computeUnitGaps(required, covered, inflight, range)`;在途单元计为"已预留"而非锁 Slot |
| 9 | 标题/年份/别名匹配 + ID 冲突复核(§5 第 3–4 步) | 身份在 `sources-identity` 解析,给证据等级,`needs_confirm` 进待识别池;评估只接 `resolved` 发布 | ↔ | REQ 增强已覆盖(更严格),不照抄 |
| 10 | 季匹配:多季包拒、无季号视为 S1(§5 第 5 步) | `current_season` + `season_unit_id` 时按季号过滤;实体级 Slot 接受任何季;多季包不拒 | ◐ | 需补 P1 S:`current_season` 未设 `season_unit_id` 时取创建时最新季;多季包按 Slot 范围判定覆盖而非直接拒 |
| 11 | 规则组 `>` 分层 → pri_order(§6.1) | Slot `hard`/`soft` + `qualityTier` + `softRank` 确定性排序(`domain/src/slot/evaluate.ts`) | ↔ | REQ §6.1 已覆盖 |
| 12 | include/exclude 正则(标题+副标题+标签)(§5 第 8 步) | 只有 `excludeGroups`;无关键词包含/排除 | ✗ | 需补 P1 S:`SlotHardConditions.include/exclude`(正则白名单校验,匹配 title+subtitle+labels) |
| 13 | 订阅站点范围 ∩ 系统 RSS 站点(§5 第 1 步,`get_sub_sites`) | 无;任何站点的发布都参与 | ✗ | 需补 P1 S:`subscription.params.siteIds`,评估时 `rel.site_id` 不在范围 → skipped `site_out_of_scope` |
| 14 | 体积区间,剧集按每集体积(§6.1 `__match_size`) | `min/maxSizeBytes` 按整包体积(`evaluate.ts` checkHardConditions) | ◐ | 需补 P1 S:增加 `perUnitSize`(体积/覆盖单元数),季包与单集同口径 |
| 15 | 做种数/促销/发布时间规则(§6.1) | 无 | ✗ | 需补 P2 S:hard 增加 `minSeeders`、`freeOnly`;站点字段已在 source_release |
| 16 | 订阅识别词 custom_words(§5 第 2 步) | 无订阅级识别词 | ✗ | 需补 P2 M:放 identity 层(实体级别名/偏移),不放订阅 |
### 1.3 搜索与周期
| # | MP 语义 | 现状 | 状态 | 处置 |
| --- | --- | --- | --- | --- |
| 17 | RSS/首页刷新后逐订阅匹配(§5) | Feed 轮询 + 站点快照 → 识别 → `release.resolved` 事件逐发布评估(`acquisition-organize/index.ts` outboxRoutes) | ✅ | 照现状(事件驱动比批量匹配更及时) |
| 18 | 订阅主动搜索:新订阅 5 分钟、定时 24h(默认关)、手动(§4、§11) | 只在创建/改 Slot 时对**本地已索引**的最近 50 条重评(`kickEvaluate`);`release.search` 能力支持实时补查但无订阅调度 | ✗ | **需补 P0 M**:`searchSubscription` 任务 = 按实体标题/别名调 `snapshotSite(query)` → 走同一识别评估链;新订阅立即一次,缺口 Slot 按间隔(默认 24h,站点限流器兜底),手动按钮 |
| 19 | 忙时/无结果后下次还能再匹配(批量匹配天然重试)(§5) | Slot `reserved/downloading/verifying` 时发布记 `slot_busy` 后不再评估;`next_evaluate_at` 有列无消费 | ✗ | **需补 P0 S**:Slot 离开忙态(verify_passed/failed、release)时入队按 Slot 重评;巡检消费 `next_evaluate_at` |
| 20 | 元数据定时刷新 6h,完成前再刷总集数(§8) | 无订阅相关元数据刷新调度 | ✗ | 需补 P0 M(并入 #2):追踪中的剧集每 6h `refreshMetadata`,完成判定前强制刷新一次 |
### 1.4 洗版/升级
| # | MP 语义 | 现状 | 状态 | 处置 |
| --- | --- | --- | --- | --- |
| 21 | 电影洗版:pri 严格提升,100 即完成(§6.2) | `evaluateCandidate` upgrade:`minTierSteps`、`ceilingTier`、压制组白名单、同档不换 | ✅ | REQ §6.1 已覆盖(更细) |
| 22 | 分集洗版:`episode_priority` 按集记录质量,只下能提升的集(§6.2) | 剧集 Slot 只有一个 `current_asset_id`;升级比较用"最近导入的那个资产";导入后 `current_asset_id = mainAssets[0]`(`services/organize.ts` verifyAndImport) | ✗ | **需补 P1 L**:按 (Slot, content_unit) 记录当前资产与质量档(新表或 coverage 加 slot_id+tier);升级按单元比较,候选只下"能提升的单元" |
| 23 | 全集洗版 / 分集洗版整包优先(整包须严格高于每集)(§6.2) | 无 | ✗ | 需补 P2 M:Slot `packPolicy: per_unit / full_pack / prefer_pack`,门槛照抄 MP |
| 24 | 洗版后旧文件由整理层覆盖/删除 | 新版核验入库后旧资产保留,生成"旧版本待处置" decision(`organize.ts`) | ↔ | REQ §6.2 增强已覆盖 |
### 1.5 状态、完成、回写
| # | MP 语义 | 现状 | 状态 | 处置 |
| --- | --- | --- | --- | --- |
| 25 | N/R/P/S;S 不参与任何动作(§7) | `active/paused/completed/cancelled`;非 active 订阅的 Slot 被跳过;无订阅 Slot 仍追踪 | ✅ | 照现状 |
| 26 | P 待定:可搜索不可完成(§7) | 无对应状态;domain `unitCompletion` 有 `keepTracking/totalKnown` 但服务端未用 | ◐ | 用 #27 的 `totalKnown` 替代 P,不新增状态 |
| 27 | 完成判定按目标范围与 total(§8) | `slotComplete` 固定 `{ totalKnown: true }`,required = 已存在的 main 集单元(懒建,只含见过的集)(`organize.ts` slotComplete) | ◐ | **需补 P0 S**:required 取季单元 `episodeTotal`(或预建单元);total 未知 → `complete=false, keepTracking`;once 订阅只在 `totalKnown && complete` 时完成 |
| 28 | 总集数只升;回退不低于已存在最高集;手动锁(§8) | 无 | ✗ | 需补 P0 S(并入 #2):domain 纯函数 `resolveEpisodeTotal(old, fresh, injected, coveredMax, locked)` |
| 29 | 下载后按集回写 note/episode_priority;整包需开种子核对(§9) | 入库后按实际文件写 `asset_unit_coverage`(domain `unitCompletion` 只认文件清单) | ↔ | REQ §6.2 已覆盖(更严格) |
| 30 | 从整季包按需选集下载(仅 qB/TR)(§9 第 4 步) | qB 适配器有 `setFilePriority`(`adapters/src/qbittorrent/client.ts`)但提交链路未用;季包整包下载 | ✗ | 需补 P1 M:提交后取文件列表,按 `computeUnitGaps` 只勾缺失单元文件;计划记 `selectedUnits` |
| 31 | 同一订阅一轮可下多个资源(batch_download 逐步扣减 lefts)(§9) | 每 Slot 同时一个计划;一个发布只给一个 Slot(`planned` 变量) | ✗ | **需补 P0 M**:允许同 Slot 多个"单元不相交"的在途计划(预留粒度从 Slot 改为 Slot×单元);部分唯一索引相应调整 |
| 32 | 完成 = 写历史 + 删订阅 + outbox 事件(§7) | `status=completed` 保留行,`status_reason` 记原因 | ↔ | 已覆盖(保留行即历史) |
| 33 | 重新订阅 / 重置(清事实,state=R)(§7、§10) | `subscriptions.update` 可把 status 改回 active;无"重置" | ◐ | 需补 P2 S:`POST /subscriptions/{id}/reset` 把 Slot 置回 empty/保留资产并重评 |
| 34 | 优先级:无显式排队(MP 串行 + 随机休眠) | plan.priority 按 即时>追更>新作品>升级 写入,但提交/巡检不按它排序(`services/downloads.ts` sweepReservedPlans) | ◐ | 需补 P2 S:巡检与 deferred 恢复按 `sortByPriority` 出队 |
| — | 分享/Fork/Follow/热门/Seerr、完成否决插件事件(§10) | 无 | — | 不迁移(REQ 未要求;否决由 decision/approval 承担) |
## 2. REQ 对 MP 的增强点:实现情况
| 增强点(REQ) | 期望 | 现状(文件) | 判定 | 缺口 |
| --- | --- | --- | --- | --- |
| Slot 多目标(§6.1) | 每作品多 Slot,独立状态/条件/升级;双版本须不同物理资产 | 多 Slot + 独立状态机 ✅(`domain/slot/state.ts`、`acquisition_slot`);`require_distinct_asset` 只存字段,`checkDistinctAssets` 未接入评估/入库 | ◐ | 评估时排除已被同组其他 Slot 占用的资产;一个发布可同时满足多个 Slot 时按组规则决定是否复用 |
| 升级不覆盖旧版(§6.2) | 新版核验后切换,旧版按保留策略另行处理 | 升级计划 purpose=upgrade;核验通过才切 `current_asset_id`;旧资产只生成 decision(`organize.ts`) | ✅ | 剧集按单元切换依赖 #22 |
| 追更总集数未知不结束(§5.4) | 总集数未知保持追更 | domain `unitCompletion({totalKnown})` 已支持;服务端硬编码 `true`,且无总集数来源 | ✗ | #2、#27、#28(P0) |
| 仅本季/接续后续季(§6.5) | 用户添加默认接续;Agent 推荐默认追完当前季;特别篇/重启不算下一季 | `season_scope` 字段 + 用户创建默认 `following_seasons` ✅;`current_season` 仅在有 `season_unit_id` 时生效;agent 来源未强制默认 `current_season`;特别篇/同名重启无判定 | ◐ | origin=agent 默认 current_season;新季出现时对 following 订阅建季级 Slot 或记录;特别篇(S00/同名重启)进 decision |
| Agent 主观选片接口(§6.5) | 明确条件代码执行;Agent 挑选记证据理由、独立预算、不得放宽硬限制 | `release.search` + `download.plan(slotId, releaseId)` 走同一确定性闸门 ✅(`acquisition-organize/capabilities.ts`);无"Agent 选择理由/证据"字段,无 Agent 独立预算 | ◐ | plan 增加 `selectedBy: agent` + `rationale/evidence`;预算加 `agentBytesPerDay/agentNewTitlesPerDay` |
| 偏好标签与抑制(§6.7) | 标签结构化、可编辑、取消留抑制,Agent 不得据旧证据加回 | CRUD、修订历史、抑制记录、`agent_inferred` 遇抑制拒绝 ✅(`routes/rules.ts`);**标签未参与任何评估**(hard 排除/soft 排序/version 偏好均未映射到 SlotSpec 或新作品收录) | ◐ | 评估前把 active 标签编译进 SlotSpec(soft→`softRank` 加一维,hard→新作品/Slot 硬排除),拒绝理由写标签 id |
| 预算与优先级(§6.4、§6.8) | 每日新增作品/字节/站点比例风险/存储保留/并发;即时>追更>新作品>升级 | 新增作品、字节、并发 ✅(`domain/acquisition/budget.ts`,gates 中执行,预留计入);优先级写入 plan ✅;站点比例风险、存储保留、Agent 独立预算 ✗;队列不按优先级 | ◐ | #34;站点比例 P2(需站点用户数据) |
| 临时集单元(§5.4) | TMDB 未更新可建 provisional 单元继续下载入库 | 解析发布即建 `content_unit(status=provisional)` ✅(`entities.ts`);但完成判定把"已见单元"当全集 | ◐ | 与 #27 联动:provisional 单元参与覆盖,但不代表 total |
| 三模式判定(§6.3) | 新作品看首播日期,追更看账本,升级独立于日期 | `determineMode` ✅(`domain/acquisition/mode.ts`),新作品需已发布策略 + 授权 | ✅ | — |
| 回退条件(§6.1) | 指定等待期后接受次选 | `fallback` 只映射 `unknownPolicy`(`packages/mapping/src/slot.ts`) | ✗ | P2 M:`fallback.after{days, relax}`,依赖 #19 的定时重评 |
## 3. 订阅增强实施清单(按优先级)
### P0(错误结果/主链路,建议 W5 首批)
1. **总集数来源与账本**:`refreshMetadata` 把 TMDB `seasons[].episodeCount` 写入季单元并预建 provisional 集单元,插件/人工值只升、可锁 — `apps/server/src/modules/sources-identity/service/{metadata,entities}.ts`、`packages/adapters/src/tmdb/client.ts`(已有字段)。
2. **总集数纯函数**:`resolveEpisodeTotal(old, fresh, injected, coveredMax, locked)`(只升;回退不低于已覆盖最高集)— `packages/domain/src/slot/`(新 `episode-total.ts`)+ 单测(账本 §13 样本 1、9)。
3. **完成判定修正**:`slotComplete` 用季单元 total 与 `totalKnown`,未知或未齐 → 继续追踪;`once` 订阅仅 `totalKnown && complete` 完成 — `apps/server/src/modules/acquisition-organize/services/organize.ts`、`packages/domain/src/slot/completion.ts`。
4. **缺集纯函数**:`computeUnitGaps(required, covered, inflight, range)`,评估与完成共用 — `packages/domain/src/slot/`(新 `gaps.ts`)、`services/acquisition.ts` satisfiedUnits。
5. **Slot×单元预留**:同 Slot 允许多个单元不相交的在途计划,`BUSY` 从整 Slot 改为按单元冲突;调整部分唯一索引 — `services/acquisition.ts` reserve、`packages/db/migrations`(新迁移)、`domain/slot/state.ts`(剧集态语义)。
6. **忙后重评与定时重评**:Slot 离开忙态入队 `evaluateAcquisition{acquisitionSlotId}`;巡检消费 `next_evaluate_at` — `services/organize.ts`、`services/downloads.ts`、`acquisition-organize/index.ts` 循环。
7. **订阅主动搜索任务**:`searchSubscription`(新订阅立即 + 缺口 Slot 每 24h + 手动),按实体标题/别名调 `snapshotSite({query})` 后走原识别评估链 — `apps/server/src/modules/sources-identity/{tasks,scheduler}.ts`、`acquisition-organize/routes/rules.ts`(手动端点)、`packages/contracts/src/api.ts`。
8. **追踪剧集元数据刷新周期**:active 剧集订阅每 6h `refreshMetadata`,完成判定前强制一次 — `sources-identity/scheduler.ts`。
### P1(常用过滤与季包)
9. **关键词包含/排除**:`SlotHardConditions.include/exclude`(正则白名单校验)— `packages/domain/src/slot/{types,evaluate}.ts`、`packages/contracts/src/policy.ts`、`packages/mapping/src/slot.ts`。
10. **订阅站点范围**:`params.siteIds`,评估跳过范围外站点并记理由 — `services/acquisition.ts`、`packages/contracts/src/policy.ts`。
11. **每集体积口径**:`perUnitSize` 按覆盖单元数折算 — `domain/slot/evaluate.ts`。
12. **集范围**:`params.episodeRange{start,end?,locked}` 裁剪评估与完成 — `services/acquisition.ts`、`services/organize.ts`、contracts。
13. **季包按需选集**:提交后读文件列表,`setFilePriority` 只下缺失单元文件,计划记 `selectedUnits` — `services/downloads.ts`、`packages/adapters/src/qbittorrent/client.ts`。
14. **按单元质量账本(分集升级)**:(Slot, unit) → 当前资产 + 质量档;升级按单元比较,候选只取能提升的单元 — 新迁移、`services/{acquisition,organize}.ts`、`domain/slot/evaluate.ts`。
15. **titleQuery 订阅落地**:创建时解析身份,需确认进待处理中心,确认后回填实体并重评 — `routes/rules.ts`、`sources-identity/service/resolve.ts`。
16. **季范围语义**:`current_season` 无 `season_unit_id` 时固定到创建时最新季;origin=agent 默认 current_season — `routes/rules.ts`、`services/acquisition.ts`。
17. **偏好标签接入评估**:active 标签编译进 SlotSpec/新作品过滤,理由记标签 id — `services/acquisition.ts`、`packages/mapping/src/slot.ts`。
### P2(增强)
18. 订阅查重(同实体同季 active 幂等)— `routes/rules.ts`。
19. 全集/整包优先升级模式 `packPolicy` — `domain/slot/{types,evaluate}.ts`、`services/acquisition.ts`。
20. 做种数/促销硬条件 — `domain/slot/evaluate.ts`。
21. `require_distinct_asset` 接入评估与入库 — `services/{acquisition,organize}.ts`、`domain/slot/completion.ts`。
22. Agent 选片理由/证据字段与独立预算 — `acquisition-organize/capabilities.ts`、`domain/acquisition/budget.ts`、contracts。
23. 回退等待期 `fallback.after` — `packages/mapping/src/slot.ts`、`domain/slot/evaluate.ts`。
24. 订阅重置端点 — `routes/rules.ts`。
25. 队列按 `sortByPriority` 出队 — `services/downloads.ts`。
26. 实体级识别词/集偏移(替代 MP custom_words)— `sources-identity`。
不迁移:订阅分享/Fork/Follow/热门、Seerr 入口、完成否决插件事件、搜索随机休眠(由站点限流器替代)。
+258
View File
@@ -0,0 +1,258 @@
# MoviePilot 订阅业务逻辑:迁移对照记录
> 格式:REQ §14.1.2 迁移交付规范。只记录行为对照与 TS 重写建议,**不复制 MP 源码**(GPLv3,见 `license-and-provenance.md`)。
> 对应需求:REQ §5.4(本地集账本)、§5.5(正片完整性)、§6.1–6.8(Slot/升级/三模式/预算/选片追踪/偏好/优先级)。任务书:`docs/plan/00-first-version-plan.md` §3.1 第 5 条。差距与实施清单见 `docs/plan/05-subscription-gap.md`。
## 0. 上游版本与核验范围
| 项 | 值 |
| --- | --- |
| 上游 | `jxxghp/MoviePilot` v3,本地 `/opt/moviepilot-plus/upstream/MoviePilot` |
| 精确提交 | `e6820a8`(`docs(architecture): close residual audit goal`),单提交浅克隆 |
| 核验方式 | 只读源码沿调用链阅读;未运行服务、未跑上游测试。"推断"条目须在 testkit 用样本复现 |
| 背景文档 | 上游 `docs/subscribe-lifecycle.md`(订阅模式/字段所有权);与源码不一致处以源码为准 |
V3 已把旧版单文件 `app/chain/subscribe.py` 拆成 owner 组合,任务书里列的旧路径在本提交中对应如下:
| 任务书旧路径 | e6820a8 实际路径 | 职责 |
| --- | --- | --- |
| `app/chain/subscribe.py` | `app/chain/subscribe/{create,search,match,completion,refresh,policy,query,notify}.py` + `facade.py` | 创建 / 主动搜索 / RSS 匹配 / 事实与完成 / 元数据刷新 / 洗版策略 / 查询与分享 / 通知 |
| (新增)洗版纯算法 | `app/application/subscription/priority.py` | 缺集数、当前优先级、洗版完成、全集包判定(纯函数) |
| `app/db/subscribe_oper.py` | `app/db/oper/subscribe.py`、`app/application/subscription/{write,mutation,complete,delete,query}.py` | 写入/查重、状态与重置、完成事务(写历史+删订阅+outbox) |
| `app/db/models/subscribe.py` | 同名 + `app/db/models/subscribehistory.py` | 订阅表 / 订阅历史表 |
| `app/chain/search.py` / `download.py` | `app/chain/search/*`、`app/chain/download/{existence,batch,selection}.py` | 搜索、缺集查询 `get_no_exists_info`、批量择优下载 `batch_download` |
| `app/helper/rule*.py` + filter | `app/application/rules.py`(`RuleHelper`)、`app/modules/filter/__init__.py` | 规则组选择、优先级(pri_order)计算 |
| `app/helper/torrent` | `app/application/torrent/download.py`(`TorrentHelper`) | 标题匹配 `match_torrent`、订阅附加过滤 `filter_torrent` |
| `app/scheduler.py` | `app/scheduler/catalog.py`、`app/runtime/config.py` | 订阅相关定时任务与间隔 |
## 1. 数据模型(`app/db/models/subscribe.py`)
| 字段组 | 字段 | 语义 |
| --- | --- | --- |
| 身份 | `media_source`+`media_id`(唯一约束 `media_identity_constraint`)、`type`、`season`、`episode_group` | 一条订阅 = 一个媒体身份的**一季**;多季订阅拆成多条(`async_add_batch`) |
| 目标范围 | `start_episode`、`total_episode`、`manual_total_episode` | 季内集范围;`manual_total_episode=1` 后元数据刷新不再改总集数 |
| 过滤 | `include`/`exclude`/`quality`/`resolution`/`effect`(正则)、`filter_groups`(规则组名)、`sites`(站点 ID)、`search_imdbid`、`custom_words`、`keyword` | 订阅级覆盖;空则回落系统默认 |
| 下载去向 | `downloader`、`save_path`、`media_category` | |
| 下载事实 | `note`(电影 `[1]`,剧集=已存在/已下载集号列表)、`episode_priority`(`{"集号":pri}` 只升不降) | 事实源 |
| 进度摘要 | `lack_episode`、`current_priority`、响应层派生 `completed_episode` | 派生值,由受控入口写 |
| 洗版 | `best_version`、`best_version_full`(只洗整包) | 三种洗版模式见 §6 |
| 生命周期 | `state`(N/R/P/S)、`last_update`、`date`、`username` | 见 §7 |
`SubscribeHistory` 结构与订阅相同(完成时整行复制),用于"订阅历史"查询、Follow 去重与重新订阅。
## 2. 订阅创建
| 项 | 内容 |
| --- | --- |
| 上游入口 | `SubscribeCreateOwner.add` / `async_add` / `async_add_batch`(`chain/subscribe/create.py:464`) |
| 输入 | title、year、mtype、season、episode_group、media_source+media_id(成对)、kwargs(过滤/洗版/站点/下载器/保存路径/total_episode/start_episode/lack_episode…) |
| 输出 | `(sid, message)`;失败 sid 为空并发失败通知 |
关键分支(按执行顺序):
1. **构造上下文** `__build_subscribe_create_context`:media_source/media_id 只给一个 → 直接拒绝;给了 season 则强制 `type=TV`。
2. **识别** `__recognize_subscribe_media`:有显式身份按身份识别(`cache=False`);**显式身份识别失败不允许退回按标题识别**(防串台)。非 TMDB 来源时用 MetaInfo 清洗标题,并从标题补季号。
3. **季集补全** `__prepare_subscribe_episodes`(`create.py:244`):
- 非 TV → `season=None`;TV 未给季 → 默认第 1 季。
- 未显式给 `total_episode`:若识别结果没有 `seasons` 或指定了剧集组,强制重新识别拿季集表;取 `len(seasons[season])` 为当前集数。
- 发 `SubscribeEpisodesRefresh` 链式事件(`refresh.py:487 __apply_episodes_refresh`),插件可把总集数**只向上**覆盖(`max(current, injected)`),用于"TMDB 未更新集数"场景。
- `__store_subscribe_episode_total`:外部值低于当前值时取当前值;总集数为 0 → 创建失败("未获取到第 N 季的总集数")。
- `__fill_subscribe_lack_episode`:未给 `lack_episode` 时 = `total_episode`(**创建时不查媒体库**,真实缺集在首次搜索时刷新)。
4. **默认值** `__get_default_kwargs`(`create.py:32`):按媒体类型读 `DefaultTv/Movie/MusicSubscribeConfig`,逐字段"订阅值优先,空则默认";`best_version` 用 `is None` 判空(显式 0 不被默认覆盖)。
5. **持久化** `add_subscribe`(`application/subscription/write.py:426`):按身份查重(可 `owner_scope` 限定用户);提交后副作用(通知、`SubscribeAdded` 事件、统计上报)经 after_commit 顺序执行。`state` 初值 `N`。
保留/调整:保留"显式身份失败不回退标题"、"外部总集数只升不降"、"季号默认 1"。调整:我们订阅对象是**本地实体 + 季单元**而非 (source,id,season) 三元组;总集数未知**不应拒绝创建**(REQ §5.4),应建 provisional 单元继续追。
验证样本:①TMDB 某季集数 0(未公布)→ MP 创建失败,我们应创建成功且 `totalKnown=false`;②插件注入 12 而 TMDB 为 8 → 总集数 12;③显式 tmdb:999999 不存在 → 失败且不按标题落到别的作品。
## 3. 缺集计算(no_exists)
### 3.1 媒体库缺集 `DownloadChain.get_no_exists_info`(`chain/download/existence.py:25`)
| 输入 | 输出 |
| --- | --- |
| meta、mediainfo(需 seasons)、`totals={季:总集数}` | `(exist_flag, {media_key: {季: NotExistMediaInfo(season, episodes, total_episode, start_episode)}})` |
- 电影:媒体服务器 `media_exists` 命中即 `(True, {})`。
- 剧集:媒体库完全没有 → 每季记 `episodes=[]`(**空列表=整季缺失**)、`total=totals[季] or len(TMDB集)`、`start=min(TMDB集号)`。部分存在 → 有 `totals` 时按 `range(min, min+total)` 差集,否则按 TMDB 集号差集;全部存在 → 跳过该季。
- 只看**媒体服务器已入库**;下载中的集不在这里。
### 3.2 订阅口径 `resolve_subscribe_missing`(`chain/subscribe/refresh.py:304`)
只读,不改状态。合并三类事实:**媒体库缺集 ∩ 订阅范围 − 订阅已下载事实**。
- 有效总集数 `__resolve_effective_total_episode`:TV、非手动、当前 mediainfo 季集数更大时临时用更大值(防旧 total 误判完成)。
- 普通订阅:调 `get_no_exists_info(totals={season: effective_total})`。
- 洗版订阅:**不看媒体库**;电影 `current_priority==100` 直接满足;剧集按 `episode_priority` 算 pending 集(全集洗版 pending 为空但 `require_complete_coverage=True`)。
- 已下载事实 `__get_downloaded`(`completion.py:227`):普通订阅取 `note`;分集洗版只取 priority==100 的集;全集洗版取空。
### 3.3 范围裁剪 `__get_subscribe_no_exits`(`completion.py:384`)
1. 用订阅 `start_episode/total_episode` 覆盖媒体库结果:整季缺失且订阅起始集 > 原起始集 → 转成显式集列表(否则空列表会被下载层当整季);部分缺失 → 与 `[max(start,原start), total]` 取交集,交集空 → 视为全部已有 `(True,{})`。
2. 减去已下载集(`note`);差集空 → `(True,{})`。
"下载中"的集如何算已有:**MP 在下载任务添加成功时就写 `note`**(§9),所以"下载中+已完成"都经 `note` 计入,不需要单独查下载器。代价:下载失败后 note 不回滚,只有"重置订阅"能清(`mutation.py:73 _reset_payload`)。
验证样本:S01 共 12 集,库有 1–4,note 有 5–6,start_episode=3 → 缺 7–12。
## 4. 订阅搜索(主动)
| 项 | 内容 |
| --- | --- |
| 入口 | `SubscribeSearchOwner.search(sid, state, manual, sids)` → `_execute_search`(`chain/subscribe/search.py:98`) |
| 并发 | 进程内可重入锁 `_rlock`,等待超时则跳过本轮 |
| 订阅集合 | 单个 sid / 指定 sids / 按状态(`get_states_for_search`:R 或 P → "R,P";N 单独) |
单条流程 `_process_search_subscription`(`search.py:199`):
1. 新增 < 60 秒的订阅跳过(`_defer_recent_subscription`,给用户改过滤条件的窗口)。
2. 定时的 R/P 批量搜索每条之间随机休眠 60–300 秒(`_wait_before_scheduled_search`),手动/定向不等。
3. 重新识别媒体(`cache=False`)→ `check_and_handle_existing_media`(`query.py:488`):先 `__refresh_total_episode_before_completion` 修正总集数,再 `resolve_subscribe_missing`;已齐 → `finish_subscribe_or_not(force=True)` 并结束。
4. `SearchChain.process(mediainfo, keyword, no_exists, sites=get_sub_sites, rule_groups=订阅 filter_groups or 系统 SubscribeFilterRuleGroups/BestVersionFilterRuleGroups, area=imdbid|title, custom_words, filter_params=get_params)`。
5. 无结果 / 过滤后无候选 → 仍调 `finish_subscribe_or_not(lefts=no_exists)` 刷新 lack_episode。
6. `_filter_search_contexts`(`search.py:286`):洗版剧集依次过 全集资源约束 → 集范围覆盖 → `prepare_best_version_tv_candidate`(设置 `allowed_episodes`);洗版电影 `pri_order <= current_priority` 拒绝。
7. 下载 `__download_best_version_with_full_pack_first`(`policy.py:261`)→ 重读订阅 → `finish_subscribe_or_not(downloads, lefts)`。
8. finally:若仍是 N 状态 → 改 R。
## 5. RSS/站点刷新匹配(被动)
入口 `refresh()`(`refresh.py`)→ `TorrentsChain().refresh(sites=get_subscribed_sites())` 抓各站首页/RSS 缓存 → `match(torrents)` → `_execute_match`(`match.py:105`)。只处理 R/P 订阅。
候选预处理 `_prepare_match_torrents`(`match.py:33`):未识别的种子重试识别(失败计数 < 3)。
逐订阅 × 逐种子过滤链(顺序即优先级):
| # | 检查 | 失败行为 |
| --- | --- | --- |
| 1 | 订阅站点域名 / `get_sub_sites` 站点范围 | 跳过 |
| 2 | 订阅有 custom_words 且命中 → 按识别词重建 meta 并重识别 | — |
| 3 | 仍无媒体 ID → 标题兜底 `__is_title_match_allowed`:`TorrentHelper.match_torrent`(`application/torrent/download.py:469`)比对标题/原名/别名/副标题,年份(电影 ±1 年且必须有年份;剧集比季年份,无年份不比),剧集拓扑消歧;仅无年份别名命中且无法消歧 → 拒绝 | 跳过 |
| 4 | 有媒体 ID:类型不同拒绝;ID 冲突时 `__reconcile_candidate_media`(`match.py:517`):标题含显式 ID → 严格拒绝;否则需标题复核 + 同作品证据才改判为目标 | 跳过 |
| 5 | 剧集:多季包拒绝;季号不等拒绝(无季号视为第 1 季) | 跳过 |
| 6 | 普通订阅:种子集号与缺失集无交集拒绝(空集号=整季包放行) | 跳过 |
| 7 | 洗版:全集洗版要求全集资源;集范围须覆盖 pending 集 | 跳过 |
| 8 | `TorrentHelper.filter_torrent(get_params)`(`download.py:726`):include/exclude 正则匹配 标题+副标题+标签+促销;quality/resolution/effect 只匹配标题;`size` 区间 | 跳过 |
| 9 | 规则组 `filter_torrents(rule_groups)` → 给种子写 `pri_order` | 不命中任一层级即跳过 |
| 10 | 洗版优先级门(同 §4 第 6 步) | 跳过 |
匹配到的候选走与主动搜索相同的 `__download_best_version_with_full_pack_first` 与 `finish_subscribe_or_not`。
## 6. 规则组与优先级、洗版
### 6.1 规则组(`modules/filter/__init__.py`、`application/rules.py`)
- 规则组 = 名称 + `rule_string` + 可选 `media_type`/`category`;`RuleHelper.get_rule_group_by_media` 按媒体类型/分类筛适用组。
- `rule_string` 用 `>` 分层,每层是规则名的 `& | !` 表达式。`__get_order`(`filter:269`):从第 1 层起逐层匹配,第 k 层命中 → `pri_order = 100 - (k-1)`;全不命中 → 过滤掉。**100 = 最高档**,洗版完成条件就是"拿到 100"。
- 单条规则 `__match_rule`:include/exclude 正则(可限定 match 字段)、`size_range`(MB,**剧集按每集体积 = 总体积/集数**,`__match_size` `filter:468`)、`seeders`、`downloadvolumefactor`(促销)、`publish_time`(发布分钟区间)、`tmdb` 条件(命中即放行)。
- 订阅 `filter_groups` 非空则覆盖系统默认组;普通订阅默认 `SubscribeFilterRuleGroups`,洗版默认 `BestVersionFilterRuleGroups`。
- `get_params`(`query.py:239`):订阅字段优先,空则取 `SubscribeDefaultParams`;另透传 `tv_size/movie_size/min_seeders/min_seeders_time`(本提交 Python 路径 `filter_torrent` 只读 `size` 键,这几项未见消费方,**推断为残留/交给 Rust 加速路径**,待复现)。
### 6.2 洗版三模式(纯算法在 `application/subscription/priority.py`)
| 模式 | 目标 | 候选准入 | 完成 |
| --- | --- | --- | --- |
| 电影洗版 | 单片质量 | `pri_order > current_priority` | `current_priority == 100`(`is_best_version_complete` `priority.py:377`) |
| 分集洗版(`best_version=1, full=0`) | 每集质量 | `get_best_version_interested_episodes`:资源所含目标集中 `pri > episode_priority[集]` 的集;为空拒绝,否则写 `context.allowed_episodes` 让下载层只选这些集 | 目标范围每集 `episode_priority == 100` |
| 全集洗版(`full=1`) | 整季包质量 | 必须单季、覆盖 `[start,total]` 全部集(无集号视为整季包,下载层再开种子核对);`pri > current_priority` | `current_priority == 100` |
- **分集洗版先试整包** `should_prefer_full_pack_for_episode_best_version`(`priority.py:551`):整包 `pri` 严格高于**每一个**目标集的已知 pri 才走整包(用 `require_complete_coverage=True` 的整季 no_exists);整包下载失败或不满足 → 回退按集。
- `current_priority` 语义:电影/全集 = 准入基线;分集 = 目标范围最低已知 pri(缺失按 0)。
- `lack_episode`(`compute_lack_episode` `priority.py:146`):普通订阅 = no_exists 该季集数(空列表取 total);洗版 = 目标集中 note 与 pri>0 都没有的集数。
- **替换语义**:MP 订阅层只负责"下载更高 pri 的版本";旧文件的处置在整理(转移)层按覆盖策略执行(见 `moviepilot-transfer.md`,`overwrite/latest` 可删同集其他版本)。
## 7. 状态机与生命周期
| 状态 | 含义 | 参与搜索 | 参与 RSS 匹配 | 可完成 |
| --- | --- | --- | --- | --- |
| N 新建 | 刚创建 | 仅 `new_subscribe_search`(5 分钟) | 否 | 是 |
| R 订阅中 | 正常 | 是("R,P") | 是 | 是 |
| P 待定 | 信息待更新(如总集数不可信) | 是 | 是 | **否**(`__finish_subscribe` 遇 P 直接返回 `completion.py:61`) |
| S 暂停 | 不参与任何动作 | 否 | 否 | — |
- 迁移:创建 N →(首次搜索 finally)R;API `PUT /status/{id}` 只允许 R/P/S;P 通常由插件在"待定集数"场景设置;"重置" `GET /reset/{id}` 清 note/episode_priority/current_priority,`lack=total`,`manual_total_episode=0`,`state=R`。
- **完成 = 删除**:`CompleteSubscriptionCommand.execute`(`application/subscription/complete.py`)同一事务 `stage_history`(整行写 SubscribeHistory)+ `stage_delete_sync`(删订阅)+ outbox(完成事件/通知/统计,幂等键 `subscribe.complete:{id}:{source}:{id}:v1`),提交后按 通知→事件→统计 顺序投递,统计失败后台重试。
- 完成前 `SubscribeCompletionCheck` 事件可被插件否决(`cancel=True`)。
- 没有独立"自动删除"开关:完成即从订阅表移除;删除订阅(`DELETE /{id}` 或 `/media/{id}`)不写历史。
## 8. 完成判定 `finish_subscribe_or_not`(`completion.py:106`)
1. 有下载:剧集 → `__record_subscribe_download_facts`;电影 → 写 note `[1]` 并 `current_priority = max(本轮 pri)`(`__update_movie_download_priority`)。
2. 普通订阅:剧集先用 lefts 刷 `lack_episode`;完成条件 = (剧集且 lefts 为空)或(电影且本轮有下载)或 force。
3. 洗版:剧集刷进度(分集模式派生 current_priority);`is_best_version_complete` 为真才完成,否则继续洗版。
4. `force=True` 来自 `check_and_handle_existing_media` 发现已全部存在。
**异常:总集数变化**
- 定时 `check()`(`refresh.py:93`,6 小时)与完成前 `__refresh_total_episode_before_completion`(`refresh.py:556`)都会用最新季集数 + 插件注入值重算 total(`manual_total_episode=1` 时跳过)。
- 变大:普通订阅 `lack += 差值`;全集洗版基线 `current_priority` 清 0(原整包已不能证明覆盖)。
- 变小 `__resolve_total_episode_decrease`(`refresh.py:395`):**不低于旧范围内已确认存在的最高集号**(防 TMDB 临时回退导致误完成);全集洗版直接接受新值且保留基线。洗版订阅按新范围裁剪 `episode_priority`。
- TMDB 未更新:本身无内建处理;靠 P 状态 + `SubscribeEpisodesRefresh` 插件注入(只升)规避"当前已知集齐 = 完成"。
验证样本:①TMDB 先报 8 集,下载齐 8 集前 TMDB 改 12 → 完成前刷新为 12,不完成;②TMDB 由 12 回退 10,库里已有 1–11 → total 取 11;③state=P 且缺集为空 → 不完成。
## 9. 下载后回写与季包处理
**订阅事实回写** `__record_subscribe_download_facts`(`completion.py:277`),下载任务**添加成功**即调用(不等完成):
- 集来源优先级:下载层 `selected_episodes` > 种子标题 `episode_list` > `confirmed_full_coverage`(下载层开种子确认整包覆盖)时用目标范围全集 > 否则不记。
- 只记目标范围内的集;`note` 并集;`episode_priority[集] = max(旧, pri)`;全集洗版且确认整包覆盖且 pri 更高 → 提升 `current_priority`。
**批量择优下载** `DownloadChain.batch_download`(`chain/download/batch.py:32`)与季包:
1. 候选先排序(站点优先级/pri_order/做种等),失败的候选有冷却,同组继续试下一条。
2. **整季匹配**:整季缺失(`episodes=[]`)时找无集号的单季包;只一季的包可能命名错误,**打开种子核对实际集数 ≥ 总集数**;`require_complete_coverage` 时须覆盖范围内每一集。
3. **季内集匹配**:单季含集的种子,集合须是所需集子集(完整覆盖任务改为须覆盖目标范围);受 `allowed_episodes` 裁剪。
4. **从整季包选集**(仅 qB/TR):仍缺的集到整季包里按文件选集下载(`selected_episodes`)。
5. 每次成功下载后更新剩余缺集 lefts,返回 `(downloads, lefts)`。
保留:整包需文件清单证实;从包选文件而不是整包下载。调整:我们以**导入后文件清单**计完成(REQ §6.2),"添加成功即记事实"只能作为"在途预留",不能算已满足。
## 10. 历史、重新订阅、分享与外部入口
| 功能 | 上游 | 行为 |
| --- | --- | --- |
| 订阅历史 | `GET /history/{mtype}`、`DELETE /history/{id}` | 完成时整行快照;非管理员只看自己的 |
| 重新订阅 | 前端从历史行重新 `POST /`;Follow 刷新时 `history_exists` 为真则**不再自动订阅** | 历史即"已订阅过"去重依据 |
| 分享/复用 | `POST /share`、`DELETE /share/{id}`、`POST /fork`、`GET /shares`、`/share/statistics`、`/popular` | 订阅参数(过滤/规则组/洗版/识别词)作为模板上报到公共服务;fork = 用模板参数新建 |
| Follow | `follow()`(`query.py:100`,1 小时) | 关注的分享人新分享 → 身份去重 + 历史去重 → 以 `username=订阅分享` 自动 add |
| Seerr | `POST /seerr` | Overseerr/Jellyseerr 请求转订阅 |
| 文件信息 | `GET /files/{id}` | 下载历史 + 媒体库文件投影 |
| 回填 | `backfill_existing_episodes`(`refresh.py:704`) | 可信调用方写入已有集(可带 pri,只升);只接受目标范围内集 |
## 11. 周期(`scheduler/catalog.py:248-311`,`runtime/config.py:418-426`)
| 任务 | 周期 | 说明 |
| --- | --- | --- |
| `new_subscribe_search` | 5 分钟 | 只搜 state=N |
| `subscribe_search` | `SUBSCRIBE_SEARCH_INTERVAL` 小时(默认 24),**默认关闭**(`SUBSCRIBE_SEARCH=False`) | 搜 R/P,每条随机休眠 60–300 秒 |
| `subscribe_refresh` | `SUBSCRIBE_MODE=spider`(默认):每天 32 个随机时刻抓站点首页;`rss`:每 `SUBSCRIBE_RSS_INTERVAL` 分钟(默认 30) | 刷新缓存后 `match` |
| `subscribe_tmdb` | 6 小时 | `check()`:元数据与总集数刷新 |
| `subscribe_follow` | 1 小时 | Follow 分享 |
| `subscribe_calendar_cache` | 定时 | 预热订阅日历(TMDB 分集) |
## 12. 对应本项目模块建议(汇总)
| MP 机制 | 本项目落点 | 保留 / 调整 |
| --- | --- | --- |
| 订阅 = (身份, 季) + 过滤参数 | `subscription`(实体 + `season_unit_id` + `season_scope`)+ `acquisition_slot`(条件) | 调整:过滤条件放 Slot,不放订阅;一个作品可多 Slot |
| `note` / `episode_priority` | `asset_unit_coverage`(已入库事实)+ 在途计划的 `units`(预留) | 调整:在途 ≠ 已满足;质量按 Slot×单元记录 |
| `no_exists` 合并 | domain 新增 `computeUnitGaps(required, covered, inflight, range)` 纯函数 | 保留"范围裁剪 + 事实差集"算法 |
| `pri_order`(规则组层级) | `softRank` + `qualityTier`(确定性排序)| 调整:不用 100 分制;"顶档" = `ceilingTier` |
| 洗版三模式 | Slot `upgrade` + 新增 `packPolicy`(per_unit / full_pack / prefer_pack) | 保留整包优先与"严格高于每一集"门槛 |
| N/R/P/S | `subscription.status` + `totalKnown`/provisional 单元 | 调整:P 语义由"总集数未知 → keepTracking"替代 |
| 完成即删除 + 历史 | `status=completed` 保留行 + audit | 调整:不删,便于重新激活 |
| 总集数只升、回退不低于已存在最高集 | `refreshMetadata` → 季单元 `episode_total` + provisional | 保留 |
| 随机休眠/新增 60 秒窗口 | 站点级限流器已有;新增窗口可选 | 部分保留 |
| 分享/Follow/Seerr/热门 | 不做(REQ 未要求) | 不迁移 |
## 13. 验证样本清单(供 testkit)
1. 普通剧集:12 集,库 1–4,在途 5–6,RSS 来 E07 单集 → 下 E07;来 S01 整季包 → 从包里只选 7–12(或整包,按 packPolicy)。
2. start_episode=3:整季缺失时生成 3–12 显式集,不下整季。
3. 分集洗版:episode_priority `{1:90,2:100}`,来 pri=95 的 E01-E02 合集 → 只 E01 感兴趣。
4. 分集洗版整包优先:整包 pri=100,目标集 pri 最低 90 → 走整包;若某集已是 100 → 回退按集。
5. 全集洗版:总集数 10→12 → 基线清 0 重新找整包。
6. 电影洗版:current=98,来 98 → 拒绝;来 99 → 下载,完成条件未达继续。
7. RSS 标题兜底:无年份别名命中两部同名剧 → 拒绝。
8. 种子 ID 与订阅 ID 冲突但标题复核 + 同作品证据通过 → 改判为目标。
9. TMDB 回退:12→10,库 1–11 → total=11,不完成。
10. P 状态缺集为空 → 不完成;S 状态 → RSS 不匹配。
11. 规则组 `A > B > C`:命中 B → pri=99;都不命中 → 过滤。
12. 规则 size_range 500-3000MB,10 集 20GB 包 → 每集 2GB 通过。