Files
UHD_Media_Tool/docs/porting/moviepilot-subscribe.md
T
2026-09-27 22:36:17 +08:00

259 lines
23 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.
# 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 通过。