Files
UHD_Media_Tool/docs/porting/moviepilot-subscribe.md
rootandClaude Opus 5.5 b343dddbf6 docs: 清理过程文档,CHANGELOG 补齐 0.3.8–0.3.14
- 删除 docs/plan 下 00–18 的计划、评审、验收、发布记录与升级证据,docs/spikes 与 spikes 验证代码
  (保留 spikes/movieclaw-src 本地克隆),以及 4 份一次性审查 / 调研;全部可在 ceb66f8 查到
- MP 对照移到 docs/porting/moviepilot-v3-comparison.md;经验教训改为 docs/plan/lessons.md(补第 25 条)
- docs/plan/README.md:文档放哪、当前状态、待办
- 引用改写:代码注释指向在维护的文档,其余注明已删除与 git 位置;清掉原有 6 个失效链接
- README 开头改为当前状态与入口;CHANGELOG 补齐 0.3.8–0.3.14 与工程变更

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 16:08:09 +08:00

23 KiB
Raw Permalink Blame History

MoviePilot 订阅业务逻辑:迁移对照记录

格式:REQ §14.1.2 迁移交付规范。只记录行为对照与 TS 重写建议,不复制 MP 源码(GPLv3,见 license-and-provenance.md)。 对应需求:REQ §5.4(本地集账本)、§5.5(正片完整性)、§6.1–6.8(Slot/升级/三模式/预算/选片追踪/偏好/优先级)。任务书:00-first-version-plan(已删除,见 git ceb66f8) §3.1 第 5 条。差距与实施清单见 05-subscription-gap(已删除,见 git ceb66f8)。

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