# 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 通过。