mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 04:47:03 +08:00
test(subscribe): establish governance replay baseline
This commit is contained in:
871
docs/refactor/moviepilot-subscription-governance-handoff.md
Normal file
871
docs/refactor/moviepilot-subscription-governance-handoff.md
Normal file
@@ -0,0 +1,871 @@
|
||||
# MoviePilot 订阅执行治理上游 Handoff
|
||||
|
||||
> 日期:2026-09-01
|
||||
> 目标版本:MoviePilot V3
|
||||
> 生产参考:MoviePilot V2 `2.15.5` 的一次只读样本
|
||||
> 关联路线图:`docs/refactor/moviepilot-subscription-governance-roadmap.md`
|
||||
> 交接目的:由上游完成订阅执行链路的整体治理,而不是继续修补单个慢点
|
||||
|
||||
> 仓库接管说明(2026-09-01):本文已纳入 MoviePilot `v3` 仓库。文中
|
||||
> `d923037f9` 是原交接环境的参考提交,不存在于当前仓库历史;对应正确性合同必须在
|
||||
> `SUB-GOV-001A` 中基于当前源码重新建立和验证,不能将该提交视为当前分支已交付证据。
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
当前订阅问题需要按一个完整执行域治理,不能再把它理解为“给 `match()` 加缓存”或“把循环改成并发”。
|
||||
|
||||
需要同时解决四类问题:
|
||||
|
||||
1. 日常 RSS/Spider 刷新后,完整候选缓存被全部订阅重复扫描,核心复杂度接近 `O(S * C)`;
|
||||
2. 每条订阅在确认本轮是否存在相关候选前,就执行新鲜媒体识别和媒体库缺失查询;
|
||||
3. 24 小时兜底搜索在与日常 Match 共用的长锁中逐订阅等待并访问站点;
|
||||
4. 日常 Match、手工搜索和兜底搜索缺少统一的 single-flight、下载提交幂等、取消和恢复合同。
|
||||
|
||||
建议的最终形态是:
|
||||
|
||||
```text
|
||||
站点候选获取
|
||||
-> 本轮候选批次与 retry/pending 账本
|
||||
-> 无损候选路由
|
||||
-> 受影响订阅准备
|
||||
-> 本轮新鲜事实租约
|
||||
-> 串行、短临界区、可幂等的下载提交
|
||||
-> 独立完成对账
|
||||
|
||||
兜底搜索
|
||||
-> 可恢复搜索队列
|
||||
-> 每站点容量预算
|
||||
-> 复用同一准备/提交/完成合同
|
||||
```
|
||||
|
||||
生产发布不采用 shadow A/B,也不在生产同时运行旧、新两套 Match:
|
||||
|
||||
- 上线前通过固定数据快照、可重放测试和目标规模压测证明新链路;
|
||||
- 上线后只有新链路产生计算、下载和状态写入;
|
||||
- 不保留运行时旧链路双跑或对照开关;
|
||||
- 回滚以镜像/版本回滚和兼容数据库迁移为基础。
|
||||
|
||||
## 2. 目标与验收规模
|
||||
|
||||
这次治理的目标不是小样本快几秒,而是让以下规模成为正常使用场景:
|
||||
|
||||
- `100–200` 条活跃订阅;
|
||||
- `10–20` 个启用站点;
|
||||
- 单轮 `1000+` 条 RSS/Spider 候选;
|
||||
- 兜底搜索开启后,不阻塞日常 RSS/Spider Match;
|
||||
- 手工刷新、日常 Match 和兜底搜索重叠时,不重复下载、不错误完成;
|
||||
- PT 站点请求频率和峰值压力不高于治理前基线。
|
||||
|
||||
最终验收必须同时看:
|
||||
|
||||
- 正确性;
|
||||
- 用户等待时间;
|
||||
- 本地计算复杂度;
|
||||
- TMDB/媒体服务器请求数量;
|
||||
- PT 站点请求压力;
|
||||
- 下载幂等;
|
||||
- 取消、重启和异常恢复。
|
||||
|
||||
任何只优化其中一项、但把代价转移到其他项的方案都不算完成。
|
||||
|
||||
## 3. 证据边界
|
||||
|
||||
### 3.1 生产 V2 样本
|
||||
|
||||
生产实例运行 MoviePilot V2 `2.15.5`。对 11 轮连续日志做过一次只读统计:
|
||||
|
||||
| 指标 | 观察值 |
|
||||
| --- | ---: |
|
||||
| 每轮活跃订阅 | 约 12 条 |
|
||||
| 每轮候选站点域 | 约 5 个 |
|
||||
| 每轮候选 | 约 500 条 |
|
||||
| Match 中位数 | `17.752s` |
|
||||
| Match 范围 | `11.049–29.200s` |
|
||||
| 单订阅相邻日志跨度中位数 | 约 `0.4s` |
|
||||
| 每轮 TMDB 查询日志 | `12–63` 次 |
|
||||
|
||||
该证据只能说明:在订阅数量很少时,日常 Match 已经有明显耗时和波动;它不能直接外推 V3 在
|
||||
`100–200` 条订阅下的绝对耗时,也不能替代 V3 的可重放验证。
|
||||
|
||||
取证期间未修改配置、未触发额外刷新或搜索、未重启服务。
|
||||
|
||||
### 3.2 V3 源码快照
|
||||
|
||||
本 handoff 的源码定位基于 2026-09-01 的 V3 快照。上游持续变化不影响本文的业务约束;实际开始集成时,
|
||||
再将符号和文件位置映射到最新 V3,不需要在设计和实现阶段持续追逐上游提交。
|
||||
|
||||
当前关键位置:
|
||||
|
||||
| 位置 | 当前职责 |
|
||||
| --- | --- |
|
||||
| `app/chain/subscribe/refresh.py::SubscribeRefreshOwner.refresh` | 串联站点刷新和日常 Match |
|
||||
| `app/chain/torrents.py::TorrentsChain.refresh` | 逐站点获取 RSS/Spider、识别、合并并返回完整缓存 |
|
||||
| `app/chain/subscribe/match.py::SubscribeMatchOwner._execute_match` | 持锁、预处理完整缓存、逐订阅识别/查缺/全量扫描/下载/完成 |
|
||||
| `app/chain/subscribe/search.py::SubscribeSearchOwner._execute_search` | 持同一把锁执行新增、手工或兜底搜索 |
|
||||
| `app/chain/subscribe/search.py::_wait_before_scheduled_search` | R/P 兜底搜索逐订阅随机等待 60–300 秒 |
|
||||
| `app/chain/subscribe/facade.py::SubscribeChain._rlock` | Match 和 Search 共用的类级锁,超时上限 2 小时 |
|
||||
| `app/chain/subscribe/completion.py::finish_subscribe_or_not` | 缺集、下载事实和完成状态收敛 |
|
||||
| `app/chain/subscribe/refresh.py::__refresh_total_episode_before_completion` | 完成前按新鲜季集事实修正总集数 |
|
||||
|
||||
V3 当前已经提供 `TorrentsChain.get_subscribe_cache_candidates()`,可以按媒体身份和季筛选缓存候选,
|
||||
并对标题兜底候选做显式低置信标记;但日常 `SubscribeMatchOwner._execute_match()` 尚未调用它,仍然逐订阅
|
||||
遍历传入的完整候选集合。该能力可以复用,但不能直接把它当成治理已经完成。
|
||||
|
||||
## 4. 当前执行路径
|
||||
|
||||
### 4.1 日常 RSS/Spider Match
|
||||
|
||||
```text
|
||||
Scheduler / 手工刷新
|
||||
|
|
||||
v
|
||||
SubscribeRefreshOwner.refresh()
|
||||
|
|
||||
v
|
||||
TorrentsChain.refresh()
|
||||
- 逐站点读取 RSS,或 Spider 每站点最多两页
|
||||
- 与本地完整缓存去重
|
||||
- 新候选做媒体识别
|
||||
- 新候选追加到完整缓存
|
||||
- 返回当前站点范围内的完整缓存
|
||||
|
|
||||
v
|
||||
SubscribeMatchOwner._execute_match()
|
||||
- 获取 Match/Search 共用类级锁
|
||||
- 对完整缓存做候选预处理/失败重识别
|
||||
- 读取所有 R/P 订阅
|
||||
- 每条订阅重新取得 cache=False 的媒体事实
|
||||
- 每条订阅查询媒体库和下载事实
|
||||
- 每条订阅扫描完整候选集合
|
||||
- 过滤、择优、下载、写进度、判断完成
|
||||
- 释放类级锁
|
||||
```
|
||||
|
||||
### 4.2 兜底搜索
|
||||
|
||||
```text
|
||||
subscribe_search 定时任务
|
||||
|
|
||||
v
|
||||
SubscribeSearchOwner._execute_search(state=R/P)
|
||||
- 获取与 Match 共用的类级锁
|
||||
- 逐订阅读取
|
||||
- 每条订阅先随机等待 60–300 秒
|
||||
- 识别目标和查询缺失
|
||||
- 对多个站点发起搜索
|
||||
- 过滤、下载、写进度、判断完成
|
||||
- 全部完成后释放类级锁
|
||||
```
|
||||
|
||||
如果有 100 条需要兜底搜索的订阅,仅随机等待的期望总时间约为 5 小时;200 条约为 10 小时。
|
||||
实际站点请求、超时、解析和下载还会继续增加总时长。在这段时间里,日常 Match 会等待同一把锁。
|
||||
|
||||
## 5. 问题清单
|
||||
|
||||
### P0:完成正确性不能被性能优化破坏
|
||||
|
||||
日常 Match 的媒体识别使用 `cache=False`,并非无意义的缓存遗漏。电视剧在完成前会依据本次新鲜季集事实
|
||||
修正 `total_episode` 和 `lack_episode`。
|
||||
|
||||
典型边界:
|
||||
|
||||
1. 本地订阅仍记录 12 集并且 `lack_episode=0`;
|
||||
2. TMDB 已将该季扩展为 13 集;
|
||||
3. 如果使用旧缓存,系统会继续认为 12 集完整并完成/删除订阅;
|
||||
4. 如果使用新鲜事实,系统应更新为 13 集并恢复 1 集缺口。
|
||||
|
||||
本地提交 `d923037f9 test(subscribe): guard fresh match metadata` 已固定以下合同:
|
||||
|
||||
- 日常 Match 继续请求 `cache=False`;
|
||||
- 12 集事实不产生错误变化;
|
||||
- 13 集新鲜事实会把 `total_episode` 更新为 13、`lack_episode` 更新为 1。
|
||||
|
||||
因此不能把 `cache=True` 作为治理方案。正确的优化方向是“同一轮只取得一次足够新鲜的事实并安全复用”,
|
||||
而不是跨轮复用最长可达数十小时的旧详情。
|
||||
|
||||
### P0:下载提交缺少跨入口统一幂等合同
|
||||
|
||||
当前 Match 和 Search 依赖同一把全局锁降低重叠概率,但这不是可扩展的幂等设计。锁一旦拆分,以下入口可能
|
||||
同时为同一订阅、同一 torrent 或同一剧集范围生成下载:
|
||||
|
||||
- RSS Match;
|
||||
- Spider Match;
|
||||
- 新增订阅搜索;
|
||||
- 24 小时兜底搜索;
|
||||
- 手工刷新或手工搜索;
|
||||
- 插件触发的订阅处理。
|
||||
|
||||
现有下载历史和订阅 `note` 能参与缺失判断,但不能替代“提交瞬间的原子防重”。尤其是下载器已经接受任务、
|
||||
本地写回失败时,系统无法安全判断是否应该自动重试。
|
||||
|
||||
### P1:完整缓存和本轮新增候选没有执行语义上的区分
|
||||
|
||||
`TorrentsChain.refresh()` 会识别新种子、追加到缓存,再把完整缓存返回给 Match。这样可以保留旧候选重试能力,
|
||||
但也使历史候选在每轮反复进入预处理和全订阅扫描。
|
||||
|
||||
不能简单改成“只处理本轮 delta”,因为以下候选仍需要后续重试:
|
||||
|
||||
- 媒体识别临时失败;
|
||||
- TMDB 或媒体服务器临时失败;
|
||||
- 下载器暂时不可用;
|
||||
- 新订阅创建时需要回看仍有效的缓存候选;
|
||||
- 自定义识别词只能在具体订阅上下文中应用;
|
||||
- 下载器接受结果不确定,需要对账而不是直接丢弃。
|
||||
|
||||
因此需要的是:本轮 delta 优先 + 明确的 pending/retry 账本,而不是完整缓存永远重扫,也不是只处理新候选。
|
||||
|
||||
### P1:核心循环接近 `O(S * C)`
|
||||
|
||||
每条订阅都会遍历按站点分组的全部候选,再执行站点、身份、季、剧集、过滤规则和洗版优先级判断。
|
||||
|
||||
当 `S=200`、`C=1000` 时,仅第一层组合就是 20 万次;若候选包含复杂识别、标题兜底、自定义词和优先级规则,
|
||||
成本会继续放大。
|
||||
|
||||
已有 `get_subscribe_cache_candidates()` 证明媒体身份/类型/季可以做安全的轻量前置过滤,但当前接口仍然是
|
||||
“每条订阅重新遍历完整缓存”,并没有建立一次构建、多订阅复用的反向索引。
|
||||
|
||||
### P1:在候选相关性确认前执行每订阅外部事实查询
|
||||
|
||||
当前 Match 对每条订阅先执行:
|
||||
|
||||
- 新鲜媒体识别;
|
||||
- 媒体库缺失查询;
|
||||
- 下载历史和洗版进度计算;
|
||||
|
||||
之后才扫描候选。即使本轮没有任何候选可能命中该订阅,也会支付主要外部成本。
|
||||
|
||||
候选门禁可以跳过“本轮资源匹配准备”,但不能跳过“订阅完成对账”。这两个业务目标必须拆开:
|
||||
|
||||
- 资源匹配:由候选到达触发,只处理受影响订阅;
|
||||
- 完成对账:按独立调度或媒体事实变化触发,不依赖本轮是否有候选。
|
||||
|
||||
### P1:Match 和兜底搜索共享长锁
|
||||
|
||||
`SubscribeChain._rlock` 同时保护 Match 和 Search,锁范围包括:
|
||||
|
||||
- TMDB/媒体识别;
|
||||
- 媒体服务器查询;
|
||||
- PT 站点搜索;
|
||||
- 60–300 秒随机等待;
|
||||
- 种子获取和下载器调用;
|
||||
- 订阅状态写入。
|
||||
|
||||
该锁把“防止同一订阅重复提交”扩大成了“整个订阅域一次只能执行一个长任务”。结果是:
|
||||
|
||||
- 慢站点阻塞所有日常 Match;
|
||||
- 一条订阅异常拖延整批;
|
||||
- 手工操作无法合理获得优先级;
|
||||
- 取消只能在少数循环边界生效;
|
||||
- 直接增加 worker 没有意义,因为全局锁仍然串行。
|
||||
|
||||
### P1:兜底搜索节流单位错误
|
||||
|
||||
当前随机等待按“订阅”执行,但真正需要保护的是“站点请求容量”。
|
||||
|
||||
订阅级等待的问题:
|
||||
|
||||
- 多站点请求仍可能在等待结束后同时突发;
|
||||
- 订阅多时等待线性累积;
|
||||
- 等待期间占用业务锁;
|
||||
- 无法针对单个站点的 `429/403`、登录失效或超时单独冷却;
|
||||
- 无法在进程重启后恢复公平的处理位置。
|
||||
|
||||
### P2:取消、停止和恢复粒度不足
|
||||
|
||||
现有停止检查主要位于订阅或候选循环边界,`time.sleep()` 和正在进行的同步网络调用不可中断。进程重启后,
|
||||
系统也没有明确的批次游标、候选终态和不确定下载提交状态用于恢复。
|
||||
|
||||
### P2:用户状态只有任务进度,没有完整业务状态
|
||||
|
||||
当前可以报告“正在匹配第 N 个订阅”,但缺少稳定的业务状态合同,例如:
|
||||
|
||||
- queued;
|
||||
- matching;
|
||||
- waiting_site_budget;
|
||||
- preparing_download;
|
||||
- submitted;
|
||||
- retryable;
|
||||
- failed;
|
||||
- cancelling/cancelled;
|
||||
- reconcile_required;
|
||||
- completed。
|
||||
|
||||
因此后台失败、锁等待、下载器不确定状态和订阅完成容易被用户理解成操作成功、卡住或订阅消失。
|
||||
|
||||
## 6. 必须保持的不变量
|
||||
|
||||
### 6.1 正确性
|
||||
|
||||
1. 日常 Match 和完成对账必须获得足够新鲜的季集事实;
|
||||
2. 12→13 集等更新不能因跨轮缓存而错误完成订阅;
|
||||
3. 候选索引不能漏掉当前完整逻辑能够命中的资源;
|
||||
4. 自定义识别词、标题兜底、动态插件规则和身份不确定候选必须保守处理;
|
||||
5. 候选门禁只能缩小资源匹配范围,不能隐式跳过完成对账;
|
||||
6. 下载前必须重读订阅、缺集、下载历史和洗版优先级;
|
||||
7. 失败、取消、停机和锁等待失败不能写为成功或完成;
|
||||
8. 单条订阅失败不能阻止其他订阅继续,但批次必须暴露聚合失败。
|
||||
|
||||
### 6.2 站点压力
|
||||
|
||||
1. RSS/Spider 默认请求频率不得增加;
|
||||
2. RSS/Spider 不通过站点并发化换性能;
|
||||
3. 兜底搜索同一站点最多 1 个在途请求,除非未来有明确站点能力证据;
|
||||
4. 同一站点请求间隔、冷却和重试必须统一受预算控制;
|
||||
5. `429/403`、登录失效和连续超时必须降低该站点压力;
|
||||
6. 手工搜索可以提高队列优先级,但不能绕过站点预算。
|
||||
|
||||
### 6.3 下载与状态
|
||||
|
||||
1. 同一订阅、同一 torrent、同一季集覆盖在入口重叠时至多提交一次;
|
||||
2. 下载器已接受但本地写回失败必须进入“不确定/待对账”,不能盲目重试;
|
||||
3. 提交成功前不得提前更新订阅已下载事实;
|
||||
4. 完成、删除、通知和历史写入必须由一条 canonical 路径执行;
|
||||
5. 数据库迁移应优先保持向后兼容,以支持版本级回滚。
|
||||
|
||||
## 7. 明确否决的方案
|
||||
|
||||
以下方案不应再次作为主方案提交:
|
||||
|
||||
### 7.1 把日常 Match 改为 `cache=True`
|
||||
|
||||
原因:可能使用旧季集事实错误完成或删除追更订阅。
|
||||
|
||||
### 7.2 直接回写调用方传入的完整候选缓存
|
||||
|
||||
原因:可能覆盖未参与本轮处理的站点域、音乐缓存、RSS/Spider 不同缓存文件或其他消费者状态。
|
||||
|
||||
### 7.3 只处理本轮新增候选
|
||||
|
||||
原因:会丢失临时识别失败、外部服务失败、新建订阅回看和下载器不确定状态的重试机会。
|
||||
|
||||
### 7.4 跨刷新周期轮流处理订阅
|
||||
|
||||
原因:只是把耗时隐藏到多轮,可能错过短生命周期候选,并显著增加用户完成延迟。
|
||||
|
||||
### 7.5 直接把全部订阅或全部站点开成 2/4/N worker
|
||||
|
||||
原因:会放大 TMDB、媒体服务器、PT 站点和下载器并发,且在没有幂等合同前引入重复下载和完成竞态。
|
||||
|
||||
### 7.6 只删除全局锁
|
||||
|
||||
原因:全局锁虽然粒度错误,但当前仍承担部分防重作用;没有订阅级 single-flight 和提交幂等时直接删除会暴露竞态。
|
||||
|
||||
### 7.7 把逐订阅随机等待改成整批只等待一次
|
||||
|
||||
原因:可能让大量实例在同一时间集中访问站点,降低总耗时但增加 PT 风控压力。
|
||||
|
||||
### 7.8 生产 shadow A/B 或旧新双跑
|
||||
|
||||
原因:生产上线即采用新方案,不在同一轮同时运行旧、新 Match,也不维护两套结果和采集闭环。双跑还会增加
|
||||
实现复杂度、观测责任和误触发副作用的风险。
|
||||
|
||||
正确性对照应在上线前的隔离可重放环境中完成;生产只运行一条新链路。
|
||||
|
||||
### 7.9 单独上线统计日志或日志聚合器
|
||||
|
||||
原因:它不解决用户问题,也没有必要先引入一套长期采集系统。实现所需的最小运行指标应直接成为新链路的
|
||||
业务结果和运维摘要,而不是另建文本日志聚合器。
|
||||
|
||||
## 8. 推荐目标架构
|
||||
|
||||
### 8.1 阶段一:候选获取与批次边界
|
||||
|
||||
每次 RSS/Spider 刷新产生一个明确的 `CandidateBatch`:
|
||||
|
||||
```text
|
||||
CandidateBatch
|
||||
- batch_id
|
||||
- source: rss | spider | fallback_search | manual_search
|
||||
- started_at / finished_at
|
||||
- sites
|
||||
- fresh_candidates
|
||||
- retry_candidates
|
||||
- counters / terminal_status
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- RSS/Spider 仍按当前站点策略获取,不增加请求;
|
||||
- 当前轮新增候选与历史完整缓存分开表达;
|
||||
- 当前轮优先处理 `fresh_candidates`;
|
||||
- 只有 retryable、不确定或明确需要回看的候选进入持久 retry/pending 账本;
|
||||
- 已终态拒绝、已成功提交、已过期候选不再每轮重扫;
|
||||
- 不要求把每轮所有候选都写入数据库,避免用持久化开销替代扫描开销。
|
||||
|
||||
建议候选身份至少包含:
|
||||
|
||||
- 稳定 torrent/resource key;
|
||||
- 来源、站点、首次/最后出现时间;
|
||||
- 影视/音乐类型;
|
||||
- 识别出的 `media_source + media_id`;
|
||||
- 季和集范围;
|
||||
- 识别置信来源;
|
||||
- 当前终态和下一次允许重试时间。
|
||||
|
||||
### 8.2 阶段二:一次构建的无损候选索引
|
||||
|
||||
每个批次只构建一次索引,不再由每条订阅重新遍历完整候选:
|
||||
|
||||
```text
|
||||
CandidateIndex
|
||||
- by_site[site_id]
|
||||
- by_media[(media_source, media_id)]
|
||||
- by_media_season[(media_source, media_id, season)]
|
||||
- unknown_identity[]
|
||||
- custom_word_fallback[]
|
||||
- music[]
|
||||
```
|
||||
|
||||
路由规则:
|
||||
|
||||
1. 有明确媒体身份的候选,通过 `media_source + media_id` 直接定位受影响订阅;
|
||||
2. 类型或季明确冲突时可以安全排除;
|
||||
3. 身份缺失、标题兜底、自定义识别词、动态插件规则进入保守集合;
|
||||
4. 保守集合只允许缩小到“可能相关”,不能提前执行最终业务匹配;
|
||||
5. 最终季集、过滤规则、洗版优先级和自定义词仍复用 canonical 逻辑。
|
||||
|
||||
复杂度目标从 `O(S * C)` 收敛为近似:
|
||||
|
||||
```text
|
||||
O(C) 构建索引
|
||||
+ O(S) 构建订阅路由
|
||||
+ O(M) 对真实可能匹配的订阅/候选对执行完整规则
|
||||
+ O(F) 处理保守 fallback
|
||||
```
|
||||
|
||||
其中 `M` 应远小于 `S * C`,`F` 必须可度量且不能静默吞掉候选。
|
||||
|
||||
### 8.3 阶段三:资源匹配与完成对账分离
|
||||
|
||||
资源匹配入口只处理本批次受影响订阅:
|
||||
|
||||
- 没有候选关联的订阅,不执行本轮资源匹配准备;
|
||||
- 有候选关联的订阅,才取得新鲜媒体事实、媒体库缺失事实并执行完整规则;
|
||||
- 完成前仍重读当前订阅和缺集事实。
|
||||
|
||||
完成对账成为独立任务:
|
||||
|
||||
- 订阅元数据更新后;
|
||||
- 媒体库同步后;
|
||||
- 下载完成/整理后;
|
||||
- 低频定时校验;
|
||||
- 用户显式刷新。
|
||||
|
||||
完成对账不扫描 PT 候选,但必须继续保护新鲜季集语义。
|
||||
|
||||
### 8.4 阶段四:本轮新鲜事实租约
|
||||
|
||||
在同一个批次内,相同事实只获取一次:
|
||||
|
||||
```text
|
||||
FreshFactKey
|
||||
- media_source
|
||||
- media_id
|
||||
- media_type
|
||||
- season
|
||||
- episode_group
|
||||
```
|
||||
|
||||
首次读取仍按当前 `cache=False` 语义取得新鲜事实,随后只在本轮内复用。批次结束即释放,不跨刷新周期保留。
|
||||
|
||||
需要区分:
|
||||
|
||||
- 稳定元数据:标题、年份、别名、图片等;
|
||||
- 动态季集事实:当前季集数、分集信息;
|
||||
- 媒体服务器存在事实;
|
||||
- 订阅业务判断:缺集、洗版优先级、下载历史。
|
||||
|
||||
可以合并相同媒体的外部事实查询,但不能把不同订阅的范围、过滤规则、洗版模式或最终状态判断直接共用。
|
||||
|
||||
### 8.5 阶段五:准备与提交分离
|
||||
|
||||
准备阶段允许纯计算或有界外部读取:
|
||||
|
||||
- 候选完整匹配;
|
||||
- 新鲜媒体事实;
|
||||
- 媒体库缺失事实;
|
||||
- 规则过滤;
|
||||
- 下载计划。
|
||||
|
||||
准备阶段不得:
|
||||
|
||||
- 提交下载;
|
||||
- 标记已下载;
|
||||
- 完成或删除订阅;
|
||||
- 发送完成通知。
|
||||
|
||||
提交阶段保持短临界区:
|
||||
|
||||
1. 按订阅或逻辑媒体目标取得 single-flight/提交锁;
|
||||
2. 重读订阅当前状态;
|
||||
3. 重读下载历史、媒体库缺失和洗版优先级;
|
||||
4. 计算幂等键并检查终态;
|
||||
5. 提交下载;
|
||||
6. 根据下载器明确结果写入成功、失败或不确定状态;
|
||||
7. 更新订阅进度并执行 canonical 完成判断;
|
||||
8. 释放锁。
|
||||
|
||||
### 8.6 下载幂等
|
||||
|
||||
建议幂等身份至少组合:
|
||||
|
||||
```text
|
||||
subscription logical identity
|
||||
+ torrent/resource identity
|
||||
+ target season/episode coverage
|
||||
+ subscription mode (normal/best-version)
|
||||
```
|
||||
|
||||
不要只使用订阅数据库行 ID:多条订阅记录可能指向同一媒体。首轮可以先保证同一记录不重复提交,跨记录去重
|
||||
作为后续低优先级能力,但数据模型不要阻止未来扩展。
|
||||
|
||||
提交终态建议:
|
||||
|
||||
- `planned`;
|
||||
- `submitting`;
|
||||
- `accepted`;
|
||||
- `rejected`;
|
||||
- `uncertain`;
|
||||
- `reconciled`。
|
||||
|
||||
`uncertain` 必须先查询下载器或下载历史对账,不能自动再次提交。
|
||||
|
||||
### 8.7 兜底搜索队列与站点预算
|
||||
|
||||
兜底搜索不再在全局锁内逐订阅 `sleep`。建议模型:
|
||||
|
||||
```text
|
||||
SubscriptionSearchQueue
|
||||
- 订阅任务与优先级
|
||||
- 可取消等待
|
||||
- 持久游标/租约
|
||||
- 重启恢复
|
||||
|
||||
SiteBudget
|
||||
- 每站点最多 1 个在途请求
|
||||
- next_allowed_at
|
||||
- 随机请求间隔
|
||||
- 429/403/timeout/login-invalid 冷却
|
||||
- 成功后逐步恢复
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 随机化保留在站点预算层,而不是订阅长循环;
|
||||
- 不同站点可独立推进,但同一站点默认串行;
|
||||
- 批次启动仍保留全局抖动,避免大量实例整点突发;
|
||||
- 手工任务可提高队列优先级,但不能突破站点预算;
|
||||
- 等待不持有订阅提交锁;
|
||||
- 取消应能终止尚未发出的站点请求和可取消等待;
|
||||
- 已发出的同步请求若无法中断,结果返回后必须检查任务租约再决定是否提交。
|
||||
|
||||
### 8.8 业务状态
|
||||
|
||||
后端应把批次和订阅任务状态作为业务结果,而不是只输出日志:
|
||||
|
||||
```text
|
||||
queued
|
||||
running
|
||||
waiting_site_budget
|
||||
matching
|
||||
preparing
|
||||
submitting
|
||||
accepted
|
||||
retryable
|
||||
uncertain
|
||||
failed
|
||||
cancelling
|
||||
cancelled
|
||||
completed
|
||||
```
|
||||
|
||||
状态需包含:
|
||||
|
||||
- 当前阶段;
|
||||
- 已处理/总数;
|
||||
- 当前订阅或站点;
|
||||
- 可安全展示的失败原因;
|
||||
- 是否可取消、可重试或需人工对账;
|
||||
- 批次聚合结果。
|
||||
|
||||
前端展示可以后置,但后端状态合同应与执行治理一起设计,避免后续继续依赖日志推断。
|
||||
|
||||
## 9. 并发策略
|
||||
|
||||
首轮不要以并发作为主要收益来源。正确顺序是:
|
||||
|
||||
1. 消除 `S * C` 全量扫描;
|
||||
2. 没有关联候选时跳过资源匹配的外部查询;
|
||||
3. 同轮合并新鲜媒体事实和媒体服务器事实;
|
||||
4. 缩短提交锁;
|
||||
5. 把兜底搜索等待移到站点预算;
|
||||
6. 仍存在可重复等待热点时,再考虑 2/4 个有界 prepare worker。
|
||||
|
||||
即使启用 prepare worker:
|
||||
|
||||
- 站点请求仍由 SiteBudget 控制;
|
||||
- 下载提交仍按订阅/幂等键串行;
|
||||
- 不共享可变 `Context`;
|
||||
- worker 输入使用不可变快照或副本;
|
||||
- worker 失败不能取消其他订阅;
|
||||
- 系统停止和任务取消必须可传播。
|
||||
|
||||
## 10. 上线前验证:隔离重放,不做生产 shadow
|
||||
|
||||
### 10.1 一次性输入快照
|
||||
|
||||
从生产或生产形态实例一次性提取用于测试的输入,不需要常驻日志聚合器:
|
||||
|
||||
- 订阅业务字段快照;
|
||||
- RSS/Spider `Context` 候选快照;
|
||||
- 站点选择和过滤规则配置;
|
||||
- 媒体库存在/缺失返回;
|
||||
- TMDB 季集事实;
|
||||
- 下载历史与洗版优先级;
|
||||
- 预期匹配、下载计划、进度更新和完成集合。
|
||||
|
||||
不得包含站点 Cookie、token、下载器凭据等运行秘密。是否对标题做隐私脱敏不影响治理设计,但提交给上游的
|
||||
公开夹具仍应只保留可复现业务字段。
|
||||
|
||||
### 10.2 参考结果生成
|
||||
|
||||
在隔离测试进程中,用当前 canonical 逻辑对固定快照生成参考结果:
|
||||
|
||||
- matched candidate set;
|
||||
- planned download set;
|
||||
- missing episode set;
|
||||
- completion/update/delete set;
|
||||
- 外部请求计数;
|
||||
- 候选扫描计数。
|
||||
|
||||
然后只运行新链路,对相同快照断言结果。该过程属于离线回归验证,不部署双路径,也不允许任何真实下载、
|
||||
站点搜索、TMDB 或媒体服务器出站。
|
||||
|
||||
### 10.3 正确性场景
|
||||
|
||||
至少覆盖:
|
||||
|
||||
1. RSS/Spider 无新候选;
|
||||
2. 有新候选但全部不相关;
|
||||
3. 电影命中;
|
||||
4. 电视剧单集、连续集、全集、多季包;
|
||||
5. 12→13 集季集增长;
|
||||
6. 手工总集数;
|
||||
7. 普通订阅和全集/分集洗版;
|
||||
8. 自定义识别词;
|
||||
9. 标题兜底和候选身份冲突;
|
||||
10. 不同媒体源身份;
|
||||
11. 音乐订阅;
|
||||
12. 媒体库已存在、部分缺失、不可用;
|
||||
13. TMDB 失败、空结果和慢响应;
|
||||
14. 下载器接受、拒绝、超时和结果不确定;
|
||||
15. 手工、日常和兜底入口重叠;
|
||||
16. 取消、停机和重启恢复;
|
||||
17. 插件对完成事件或候选诊断的兼容行为。
|
||||
|
||||
### 10.4 规模矩阵
|
||||
|
||||
至少验证:
|
||||
|
||||
| 档位 | 订阅 | 站点 | 候选 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 小 | 20 | 5 | 500 |
|
||||
| 目标一 | 100 | 10 | 1000 |
|
||||
| 目标二 | 200 | 20 | 2000 |
|
||||
|
||||
每档都需要分别测试:
|
||||
|
||||
- 无相关候选;
|
||||
- 1% 订阅受影响;
|
||||
- 10% 订阅受影响;
|
||||
- 大量 fallback 候选;
|
||||
- TMDB/媒体服务器正常、慢和失败。
|
||||
|
||||
建议初始性能门槛:
|
||||
|
||||
- 无相关候选时,不允许产生逐订阅 TMDB/媒体服务器调用;
|
||||
- 候选扫描量应从 `S * C` 收敛到候选索引和实际关联集合;
|
||||
- 目标一的 Match 阶段 p95 不高于 15 秒;
|
||||
- 目标二的 Match 阶段 p95 不高于 30 秒;
|
||||
- 无相关候选的 Match 阶段 p95 不高于 5 秒;
|
||||
- 首个可提交下载计划不应等待整个批次完成;
|
||||
- 同一输入连续运行不得出现不可解释的线性退化。
|
||||
|
||||
绝对时限可以由上游基于参考硬件调整,但不能删除结构性门禁,也不能只用小样本证明“已优化”。
|
||||
|
||||
## 11. 生产发布方式
|
||||
|
||||
### 11.1 发布前门禁
|
||||
|
||||
必须同时满足:
|
||||
|
||||
- 新链路全量 focused/集成测试通过;
|
||||
- 固定快照的匹配、下载计划、缺集和完成集合一致;
|
||||
- 12→13 集正确性门禁通过;
|
||||
- 目标规模压测通过;
|
||||
- 站点请求模型没有增加 RSS/Spider 压力;
|
||||
- 重叠入口不产生重复下载;
|
||||
- 取消、重启和下载器不确定状态有可验证结果;
|
||||
- 数据库迁移可从旧版本升级,并支持旧镜像读取或有明确恢复步骤。
|
||||
|
||||
### 11.2 上线行为
|
||||
|
||||
上线后:
|
||||
|
||||
- 只有新治理链路运行;
|
||||
- 不调用旧 Match 做结果对照;
|
||||
- 不为对照重复读取 RSS/Spider、TMDB、媒体服务器或下载器;
|
||||
- 不维护 baseline/shadow/active 三模式;
|
||||
- 不需要独立日志聚合器。
|
||||
|
||||
建议首个生产窗口由现有运维日志和新链路自身的批次摘要确认:
|
||||
|
||||
- 批次总耗时和各阶段耗时;
|
||||
- 候选总数、delta、retry、终态数;
|
||||
- 受影响订阅数;
|
||||
- TMDB/媒体服务器请求数;
|
||||
- 每站点请求数、在途峰值和冷却;
|
||||
- 下载计划、accepted、rejected、uncertain;
|
||||
- 完成、失败、取消和恢复数。
|
||||
|
||||
这些是新链路的运行结果,不是一套额外的文本日志采集产品。
|
||||
|
||||
### 11.3 回滚条件
|
||||
|
||||
出现以下任一情况应停止新版本并回滚:
|
||||
|
||||
- 漏匹配或错误匹配;
|
||||
- 错误完成/删除订阅;
|
||||
- 重复下载;
|
||||
- RSS/Spider 请求次数或站点峰值压力增加;
|
||||
- 同一站点出现连续 `429/403` 且预算未降压;
|
||||
- 大量 `uncertain` 无法对账;
|
||||
- 任务取消后仍继续提交下载;
|
||||
- 数据库状态无法被上一稳定版本读取;
|
||||
- 目标规模下 Match 持续超过发布门槛并造成任务重叠。
|
||||
|
||||
### 11.4 回滚方式
|
||||
|
||||
优先采用:
|
||||
|
||||
1. 停止调度器继续产生新批次;
|
||||
2. 等待或取消未提交任务;
|
||||
3. 对 `submitting/uncertain` 任务先与下载器对账;
|
||||
4. 备份数据库;
|
||||
5. 回滚到上一稳定镜像/版本;
|
||||
6. 若有新增表或字段,保持 additive migration,使旧版本可以忽略;
|
||||
7. 恢复调度前核对活跃订阅、下载任务和未决幂等记录。
|
||||
|
||||
不建议在生产保留一套旧 Match 代码作为运行时 fallback;这会让两套语义长期漂移。回滚单位应是完整版本。
|
||||
|
||||
## 12. 推荐实施顺序
|
||||
|
||||
上游可以拆成多个可审查提交或 PR,但部署到生产的版本应包含一条完整可用的新链路。
|
||||
|
||||
| 阶段 | 交付内容 | 核心验收 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 固定当前正确性合同和可重放夹具 | 12→13、匹配/下载/完成集合可重复 |
|
||||
| 2 | 引入 CandidateBatch、delta 和最小 retry/pending 账本 | 不丢临时失败或新建订阅回看候选 |
|
||||
| 3 | 建立一次构建的无损 CandidateIndex | 不再 `S * C` 全量扫描,fallback 不漏匹配 |
|
||||
| 4 | 分离资源匹配与完成对账 | 无候选不做资源准备,完成正确性保持 |
|
||||
| 5 | 引入本轮 FreshFactLease 与媒体服务器事实合并 | 外部请求按唯一受影响媒体而不是订阅数增长 |
|
||||
| 6 | 拆分 prepare/commit,建立 single-flight 和提交幂等 | 重叠入口不重复提交,uncertain 可对账 |
|
||||
| 7 | 兜底搜索移出长锁,建立队列、游标和 SiteBudget | 不阻塞 Match,可取消、恢复、降压 |
|
||||
| 8 | 后端业务状态和前端展示 | 用户看到真实进度、失败、取消和未决状态 |
|
||||
| 9 | 条件性有界 prepare worker | 只有前述治理后仍有热点才启用 |
|
||||
| 10 | 跨订阅记录的同媒体去重 | 最低优先级,不阻断单记录治理 |
|
||||
|
||||
每个阶段都必须复用一套 canonical 匹配、缺集、洗版和完成规则,不能为了拆 PR 复制业务实现。
|
||||
|
||||
## 13. 测试与检查建议
|
||||
|
||||
### 13.1 单元测试
|
||||
|
||||
- CandidateBatch 状态转换;
|
||||
- retry/pending TTL 和重试时间;
|
||||
- CandidateIndex 类型、身份、季和站点路由;
|
||||
- unknown/custom/title fallback;
|
||||
- FreshFactKey 隔离 `season` 和 `episode_group`;
|
||||
- subscription single-flight;
|
||||
- 幂等键和提交终态;
|
||||
- SiteBudget 间隔、冷却和恢复;
|
||||
- 取消状态机;
|
||||
- 完成对账的 12→13 集语义。
|
||||
|
||||
### 13.2 集成测试
|
||||
|
||||
- `refresh -> match -> download -> completion`;
|
||||
- RSS 与 Spider 各自缓存边界;
|
||||
- 音乐缓存不被影视候选写回覆盖;
|
||||
- 日常 Match 与兜底搜索重叠;
|
||||
- 手工任务插队但不突破 SiteBudget;
|
||||
- 下载器接受后本地写入失败;
|
||||
- 进程在 queued/preparing/submitting/uncertain 阶段重启;
|
||||
- 插件事件与旧公开入口兼容。
|
||||
|
||||
### 13.3 性能测试应记录
|
||||
|
||||
- 候选索引构建时间;
|
||||
- 候选访问/比较次数;
|
||||
- 受影响订阅数和 fallback 数;
|
||||
- 每阶段 p50/p95/max;
|
||||
- TMDB/媒体服务器调用数;
|
||||
- 每站点请求数、峰值在途和最小间隔;
|
||||
- 下载提交尝试数和重复抑制数;
|
||||
- event-loop lag、线程/任务遗留和 shutdown 时间。
|
||||
|
||||
## 14. 可直接复用的现有基础
|
||||
|
||||
1. `TorrentsChain.get_subscribe_cache_candidates()` 已实现候选深拷贝、媒体身份、类型和季的轻量筛选;
|
||||
2. `Context` 已有 `resource_source`、`match_source`、`candidate_recognized`、
|
||||
`media_info_is_target` 等候选来源字段;
|
||||
3. `finish_subscribe_or_not()` 和 `__refresh_total_episode_before_completion()` 已承载 canonical 完成语义;
|
||||
4. `resolve_subscribe_missing()` 已将“查询缺失但不推进状态”抽成可复用入口;
|
||||
5. 下载链已有资源 key、失败冷却、下载历史和订阅来源信息,可用于构造幂等与对账合同;
|
||||
6. Scheduler 已有任务进度回调,可作为批次业务状态的兼容入口;
|
||||
7. 提交 `d923037f9` 可作为新鲜季集语义测试的移植参考。
|
||||
|
||||
这些基础仍需要整合,不能因为局部 helper 已存在就认为执行治理已完成。
|
||||
|
||||
## 15. 上游交付完成定义
|
||||
|
||||
上游完成交付时,应能提供以下证据:
|
||||
|
||||
1. 新架构说明和状态/所有权边界;
|
||||
2. 候选 delta、retry/pending 和索引的实现;
|
||||
3. 资源匹配与完成对账分离;
|
||||
4. 本轮新鲜事实租约;
|
||||
5. prepare/commit、single-flight、幂等和 uncertain 对账;
|
||||
6. 兜底搜索队列、游标和每站点预算;
|
||||
7. 目标规模的可重放性能结果;
|
||||
8. 正确性、站点压力、重复下载、取消和恢复测试;
|
||||
9. 单一新链路的生产发布和版本级回滚说明;
|
||||
10. 后端业务状态,及后续前端展示的稳定契约。
|
||||
|
||||
如果最终实现仍然满足以下任一情况,则治理尚未完成:
|
||||
|
||||
- 每条订阅扫描全部候选;
|
||||
- 无相关候选仍逐订阅调用 TMDB/媒体服务器;
|
||||
- 兜底搜索等待仍持有 Match/提交锁;
|
||||
- 依赖全局锁防止重复下载;
|
||||
- 下载器不确定状态会自动重试;
|
||||
- 100–200 条订阅只能靠跨轮分片才能运行;
|
||||
- 生产需要旧、新双跑才能建立信心;
|
||||
- 用户仍只能从文本日志猜测任务是否成功。
|
||||
|
||||
## 16. 需要上游优先确认的设计决定
|
||||
|
||||
请上游在实现前明确以下决定,并把结果固定在代码合同或设计文档中:
|
||||
|
||||
1. CandidateBatch 与 retry/pending 的内存/持久化边界;
|
||||
2. torrent/resource 稳定身份的 canonical 计算方式;
|
||||
3. FreshFactLease 的 key 和生命周期;
|
||||
4. 完成对账的触发来源与最低频率;
|
||||
5. subscription single-flight 与下载幂等的唯一 owner;
|
||||
6. 下载器 accepted/uncertain 的可对账接口;
|
||||
7. SiteBudget 的持久化、冷却和手工任务优先级;
|
||||
8. 生产回滚时新增状态表/字段的兼容策略;
|
||||
9. 目标规模压测使用的参考硬件和最终绝对时限;
|
||||
10. 后端批次/订阅任务状态的 API 契约。
|
||||
|
||||
这些是实现必须作出的技术决定,不需要通过生产 shadow A/B 才能确定。
|
||||
340
docs/refactor/moviepilot-subscription-governance-roadmap.md
Normal file
340
docs/refactor/moviepilot-subscription-governance-roadmap.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# MoviePilot 订阅执行治理
|
||||
|
||||
> 状态:`active(2026-09-01 已由 MoviePilot v3 接管)`
|
||||
> 当前叶:`SUB-GOV-001B`
|
||||
> 迁移来源:`V3-RDY-009A`、`V3-RDY-009A1`、`V3-RDY-009A1B`、
|
||||
> `V3-RDY-009A1C`、`V3-RDY-009A1D`
|
||||
> 适用范围:V3 订阅刷新、匹配、兜底搜索、下载提交与用户状态闭环
|
||||
|
||||
本 Goal 从 V3 发布准备总 roadmap 中独立出来。总 roadmap 只保留状态和链接,本文件负责订阅域的
|
||||
事实、方案、依赖、上线前验证、叶子交付与停止条件。
|
||||
|
||||
面向上游的完整问题与实施交接见
|
||||
`docs/refactor/moviepilot-subscription-governance-handoff.md`。生产发布不采用 shadow A/B 或旧新双跑:
|
||||
上线前使用固定快照隔离重放和目标规模压测,上线后只运行新治理链路,异常时按完整版本回滚。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在不增加 PT 站点压力、不牺牲订阅完成正确性、不制造重复下载的前提下,解决以下问题:
|
||||
|
||||
1. 日常 `refresh -> match` 在订阅较多时线性变慢,即使还没有进入 24 小时兜底搜索也会影响用户;
|
||||
2. 24 小时兜底搜索在全局锁内逐订阅等待和访问站点,放大日常 Match 的锁竞争;
|
||||
3. RSS、爬虫 Match、手工搜索和兜底搜索缺少统一的订阅级 single-flight、提交幂等与取消终态;
|
||||
4. 用户看不到排队、匹配、搜索、下载提交、失败和取消的业务状态,容易把后台失败理解为操作成功或订阅消失。
|
||||
|
||||
最终用户价值不是单纯缩短某个函数耗时,而是:日常资源到达后尽快完成匹配,兜底搜索不会阻塞日常
|
||||
Match,同一资源不会因链路重叠重复提交,慢站点或 TMDB 故障不会拖死整轮,用户能够看到真实进度和失败。
|
||||
|
||||
目标常规规模明确为:`100–200` 条活跃订阅、`10–20` 个启用站点、单轮 `1000+` RSS/Spider 候选。
|
||||
达到该规模时不得依赖跨刷新周期漏处理候选来缩短表面耗时,最终验收仍以正确性、站点压力和用户完成时间
|
||||
共同决定,而不是只满足单个吞吐数字。
|
||||
|
||||
## 2. 边界
|
||||
|
||||
本 Goal 只按单媒体服务器场景设计和验收,不扩建多媒体服务器并发、聚合或 provider 分片。
|
||||
|
||||
本 Goal 不做以下事情:
|
||||
|
||||
- 不把 `match()` 简单改成 `async def`,也不以删除锁作为完成标准;
|
||||
- 不通过跨刷新周期轮流处理订阅来隐藏耗时,因为这会漏掉短生命周期候选;
|
||||
- 不把所有订阅或站点请求直接并发化;
|
||||
- 不把 TMDB 详情长期缓存当成性能修复;
|
||||
- 不在当前叶处理多条订阅记录指向同一媒体的产品规则;
|
||||
- 不重构通用 Scheduler、TaskRegistry 或媒体服务器框架。
|
||||
|
||||
同步实现可以继续位于受控 worker 内,但网络、数据库和文件 I/O 不得阻塞主事件循环。订阅完成、下载事实、
|
||||
洗版优先级和缺集口径继续只有一套 canonical 业务实现。
|
||||
|
||||
## 3. 已确认事实
|
||||
|
||||
### 3.1 日常路径
|
||||
|
||||
当前日常流程为:
|
||||
|
||||
```text
|
||||
Scheduler / 手工刷新
|
||||
|
|
||||
v
|
||||
TorrentsChain.refresh()
|
||||
RSS: 每站点读取一次 RSS
|
||||
Spider: 每站点最多抓取两页
|
||||
对新种子做媒体识别并写入本地缓存
|
||||
|
|
||||
v
|
||||
SubscribeChain.match()
|
||||
获取 search/match 共用类级锁
|
||||
读取所有 R/P 订阅
|
||||
每条订阅重新识别目标媒体
|
||||
每条订阅查询媒体库缺失情况
|
||||
每条订阅扫描本轮返回的完整候选缓存
|
||||
匹配后下载、完成判定和状态写回
|
||||
|
|
||||
v
|
||||
释放类级锁
|
||||
```
|
||||
|
||||
生产 V2 `2.15.5` 的少量订阅样本显示:
|
||||
|
||||
| 指标 | 中位数 | 解释 |
|
||||
| --- | ---: | --- |
|
||||
| 单轮日常 Match | 约 `9.931s` | 不含 24 小时兜底搜索 |
|
||||
| 单条订阅 | 约 `395ms` | 当前样本订阅数量较少 |
|
||||
| 媒体识别与媒体库检查 | 约 `357ms` | 是当前单条订阅主要耗时 |
|
||||
| 扫描约 600 条候选 | 约 `37ms` | 当前样本不是主要热点,但复杂度仍为订阅数乘候选数 |
|
||||
|
||||
这些数据来自生产只读日志,只能证明当前样本的热点分布,不能直接外推大量订阅的收益比例。
|
||||
|
||||
2026-08-24 对 NAS 上 V2 `2.15.5` 的 11 轮连续生产日志再次做了只读聚合:每轮约 12 条订阅、5 个
|
||||
候选域、500 条候选,Match 中位数约 `17.752s`,范围约 `11.049–29.200s`;单订阅相邻日志跨度的
|
||||
中位数约 `0.4s`,每轮 TMDB 查询日志为 `12–63` 次。该样本进一步确认日常 Match 已有明显波动,
|
||||
并且候选重识别会放大外部请求;日志计数只作为 001A 基线,不替代后续同 revision 固定快照隔离重放。
|
||||
|
||||
### 3.2 调度与站点访问
|
||||
|
||||
| 来源 | 默认调度 | 单轮站点访问模型 | 与订阅数量关系 |
|
||||
| --- | --- | --- | --- |
|
||||
| RSS 刷新 | 默认每 30 分钟 | 每个启用站点读取一次 RSS | 不随订阅数线性增加 |
|
||||
| Spider 刷新 | 每天随机 32 次 | 每个启用站点最多抓取两页 | 不随订阅数线性增加 |
|
||||
| 日常 Match | 紧随 RSS/Spider 刷新 | 候选匹配主要在本地;命中后可能获取种子并提交下载 | 计算和 TMDB/媒体库查询随订阅数增加 |
|
||||
| 新增订阅搜索 | 每 5 分钟检查 | 仅处理新建订阅,调用站点搜索 | 随新增订阅增加 |
|
||||
| R/P 兜底搜索 | 可配置,默认间隔 24 小时且默认关闭 | 每条订阅搜索多个站点;不同关键词间再等待 1–10 秒 | 随订阅、站点和关键词近似相乘 |
|
||||
|
||||
站点搜索当前会并发访问不同站点,同一站点按页顺序请求。60–300 秒等待位于兜底搜索的逐订阅循环内,
|
||||
并且整个搜索持有与 Match 共用的类级锁。该等待既是跨用户错峰手段,也是线性累计耗时和锁竞争来源;
|
||||
不能只删除等待,也不能继续让它占用业务锁。
|
||||
|
||||
### 3.3 TMDB 缓存语义
|
||||
|
||||
日常 Match 的 `cache=False` 会同时绕过媒体识别映射缓存和 TMDB HTTP 响应缓存。它不是单纯的性能疏漏:
|
||||
Match 会在完成前用新鲜季集数据修正总集数。若直接切为最长可达 24/72 小时的普通缓存,可能把仍在更新的
|
||||
12/13 集剧集错误判定完成并删除订阅。
|
||||
|
||||
因此当前未提交候选中的 `cache=True` 不可交付,必须撤销。可优化的是“本轮怎样取得一次可信的新鲜事实”,
|
||||
而不是“是否长期复用旧详情”。
|
||||
|
||||
### 3.4 当前结构性问题
|
||||
|
||||
1. 资源刷新只把新种子追加到缓存,但随后把完整缓存交给 Match;旧候选会在每轮被重复扫描;
|
||||
2. Match 先为所有订阅执行 TMDB 和媒体库检查,之后才知道本轮候选是否可能命中;
|
||||
3. 订阅数 `S` 与候选数 `C` 的核心循环近似 `O(S * C)`;
|
||||
4. 候选获取、资源匹配、完成对账、下载提交和状态写回被包在同一个长锁生命周期;
|
||||
5. 兜底搜索在锁内执行随机等待和站点网络 I/O;
|
||||
6. 不同入口缺少统一订阅级 single-flight,提交前也没有明确的跨入口幂等合同;
|
||||
7. 停止只能在订阅或关键词边界观察,不能可靠中断随机等待和正在执行的同步网络调用。
|
||||
|
||||
## 4. 不变量
|
||||
|
||||
任何方案必须同时保持:
|
||||
|
||||
1. RSS 和 Spider 的默认站点请求频率不得增加;
|
||||
2. 兜底搜索的峰值每站点并发不得高于基线,出现 `429/403` 或超时时应降低压力而不是重试放大;
|
||||
3. 每轮需要完成判定时必须使用足够新鲜的季集事实,不能因缓存错误完成或删除订阅;
|
||||
4. 下载前必须重新读取订阅、缺集、下载历史和洗版优先级,准备阶段的快照不能直接作为提交依据;
|
||||
5. 同一订阅、同一 torrent、同一季集覆盖在入口重叠时至多提交一次;
|
||||
6. 失败、取消、停机和锁等待失败不能写入成功或完成状态;
|
||||
7. 一条订阅失败不得阻止批次中其他订阅继续,但最终批次必须暴露聚合失败;
|
||||
8. 手工刷新可以优先,但不能绕过站点预算和下载幂等;
|
||||
9. 音乐订阅在缺少独立准备/提交合同前保持串行;
|
||||
10. 所有优化都必须能独立关闭或回滚到稳定 V3 行为。
|
||||
|
||||
## 5. 方案评估
|
||||
|
||||
### 5.1 方案矩阵
|
||||
|
||||
| 方案 | 预期收益 | 站点压力 | 正确性/维护风险 | 决策 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 保持全量串行,只增加日志 | 无直接性能收益 | 不变 | 用户问题继续存在 | 仅作为基线 |
|
||||
| Match 全部改用 `cache=True` | 当前样本可能减少约 0.2–0.35s/订阅 | 不变 | 可能错误完成正在更新的剧集 | 拒绝 |
|
||||
| 全部订阅直接开 2/4 worker | 可缩短 TMDB/媒体库等待 | Match 本身不直接增加 PT 请求,但可能放大 TMDB、媒体服务器和下载并发 | 线程安全、完成竞态、重复下载 | 不作为首选,只在前置治理后做隔离压测 |
|
||||
| 候选建立媒体身份/站点/季索引 | 降低 `S * C` 本地扫描 | 不变 | 自定义词和标题兜底不能被粗索引误删 | 建议,先作为无损前置门禁 |
|
||||
| 仅匹配本轮新增候选 | 显著减少重复扫描 | 不变 | 临时失败、后建订阅和旧候选重试可能丢失 | 不能单独采用,需 pending/replay 合同 |
|
||||
| 新增候选优先,旧候选按有界重试账本回放 | 降低常态扫描并保留失败恢复 | 不变 | 需要候选身份、终态和 TTL | 推荐目标方案 |
|
||||
| 先候选门禁,再查 TMDB/媒体库 | 没有相关候选时跳过主要热点 | 不变 | 完成对账不能随候选一起跳过 | 推荐,但必须拆出独立完成对账 |
|
||||
| 单轮新鲜事实租约 | 同媒体在一轮只请求一次,不跨轮复用旧详情 | 不变 | 同媒体不同 episode group/季必须进入 key | 推荐 |
|
||||
| 稳定元数据复用 + 轻量季集查询 | 可能替代每条完整详情请求 | 不变 | TMDB 返回合同、地区词和 episode group 需验证 | 优先探针,收益成立再实现 |
|
||||
| 单媒体服务器单轮请求合并 | 重复目标只查一次媒体库 | 不变 | 不同订阅范围、洗版规则不能共用最终缺集结果 | 可做事实层合并,不缓存业务判定 |
|
||||
| 同轮有界 2/4 worker 准备 | 隐藏独立 TMDB/媒体库等待 | 不变 | 共享缓存和同步客户端线程安全;提交顺序变化 | 候选门禁后仍慢时再启用 |
|
||||
| 跨刷新周期分片订阅 | 每轮变快 | 不变 | 候选可能在轮到订阅前过期,延迟不可控 | 拒绝作为默认方案 |
|
||||
| 按候选分片,同轮处理完 | 可复用候选索引,适合候选远少于订阅 | 不变 | 标题兜底和自定义词需保守回退 | 推荐的内部执行形态 |
|
||||
| 兜底搜索批次只随机等待一次 | 去掉按订阅线性累计 | 峰值可能集中 | 大量实例在相近时间同时访问站点 | 不能单独采用 |
|
||||
| 兜底搜索持久游标 + 每站点预算 | 不占长锁,按容量持续推进 | 可明确限制且具备退避 | 需要批次身份、恢复和公平性 | 推荐 |
|
||||
| RSS/Spider 站点并发刷新 | 缩短站点获取阶段 | 提高瞬时站点压力 | PT 站点承载和风控不确定 | 默认不做 |
|
||||
| 全事件驱动替代定时刷新 | 长期可减少无效工作 | 取决于上游能力 | 改动大,历史插件/站点无统一事件源 | 远期,不进入首轮 |
|
||||
|
||||
### 5.2 推荐架构
|
||||
|
||||
推荐把现有长函数拆成三个业务阶段,但不要求一次 PR 完成重构:
|
||||
|
||||
```text
|
||||
RSS / Spider / 兜底搜索
|
||||
|
|
||||
v
|
||||
候选账本与本轮 delta
|
||||
- torrent 身份、来源、站点、首次/最后出现时间
|
||||
- pending / matched / rejected / retryable / expired
|
||||
|
|
||||
v
|
||||
无损候选索引与受影响订阅集合
|
||||
- 媒体 ID 可确定时直接反向定位
|
||||
- 标题兜底、自定义识别词和旧缓存进入保守集合
|
||||
|
|
||||
v
|
||||
准备阶段(可取消,可选 2/4 有界 worker)
|
||||
- 本轮新鲜 TMDB 事实租约
|
||||
- 单媒体服务器事实查询
|
||||
- 规则匹配和下载计划
|
||||
|
|
||||
v
|
||||
串行提交阶段
|
||||
- 重读订阅和下载事实
|
||||
- 幂等校验
|
||||
- 提交下载
|
||||
- 写入进度、优先级和完成状态
|
||||
```
|
||||
|
||||
完成对账必须是独立路径:候选门禁可以决定“本轮是否值得做资源匹配”,但不能决定“订阅是否已经完成”。
|
||||
建议将完成对账按较低频率或在媒体库/元数据变化事件后执行;它仍使用新鲜季集事实,但不扫描站点候选。
|
||||
|
||||
### 5.3 站点压力治理
|
||||
|
||||
RSS/Spider 保持现有逐站点串行刷新,不通过并发化换取几秒收益。兜底搜索从“订阅级随机休眠”改为“站点级
|
||||
容量预算”:
|
||||
|
||||
- 每个站点最多 1 个在途搜索;
|
||||
- 同一站点请求之间保留随机间隔,具体区间通过离线压测和受控生产运行指标确定,不在设计阶段拍定;
|
||||
- 批次启动保留全局随机抖动,避免大量 MoviePilot 实例整点同时启动;
|
||||
- `429/403`、登录失效和连续超时触发站点级冷却,不让其他订阅立即重试同一站点;
|
||||
- 手工搜索可以提高队列优先级,但不能绕过站点并发和冷却;
|
||||
- 站点预算由订阅域消费,首轮不扩建成全应用通用限流平台。
|
||||
|
||||
该设计让等待不再持有 Match/提交锁,同时比“每条订阅固定睡眠”更直接地约束真实外部请求。
|
||||
|
||||
### 5.4 下载防重与锁边界
|
||||
|
||||
锁应按事实所有权拆分,而不是按入口拆分:
|
||||
|
||||
1. 候选获取不持有订阅提交锁;
|
||||
2. TMDB、媒体服务器和站点网络请求不持有提交锁;
|
||||
3. 同一订阅的准备任务使用 single-flight 合并;
|
||||
4. 下载提交使用短临界区,并在临界区内重读当前订阅、下载历史、媒体库缺失和洗版优先级;
|
||||
5. 幂等键至少包含订阅逻辑身份、torrent 身份和计划覆盖的季集范围;
|
||||
6. 下载器接受但本地写回失败属于“不确定”终态,不能自动重试造成重复任务,必须先对账下载器。
|
||||
|
||||
同媒体的多条重复订阅需要更高一层的跨记录幂等,保留在最低优先级叶,不阻断单条订阅治理。
|
||||
|
||||
## 6. 交付 Goal 树
|
||||
|
||||
| ID | 交付边界 | 依赖 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `SUB-GOV-000` | 固定历史行为和生产日志;保留 `V3-RDY-009A1A` 已交付的批量请求正确性 | 无 | `completed` |
|
||||
| `SUB-GOV-001A` | 建立日常 Match 正确性和可重放夹具;撤销 `cache=True` 错误候选,只保留经验证不改变语义的失败识别状态回写 | 000 | `completed(2026-09-01)` |
|
||||
| `SUB-GOV-001B` | 区分完整缓存与本轮 delta,建立无损候选索引;先证明命中集合与基线完全一致 | 001A | `in_progress` |
|
||||
| `SUB-GOV-001C` | 将资源匹配与完成对账拆开;只有受候选影响的订阅进入匹配,完成对账独立保持新鲜语义 | 001B | `pending` |
|
||||
| `SUB-GOV-001D` | 引入单轮新鲜事实租约,评估稳定元数据复用、轻量季集查询和单媒体服务器事实合并 | 001C | `pending` |
|
||||
| `SUB-GOV-001E` | 若 001B–D 后仍有可重复等待热点,再通过隔离压测评估 2/4 worker 的有界准备;提交保持串行 | 001D | `conditional` |
|
||||
| `SUB-GOV-002A` | 将 24 小时兜底搜索移出 Match/提交长锁,建立批次、订阅 single-flight、可取消等待和恢复游标 | 001C | `pending` |
|
||||
| `SUB-GOV-002B` | 为兜底搜索建立每站点并发、间隔和错误冷却预算;RSS/Spider 保持基线压力 | 002A | `pending` |
|
||||
| `SUB-GOV-002C` | 联合运行日常 Match、手工搜索和兜底搜索,验证公平性、锁等待、站点压力和失败恢复 | 001D, 002B | `pending` |
|
||||
| `SUB-GOV-003A` | 建立跨入口的订阅级下载幂等、下载器不确定终态和取消补偿 | 001C, 002A | `pending` |
|
||||
| `SUB-GOV-003B` | 后端提供订阅及批次业务状态,前端展示排队、匹配、搜索、提交、完成、失败和取消 | 002A, 003A | `pending` |
|
||||
| `SUB-GOV-004` | 多条订阅记录指向同一媒体时的跨记录季集去重和产品规则 | 003A | `pending(最低优先级)` |
|
||||
|
||||
当前只激活 `SUB-GOV-001B`。001B–D 是日常 Match 主线;002A–C 是 24 小时兜底搜索主线;003A/B
|
||||
只有在前两条链路的身份和终态稳定后实施。每个叶子完成验收并更新本表后,才激活下一个满足依赖的叶子。
|
||||
|
||||
### 6.1 SUB-GOV-001A 验收证据
|
||||
|
||||
- 基线:MoviePilot `v3` 的 `f5c73dcab`;原 handoff 提交 `d923037f9` 未进入当前仓库历史;
|
||||
- 固定输入:`tests/fixtures/subscription_governance_match_replay.json`;
|
||||
- 重放入口:`tests/test_subscription_governance_replay.py` 从真实 `SubscribeChain.match()` 进入;
|
||||
- 合同:日常 Match 明确传入 `cache=False`;12 集事实保持完成,12→13 集时写入
|
||||
`total_episode=13`、`lack_episode=1` 且不完成订阅;
|
||||
- 当前 `match()` 没有把失败识别次数写回 RSS/Spider 缓存,因此本叶未引入未经验证的缓存覆盖;
|
||||
- 验证:上述重放、现有订阅 Chain 与候选筛选共 `118 passed`,固定 JSON、文档链接和
|
||||
`git diff --check` 通过。
|
||||
|
||||
## 7. 上线前验证与验收
|
||||
|
||||
### 7.1 场景
|
||||
|
||||
每个行为叶必须使用同一 revision、同一固定数据快照:先由当前 canonical 逻辑生成参考集合,再在隔离环境中
|
||||
只运行新链路并比较结果。该流程不进入生产,也不产生真实外部请求和下载副作用。
|
||||
|
||||
1. RSS,无新候选;
|
||||
2. RSS,少量新候选且不命中;
|
||||
3. RSS,新候选命中电影、追更剧集、全集和分集洗版;
|
||||
4. Spider,同样三档候选;
|
||||
5. 24 小时兜底搜索无结果、部分结果、站点超时和 `429/403`;
|
||||
6. 日常 Match 与兜底搜索重叠;
|
||||
7. 手工刷新或手工搜索与后台任务重叠;
|
||||
8. 下载器已接受但本地写回失败;
|
||||
9. 停机、取消和进程重启后的恢复;
|
||||
10. 单媒体服务器可用、慢响应和不可用。
|
||||
|
||||
订阅规模使用真实形态的小/中/大档,不以固定的 1/10/101 档位作为治理要求;候选数量与站点数量也要
|
||||
独立缩放,避免只测订阅数量。
|
||||
|
||||
### 7.2 指标
|
||||
|
||||
| 维度 | 必测指标 |
|
||||
| --- | --- |
|
||||
| 用户耗时 | 刷新总耗时、首个匹配/首个下载时间、批次完成 p50/p95 |
|
||||
| 本地计算 | 候选预处理、每订阅匹配、完成对账、提交耗时 |
|
||||
| TMDB | 请求数、缓存/租约命中、错误率、p50/p95 |
|
||||
| 媒体服务器 | 请求数、p50/p95、超时和合并命中 |
|
||||
| PT 站点 | 按站点请求数、峰值在途、最小间隔、429/403/超时、重试数 |
|
||||
| 正确性 | 命中集合、下载集合、缺集集合、完成/删除集合与基线一致 |
|
||||
| 幂等 | 重复提交数、下载器不确定终态、恢复后重复任务数 |
|
||||
| 生命周期 | 取消延迟、event-loop lag、遗留 task/worker/锁、shutdown 上限 |
|
||||
|
||||
### 7.3 门禁
|
||||
|
||||
- 日常 Match 的站点请求数和峰值不得增加;
|
||||
- 兜底搜索的每站点峰值在途不得超过基线,分布式错峰不能退化为整点突发;
|
||||
- 所有正确性集合必须与基线一致,新增完成对账用例必须覆盖 12/13 集更新;
|
||||
- 重叠入口不得产生重复下载;
|
||||
- 候选在无外部错误时不得出现可重复性能退化;
|
||||
- `100–200` 条订阅、`10–20` 个站点和单轮 `1000+` 候选必须进入最终受控样本矩阵,且日常 Match
|
||||
不得因 `S * C` 全量扫描或逐订阅重复外部查询而失去可用性;
|
||||
- 性能收益不足以覆盖维护成本时停止对应叶,保留更小方案;
|
||||
- 本地开发数据只验证可重放性,最终性能结论必须来自受控样本或生产只读证据。
|
||||
|
||||
### 7.4 生产发布边界
|
||||
|
||||
- 不采用 shadow A/B、baseline/shadow/active 三模式或旧新双跑;
|
||||
- 上线后只有新治理链路运行和写入状态;
|
||||
- 生产运行指标只用于确认新链路健康,不承担旧新结果对照;
|
||||
- 不单独交付文本日志聚合器;
|
||||
- 异常时按完整镜像/版本回滚,不长期保留运行时旧链路 fallback。
|
||||
|
||||
## 8. 当前代码与 checkpoint 边界
|
||||
|
||||
原交接环境形成过一个可复用的正确性 checkpoint:
|
||||
|
||||
- `d923037f9 test(subscribe): guard fresh match metadata` 固定日常 Match 的 `cache=False` 和 12→13 集增长语义;
|
||||
- focused pytest、Pylint 和 `git diff --check` 在该提交形成时通过。
|
||||
|
||||
该提交不在当前 MoviePilot `v3` 仓库历史中,只作为 `SUB-GOV-001A` 的测试设计参考。当前分支必须重新
|
||||
建立等价夹具并产生本仓可验证的提交证据。
|
||||
|
||||
其余旧候选仍不构成交付:
|
||||
|
||||
- `match()` 改为 `cache=True` 已被完成语义证据否决,必须撤销;
|
||||
- 失败候选识别次数写回本地缓存可能避免每轮重复识别,但仍需证明不会覆盖音乐缓存、站点过滤结果或
|
||||
Spider/RSS 对应文件;
|
||||
- 现有测试只证明了参数或局部调用,没有证明 TMDB 请求减少、订阅完成正确、站点压力不变和下载集合一致;
|
||||
- 旧 checkpoint `c2d72280e` 与 10/100/500 数据只覆盖兜底搜索等待,不再是 A1B 的交付候选。
|
||||
|
||||
候选回写语义、完整缓存与本轮 delta、无损索引和真实性测试仍需由后续实现证明。治理文档现位于
|
||||
`docs/refactor/` 并属于后端 Git 仓;文档状态与代码提交证据仍分别记录,不能用路线图状态替代行为验收。
|
||||
|
||||
## 9. 停止条件
|
||||
|
||||
满足以下条件后,本 Goal 完成:
|
||||
|
||||
1. 日常 RSS/Spider Match 在目标样本下达到可接受完成时间,且 PT 请求压力不增加;
|
||||
2. 24 小时兜底搜索不再持有 Match/提交长锁,按站点预算运行并能取消、恢复;
|
||||
3. 日常、手工和兜底链路联合运行无重复下载和错误完成;
|
||||
4. 用户可以看到真实业务状态和失败终态;
|
||||
5. 所有保留的并发和缓存都有可重放验证、版本级回滚路径和明确 owner;
|
||||
6. 后续只剩最低优先级的重复订阅产品规则或无生产证据的推测项。
|
||||
20
tests/fixtures/subscription_governance_match_replay.json
vendored
Normal file
20
tests/fixtures/subscription_governance_match_replay.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"name": "stable_12_episodes",
|
||||
"initial_total_episode": 12,
|
||||
"initial_lack_episode": 0,
|
||||
"fresh_total_episode": 12,
|
||||
"expected_total_episode": 12,
|
||||
"expected_lack_episode": 0,
|
||||
"expected_completed": true
|
||||
},
|
||||
{
|
||||
"name": "expanded_to_13_episodes",
|
||||
"initial_total_episode": 12,
|
||||
"initial_lack_episode": 0,
|
||||
"fresh_total_episode": 13,
|
||||
"expected_total_episode": 13,
|
||||
"expected_lack_episode": 1,
|
||||
"expected_completed": false
|
||||
}
|
||||
]
|
||||
178
tests/test_subscription_governance_replay.py
Normal file
178
tests/test_subscription_governance_replay.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""订阅执行治理固定快照重放。"""
|
||||
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.subscription.contract import SubscriptionSnapshot
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.domain.context import Context, MediaInfo, TorrentInfo
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.schemas.mediaserver import NotExistMediaInfo
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "subscription_governance_match_replay.json"
|
||||
|
||||
|
||||
class _ReplaySubscriptionRepository:
|
||||
"""保存单条订阅快照,供 Match 重放读取更新后的事实。"""
|
||||
|
||||
def __init__(self, subscribe: SubscriptionSnapshot) -> None:
|
||||
self.current = subscribe
|
||||
|
||||
def list(self, _state: str = None) -> list[SubscriptionSnapshot]:
|
||||
"""返回本轮唯一订阅。"""
|
||||
return [self.current]
|
||||
|
||||
def get(self, _subscribe_id: int) -> SubscriptionSnapshot:
|
||||
"""返回当前已更新快照。"""
|
||||
return self.current
|
||||
|
||||
def update(self, update_data: dict) -> SubscriptionSnapshot:
|
||||
"""应用测试内的订阅字段更新。"""
|
||||
self.current = replace(self.current, **update_data)
|
||||
return self.current
|
||||
|
||||
|
||||
class _ReplayTorrentHelper:
|
||||
"""让无关候选稳定停在身份冲突边界。"""
|
||||
|
||||
@staticmethod
|
||||
def match_torrent(**_kwargs) -> bool:
|
||||
"""无关候选不得通过标题复核。"""
|
||||
return False
|
||||
|
||||
def filter_torrent(self, **_kwargs) -> bool:
|
||||
"""候选若意外进入过滤阶段则保持可观察。"""
|
||||
return True
|
||||
|
||||
|
||||
def _load_replay_cases() -> list[dict]:
|
||||
"""读取版本化固定输入快照。"""
|
||||
return json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _build_subscribe(case: dict) -> SubscriptionSnapshot:
|
||||
"""按固定快照构造已下载 12 集的普通电视剧订阅。"""
|
||||
return SubscriptionSnapshot(
|
||||
id=101,
|
||||
name="增长中的剧集",
|
||||
year="2026",
|
||||
type=MediaType.TV.value,
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="100",
|
||||
season=1,
|
||||
total_episode=case["initial_total_episode"],
|
||||
start_episode=1,
|
||||
lack_episode=case["initial_lack_episode"],
|
||||
note=list(range(1, 13)),
|
||||
state="R",
|
||||
sites=[],
|
||||
best_version=0,
|
||||
manual_total_episode=0,
|
||||
)
|
||||
|
||||
|
||||
def _build_unrelated_candidate() -> Context:
|
||||
"""构造不会命中目标订阅但可触发完整 Match 流程的候选。"""
|
||||
return Context(
|
||||
meta_info=MetaInfo(title="无关剧集 S01E01"),
|
||||
media_info=MediaInfo(
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="999",
|
||||
type=MediaType.TV,
|
||||
title="无关剧集",
|
||||
year="2026",
|
||||
),
|
||||
torrent_info=TorrentInfo(
|
||||
title="无关剧集 S01E01",
|
||||
site=1,
|
||||
site_name="ReplaySite",
|
||||
pri_order=100,
|
||||
),
|
||||
resource_source="rss",
|
||||
match_source=MediaSource.TMDB.value,
|
||||
candidate_recognized=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _load_replay_cases(), ids=lambda case: case["name"])
|
||||
def test_match_replays_fresh_episode_completion_contract(case, monkeypatch):
|
||||
"""日常 Match 必须用本轮新鲜季集事实决定 12 集完成或 13 集继续订阅。"""
|
||||
repository = _ReplaySubscriptionRepository(_build_subscribe(case))
|
||||
recognition_calls = []
|
||||
completions = []
|
||||
fresh_media = MediaInfo(
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="100",
|
||||
type=MediaType.TV,
|
||||
title="增长中的剧集",
|
||||
year="2026",
|
||||
seasons={1: list(range(1, case["fresh_total_episode"] + 1))},
|
||||
)
|
||||
|
||||
class _ReplayMediaChain:
|
||||
"""记录 Match 的媒体识别参数并返回固定新鲜事实。"""
|
||||
|
||||
def recognize_media(self, **kwargs) -> MediaInfo:
|
||||
"""返回当前场景的新鲜季集快照。"""
|
||||
recognition_calls.append(kwargs)
|
||||
return fresh_media
|
||||
|
||||
@staticmethod
|
||||
def recognize_by_meta(*_args, **_kwargs) -> MediaInfo:
|
||||
"""候选已有明确身份,本场景不应重新识别。"""
|
||||
raise AssertionError("明确身份候选不应重新识别")
|
||||
|
||||
@staticmethod
|
||||
def supplement_media_info(mediainfo: MediaInfo) -> MediaInfo:
|
||||
"""保持固定媒体事实,不引入其他来源。"""
|
||||
return mediainfo
|
||||
|
||||
chain = SubscribeChain()
|
||||
chain.subscription_repository = repository
|
||||
chain._SubscribeChain__apply_subscribe_update = (
|
||||
lambda _subscribe, update_data, **_kwargs: repository.update(dict(update_data))
|
||||
)
|
||||
chain._SubscribeChain__finish_subscribe = (
|
||||
lambda subscribe, **_kwargs: completions.append(subscribe)
|
||||
)
|
||||
|
||||
def _resolve_missing(*, subscribe, mediakey, **_kwargs):
|
||||
"""按当前总集数重放媒体库缺失事实。"""
|
||||
if subscribe.total_episode == 12:
|
||||
return True, {}
|
||||
return False, {
|
||||
mediakey: {
|
||||
1: NotExistMediaInfo(
|
||||
season=1,
|
||||
episodes=[13],
|
||||
total_episode=13,
|
||||
start_episode=1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
chain.resolve_subscribe_missing = _resolve_missing
|
||||
monkeypatch.setattr("app.chain.subscribe.match.MediaChain", _ReplayMediaChain)
|
||||
monkeypatch.setattr("app.chain.subscribe.match.TorrentHelper", _ReplayTorrentHelper)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.subscribe.match.get_configured_system_config",
|
||||
lambda: SimpleNamespace(get=lambda _key: []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.subscribe.query.get_configured_system_config",
|
||||
lambda: SimpleNamespace(get=lambda _key: []),
|
||||
)
|
||||
|
||||
chain.match({"replay.example": [_build_unrelated_candidate()]})
|
||||
|
||||
assert len(recognition_calls) == 1
|
||||
assert recognition_calls[0]["cache"] is False
|
||||
assert repository.current.total_episode == case["expected_total_episode"]
|
||||
assert repository.current.lack_episode == case["expected_lack_episode"]
|
||||
assert bool(completions) is case["expected_completed"]
|
||||
Reference in New Issue
Block a user