23 KiB
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 为空并发失败通知 |
关键分支(按执行顺序):
- 构造上下文
__build_subscribe_create_context:media_source/media_id 只给一个 → 直接拒绝;给了 season 则强制type=TV。 - 识别
__recognize_subscribe_media:有显式身份按身份识别(cache=False);显式身份识别失败不允许退回按标题识别(防串台)。非 TMDB 来源时用 MetaInfo 清洗标题,并从标题补季号。 - 季集补全
__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(创建时不查媒体库,真实缺集在首次搜索时刷新)。
- 非 TV →
- 默认值
__get_default_kwargs(create.py:32):按媒体类型读DefaultTv/Movie/MusicSubscribeConfig,逐字段"订阅值优先,空则默认";best_version用is None判空(显式 0 不被默认覆盖)。 - 持久化
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)
- 用订阅
start_episode/total_episode覆盖媒体库结果:整季缺失且订阅起始集 > 原起始集 → 转成显式集列表(否则空列表会被下载层当整季);部分缺失 → 与[max(start,原start), total]取交集,交集空 → 视为全部已有(True,{})。 - 减去已下载集(
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):
- 新增 < 60 秒的订阅跳过(
_defer_recent_subscription,给用户改过滤条件的窗口)。 - 定时的 R/P 批量搜索每条之间随机休眠 60–300 秒(
_wait_before_scheduled_search),手动/定向不等。 - 重新识别媒体(
cache=False)→check_and_handle_existing_media(query.py:488):先__refresh_total_episode_before_completion修正总集数,再resolve_subscribe_missing;已齐 →finish_subscribe_or_not(force=True)并结束。 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)。- 无结果 / 过滤后无候选 → 仍调
finish_subscribe_or_not(lefts=no_exists)刷新 lack_episode。 _filter_search_contexts(search.py:286):洗版剧集依次过 全集资源约束 → 集范围覆盖 →prepare_best_version_tv_candidate(设置allowed_episodes);洗版电影pri_order <= current_priority拒绝。- 下载
__download_best_version_with_full_pack_first(policy.py:261)→ 重读订阅 →finish_subscribe_or_not(downloads, lefts)。 - 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_sizefilter: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_episodepriority.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)
- 有下载:剧集 →
__record_subscribe_download_facts;电影 → 写 note[1]并current_priority = max(本轮 pri)(__update_movie_download_priority)。 - 普通订阅:剧集先用 lefts 刷
lack_episode;完成条件 = (剧集且 lefts 为空)或(电影且本轮有下载)或 force。 - 洗版:剧集刷进度(分集模式派生 current_priority);
is_best_version_complete为真才完成,否则继续洗版。 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)与季包:
- 候选先排序(站点优先级/pri_order/做种等),失败的候选有冷却,同组继续试下一条。
- 整季匹配:整季缺失(
episodes=[])时找无集号的单季包;只一季的包可能命名错误,打开种子核对实际集数 ≥ 总集数;require_complete_coverage时须覆盖范围内每一集。 - 季内集匹配:单季含集的种子,集合须是所需集子集(完整覆盖任务改为须覆盖目标范围);受
allowed_episodes裁剪。 - 从整季包选集(仅 qB/TR):仍缺的集到整季包里按文件选集下载(
selected_episodes)。 - 每次成功下载后更新剩余缺集 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)
- 普通剧集:12 集,库 1–4,在途 5–6,RSS 来 E07 单集 → 下 E07;来 S01 整季包 → 从包里只选 7–12(或整包,按 packPolicy)。
- start_episode=3:整季缺失时生成 3–12 显式集,不下整季。
- 分集洗版:episode_priority
{1:90,2:100},来 pri=95 的 E01-E02 合集 → 只 E01 感兴趣。 - 分集洗版整包优先:整包 pri=100,目标集 pri 最低 90 → 走整包;若某集已是 100 → 回退按集。
- 全集洗版:总集数 10→12 → 基线清 0 重新找整包。
- 电影洗版:current=98,来 98 → 拒绝;来 99 → 下载,完成条件未达继续。
- RSS 标题兜底:无年份别名命中两部同名剧 → 拒绝。
- 种子 ID 与订阅 ID 冲突但标题复核 + 同作品证据通过 → 改判为目标。
- TMDB 回退:12→10,库 1–11 → total=11,不完成。
- P 状态缺集为空 → 不完成;S 状态 → RSS 不匹配。
- 规则组
A > B > C:命中 B → pri=99;都不命中 → 过滤。 - 规则 size_range 500-3000MB,10 集 20GB 包 → 每集 2GB 通过。