From d9a42c672a6b252040edb6529e143563cd7cee7b Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:02:19 +0800 Subject: [PATCH] fix(subscribe): refine TV wash priority lifecycle (#6153) --- app/chain/subscribe.py | 180 +++++++++++++------- app/schemas/subscribe.py | 27 +-- docs/subscribe-lifecycle.md | 24 +-- tests/test_subscribe_chain.py | 308 +++++++++++++++++++++++++++++----- 4 files changed, 411 insertions(+), 128 deletions(-) diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 7b4b7d3c..b7337223 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -64,14 +64,15 @@ class SubscribeChain(ChainBase): 订阅链路同时服务电影、普通电视剧、分集洗版和全集洗版。普通电视剧订阅与 分集洗版共享按集事实:note 表示目标集已经存在或已经下载, - episode_priority 表示每集已知下载质量;二者可以互相切换。全集洗版关注 - 完整目标范围的整体质量,只有下载层确认整包完整覆盖目标范围后,才把资源 - 写成目标范围内的按集事实。 + episode_priority 表示每集已知下载质量;二者可以互相切换。current_priority + 表示当前洗版模式的资源准入基线:分集洗版按目标范围内的最低按集优先级派生, + 全集洗版由下载层确认完整覆盖后按整包优先级维护。 - 实现上保持三个入口分离:下载事实入口只写 note / episode_priority; - progress 刷新入口只把当前事实计算为 lack_episode 和电视剧洗版 - current_priority;完成入口只根据最终事实和完成策略收敛订阅状态。电影没有 - 按集事实,电影洗版的 current_priority 由电影下载优先级 writer 单独维护。 + 实现上保持三个入口分离:下载事实入口写 note / episode_priority,并在确认 + 全集覆盖时写全集 current_priority;progress 刷新入口计算 lack_episode,且仅 + 分集洗版从按集事实派生 current_priority;完成入口只根据当前模式的最终事实 + 和完成策略收敛订阅状态。电影没有按集事实,电影洗版的 current_priority 由 + 电影下载优先级 writer 单独维护。 """ _rlock = threading.RLock() @@ -108,7 +109,12 @@ class SubscribeChain(ChainBase): if episode_priority: return episode_priority - if subscribe.best_version and subscribe.type == MediaType.TV.value and subscribe.current_priority is not None: + if ( + subscribe.best_version + and not cls.__is_full_best_version_enabled(subscribe) + and subscribe.type == MediaType.TV.value + and subscribe.current_priority is not None + ): target_episodes = cls.__get_best_version_target_episodes(subscribe, total_episode=total_episode) return { str(episode): int(subscribe.current_priority) @@ -270,6 +276,8 @@ class SubscribeChain(ChainBase): """ if not subscribe.best_version or subscribe.type != MediaType.TV.value: return subscribe.current_priority or 0 + if cls.__is_full_best_version_enabled(subscribe): + return subscribe.current_priority or 0 target_episodes = cls.__get_best_version_target_episodes(subscribe) if not target_episodes: @@ -305,7 +313,11 @@ class SubscribeChain(ChainBase): subscribe, total_episode=old_total_episode, ) - if not episode_priority and subscribe.current_priority is not None: + if ( + not cls.__is_full_best_version_enabled(subscribe) + and not episode_priority + and subscribe.current_priority is not None + ): episode_priority = { str(episode): int(subscribe.current_priority) for episode in cls.__get_best_version_target_episodes( @@ -315,6 +327,9 @@ class SubscribeChain(ChainBase): } subscribe.episode_priority = episode_priority update_data["episode_priority"] = episode_priority + if cls.__is_full_best_version_enabled(subscribe): + subscribe.current_priority = 0 + update_data["current_priority"] = 0 update_data.update(cls.__prepare_subscribe_progress_fields(subscribe=subscribe, no_exists={})) return update_data @@ -348,10 +363,13 @@ class SubscribeChain(ChainBase): } subscribe.total_episode = total_episode subscribe.episode_priority = filtered_priority - current_priority = 0 if not target_episodes else cls.get_best_version_current_priority( - subscribe, - episode_priority=filtered_priority, - ) + if cls.__is_full_best_version_enabled(subscribe): + current_priority = 0 if total_episode > old_total_episode else subscribe.current_priority + else: + current_priority = 0 if not target_episodes else cls.get_best_version_current_priority( + subscribe, + episode_priority=filtered_priority, + ) subscribe.current_priority = current_priority update_data["episode_priority"] = filtered_priority update_data["current_priority"] = current_priority @@ -393,6 +411,8 @@ class SubscribeChain(ChainBase): return False if subscribe.type != MediaType.TV.value: return subscribe.current_priority == 100 + if cls.__is_full_best_version_enabled(subscribe): + return subscribe.current_priority == 100 target_episodes = cls.__get_best_version_target_episodes(subscribe) if not target_episodes: @@ -421,6 +441,8 @@ class SubscribeChain(ChainBase): return False if subscribe.type != MediaType.TV.value: return subscribe.current_priority == 100 + if cls.__is_full_best_version_enabled(subscribe): + return subscribe.current_priority == 100 target_episodes = cls.__get_best_version_target_episodes(subscribe) if not target_episodes: @@ -501,6 +523,34 @@ class SubscribeChain(ChainBase): interested.append(episode_num) return sorted(set(interested)) + @classmethod + def __prepare_best_version_tv_candidate( + cls, + subscribe: Subscribe, + context: Context, + priority: int, + ) -> bool: + """ + 校验电视剧洗版候选,并为分集模式设置允许下载的剧集范围。 + + 全集模式按当前准入基线筛选;分集模式设置能严格提升质量的目标集范围。 + """ + if cls.__is_full_best_version_enabled(subscribe): + try: + return int(priority or 0) > int(subscribe.current_priority or 0) + except (TypeError, ValueError): + return False + + interested_episodes = cls.__get_best_version_interested_episodes( + subscribe=subscribe, + context=context, + priority=priority, + ) + if not interested_episodes: + return False + context.allowed_episodes = set(interested_episodes) + return True + @classmethod def __is_full_best_version_enabled(cls, subscribe: Subscribe) -> bool: """ @@ -544,11 +594,20 @@ class SubscribeChain(ChainBase): return cls.__is_full_season_resource(meta=meta, subscribe=subscribe) @classmethod - def __is_full_season_priority_higher_than_all_targets(cls, subscribe: Subscribe, priority: int) -> bool: + def __should_prefer_full_pack_for_episode_best_version( + cls, + subscribe: Subscribe, + priority: int, + ) -> bool: """ - 判断整季资源优先级是否高于订阅目标范围的整体优先级门槛。 + 判断分集洗版是否应优先下载整包。 + + 整包优先级必须严格高于每个目标集;否则交回按集路径,只下载能提升质量的集。 """ - if subscribe.type != MediaType.TV.value: + if ( + subscribe.type != MediaType.TV.value + or cls.__is_full_best_version_enabled(subscribe) + ): return False target_episodes = cls.__get_best_version_target_episodes(subscribe) @@ -560,13 +619,11 @@ class SubscribeChain(ChainBase): except (TypeError, ValueError): resource_priority = 0 - try: - current_priority = int(subscribe.current_priority) if subscribe.current_priority is not None \ - else cls.get_best_version_current_priority(subscribe) - except (TypeError, ValueError): - current_priority = 0 - - return resource_priority > current_priority + episode_priority = cls.__get_episode_priority(subscribe) + return all( + resource_priority > episode_priority.get(str(episode), 0) + for episode in target_episodes + ) @classmethod def __build_full_pack_first_no_exists( @@ -622,28 +679,24 @@ class SubscribeChain(ChainBase): ] if full_pack_no_exists else [] target_episodes = self.__get_best_version_target_episodes(subscribe) target_range = f"{target_episodes[0]}-{target_episodes[-1]}" if target_episodes else "empty" - try: - current_priority_gate = int(subscribe.current_priority) if subscribe.current_priority is not None \ - else self.get_best_version_current_priority(subscribe) - except (TypeError, ValueError): - current_priority_gate = 0 + episode_priority_gate = self.__get_episode_priority(subscribe) full_pack_contexts = [] for context in full_season_contexts: - candidate_priority = getattr(context.torrent_info, "pri_order", 0) - accepted = self.__is_full_season_priority_higher_than_all_targets( + candidate_priority = context.torrent_info.pri_order + accepted = self.__should_prefer_full_pack_for_episode_best_version( subscribe=subscribe, priority=candidate_priority, ) logger.info( f"{subscribe.name} 整包候选优先级判断:candidate_priority={candidate_priority}," - f"current_priority={current_priority_gate},target_range={target_range}," + f"episode_priority={episode_priority_gate},target_range={target_range}," f"decision={'accept' if accepted else 'reject'}" ) if accepted: full_pack_contexts.append(context) if full_season_contexts and not full_pack_contexts: - logger.info(f"{subscribe.name} 全集候选优先级未高于 current_priority 门槛,回退到分集洗版") + logger.info(f"{subscribe.name} 全集候选优先级未高于全部目标集,回退到分集洗版") if full_pack_contexts: logger.info(f"{subscribe.name} 分集洗版优先尝试全集资源,共匹配到 {len(full_pack_contexts)} 个候选") @@ -1291,20 +1344,17 @@ class SubscribeChain(ChainBase): f"{subscribe.name} 正在洗版,{torrent_info.title} 不符合订阅集数范围" ) continue - # 洗版时,只保留至少能提升一集优先级的资源 + # 全集洗版按整包准入基线过滤;分集洗版按可提升剧集过滤。 if torrent_mediainfo.type == MediaType.TV: - interested_episodes = self.__get_best_version_interested_episodes( - subscribe=subscribe, - context=context, - priority=torrent_info.pri_order, - ) - if not interested_episodes: + if not self.__prepare_best_version_tv_candidate( + subscribe=subscribe, + context=context, + priority=torrent_info.pri_order, + ): logger.info( - f'{subscribe.name} 正在洗版,{torrent_info.title} 不包含可提升优先级的剧集') + f'{subscribe.name} 正在洗版,{torrent_info.title} ' + f'优先级未达到当前模式的升级条件') continue - # 将"本候选实际能升级到的集"作为允许下载集合下传到下载层, - # 防止标题元数据与实际种子文件错位导致同优先级集被重复下载。 - context.allowed_episodes = set(interested_episodes) if ( torrent_mediainfo.type != MediaType.TV and subscribe.current_priority @@ -1773,7 +1823,7 @@ class SubscribeChain(ChainBase): continue logger.info( f'{mediainfo.title_year} 通过媒体ID匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}') - match_source = getattr(_context, "match_source", "unknown") + match_source = _context.match_source if match_source == "title": # 标题兜底使用的是订阅目标 media_info,不能标记为候选自身识别结果。 _context.candidate_recognized = False @@ -1865,18 +1915,15 @@ class SubscribeChain(ChainBase): # 洗版时,优先级小于已下载优先级的不要 if subscribe.best_version: if meta.type == MediaType.TV: - interested_episodes = self.__get_best_version_interested_episodes( - subscribe=subscribe, - context=_context, - priority=torrent_info.pri_order, - ) - if not interested_episodes: + if not self.__prepare_best_version_tv_candidate( + subscribe=subscribe, + context=_context, + priority=torrent_info.pri_order, + ): logger.info( - f'{subscribe.name} 正在洗版,{torrent_info.title} 不包含可提升优先级的剧集') + f'{subscribe.name} 正在洗版,{torrent_info.title} ' + f'优先级未达到当前模式的升级条件') continue - # 与 search() 路径对称:把"本候选实际能升级到的集"作为允许下载集合下传到下载层, - # 避免 RSS / 订阅刷新场景下标题元数据与种子文件错位导致同优先级集重复下载。 - _context.allowed_episodes = set(interested_episodes) if ( meta.type != MediaType.TV and subscribe.current_priority @@ -2306,7 +2353,7 @@ class SubscribeChain(ChainBase): if no_exists is None and not subscribe.best_version: no_exists = {} update_data["lack_episode"] = cls.compute_lack_episode(subscribe, no_exists=no_exists) - if subscribe.best_version: + if subscribe.best_version and not cls.__is_full_best_version_enabled(subscribe): update_data["current_priority"] = cls.get_best_version_current_priority(subscribe) if update_data and touch_last_update: update_data["last_update"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') @@ -2374,8 +2421,8 @@ class SubscribeChain(ChainBase): mtype=meta.type, tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, - bangumiid=getattr(subscribe, "bangumiid", None), - episode_group=getattr(subscribe, "episode_group", None), + bangumiid=subscribe.bangumiid, + episode_group=subscribe.episode_group, cache=False, ) if not mediainfo: @@ -2513,9 +2560,9 @@ class SubscribeChain(ChainBase): for download in downloads: media = download.media_info - if subscribe.tmdbid and getattr(media, "tmdb_id", None) and media.tmdb_id != subscribe.tmdbid: + if subscribe.tmdbid and media.tmdb_id and media.tmdb_id != subscribe.tmdbid: continue - if subscribe.doubanid and getattr(media, "douban_id", None) and media.douban_id != subscribe.doubanid: + if subscribe.doubanid and media.douban_id and media.douban_id != subscribe.doubanid: continue if subscribe.type == MediaType.MOVIE.value and media.type == MediaType.MOVIE: @@ -2529,9 +2576,9 @@ class SubscribeChain(ChainBase): selected_episodes = getattr(download, "selected_episodes", None) if selected_episodes: episodes = selected_episodes - elif getattr(download, "meta_info", None) and download.meta_info.episode_list: + elif download.meta_info and download.meta_info.episode_list: episodes = download.meta_info.episode_list - elif getattr(download, "confirmed_full_coverage", False): + elif download.confirmed_full_coverage: episodes = self.__get_best_version_target_episodes(subscribe) used_full_coverage_fallback = True else: @@ -2549,7 +2596,16 @@ class SubscribeChain(ChainBase): if not valid_episodes: continue - priority = getattr(download.torrent_info, "pri_order", None) + priority = download.torrent_info.pri_order + if ( + self.__is_full_best_version_enabled(subscribe) + and download.confirmed_full_coverage + and isinstance(priority, int) + and not isinstance(priority, bool) + and priority > (subscribe.current_priority or 0) + ): + subscribe.current_priority = priority + update_data["current_priority"] = priority for episode_number in valid_episodes: note_set.add(episode_number) covered_episodes.add(episode_number) diff --git a/app/schemas/subscribe.py b/app/schemas/subscribe.py index 68dcc354..8fdc6ab8 100644 --- a/app/schemas/subscribe.py +++ b/app/schemas/subscribe.py @@ -5,27 +5,32 @@ from pydantic import BaseModel, Field, ConfigDict, model_validator from app.schemas.types import MediaType -def compute_subscribe_completed_episode(subscribe: Any) -> Optional[int]: +def compute_subscribe_completed_episode(subscribe: "Subscribe") -> Optional[int]: """ 计算订阅"已完成"集数派生值,仅用于响应填充,不入库。 - 普通电视剧按 ``total_episode - lack_episode`` 计算;洗版电视剧按订阅目标范围内 - priority==100 的分集数量,加上起始集前的逻辑完成集数计算。 + 普通电视剧按 ``total_episode - lack_episode`` 计算;分集洗版按订阅目标范围内 + priority==100 的分集数量计算;全集洗版按整包准入基线是否达到 100 计算。 """ - total_episode = getattr(subscribe, "total_episode", None) or 0 - if getattr(subscribe, "type", None) != MediaType.TV.value or not total_episode: + total_episode = subscribe.total_episode or 0 + if subscribe.type != MediaType.TV.value or not total_episode: return None - start_episode = getattr(subscribe, "start_episode", None) or 1 - if not getattr(subscribe, "best_version", None): - lack = getattr(subscribe, "lack_episode", None) or 0 + start_episode = subscribe.start_episode or 1 + if not subscribe.best_version: + lack = subscribe.lack_episode or 0 return max(total_episode - lack, 0) - episode_priority = getattr(subscribe, "episode_priority", None) or {} - if not episode_priority and getattr(subscribe, "current_priority", None) is not None: + if subscribe.best_version_full: + completed_targets = max(total_episode - start_episode + 1, 0) \ + if subscribe.current_priority == 100 else 0 + return min(min(max(start_episode - 1, 0), total_episode) + completed_targets, total_episode) + + episode_priority = subscribe.episode_priority or {} + if not episode_priority and subscribe.current_priority is not None: # 兼容只有整体优先级的洗版快照,响应派生值需与链路侧按集口径保持一致。 episode_priority = { - str(episode): int(getattr(subscribe, "current_priority")) + str(episode): int(subscribe.current_priority) for episode in range(start_episode, total_episode + 1) } priority_completed = sum( diff --git a/docs/subscribe-lifecycle.md b/docs/subscribe-lifecycle.md index ce406729..ec69c901 100644 --- a/docs/subscribe-lifecycle.md +++ b/docs/subscribe-lifecycle.md @@ -12,10 +12,10 @@ | --- | --- | --- | --- | | 目标范围 | `type`、`season`、`start_episode`、`total_episode` | 订阅需要覆盖的媒体范围。电影视为单个目标;电视剧按季和集范围处理。 | 新增订阅、订阅编辑、剧集刷新 | | 下载事实 | `note`、`episode_priority` | 主程序或可信回填入口确认已经存在或已经下载的目标。`note` 表示存在事实,`episode_priority` 表示电视剧每集已知下载质量。 | 下载事实入口、backfill 入口、reset 入口 | -| 进度摘要 | `lack_episode`、`current_priority`、`completed_episode` | 面向搜索、完成判定和展示的派生进度。`completed_episode` 由响应层按当前事实计算。 | progress 刷新入口、响应构造 | +| 进度摘要 | `lack_episode`、`current_priority`、`completed_episode` | 面向搜索、完成判定和展示的进度。`current_priority` 是当前洗版模式的资源准入基线;`completed_episode` 由响应层按当前事实计算。 | progress 刷新入口、确认整包下载事实入口、响应构造 | | 生命周期状态 | `state`、`last_update` | 订阅是否继续搜索,以及最近一次系统写入时间。 | 搜索下载流程、完成订阅、reset、状态更新 | -字段所有权应保持集中:下载事实入口写事实,progress 刷新入口写进度摘要,完成入口写完成状态,普通更新入口不应绕过这些语义直接改派生字段。 +字段所有权应保持集中:下载事实入口写事实,progress 刷新入口写派生进度,完成入口写完成状态,普通更新入口不应绕过这些语义直接改派生字段。 ## 生命周期 @@ -24,8 +24,8 @@ | 创建订阅 | 用户、API、Agent、插件 | 记录媒体目标、订阅模式、季集范围、保存路径和下载策略。 | 初始订阅记录 | | 搜索匹配 | 定时任务、手动搜索、RSS/站点刷新 | 按订阅目标和模式过滤候选资源,计算需要下载或升级的目标。 | 匹配候选、待下载上下文 | | 下载选择 | 下载链路 | 将候选资源交给下载器,下载层可返回明确集数或完整覆盖确认。 | 下载上下文、剩余缺口 | -| 事实记录 | 下载完成或可信 backfill | 写入 `note`,电视剧同步写入或提升 `episode_priority`。 | 已下载事实快照 | -| 进度刷新 | 下载流程、backfill、剧集范围变化、显式刷新 | 普通电视剧按媒体库缺失结果刷新 `lack_episode`;洗版电视剧按目标范围和按集事实刷新 `lack_episode` 与 `current_priority`。 | 进度字段 | +| 事实记录 | 下载任务添加成功或可信 backfill | 写入 `note`,电视剧同步写入或提升 `episode_priority`;全集洗版仅在下载层确认完整覆盖后提升 `current_priority`。 | 订阅下载事实快照 | +| 进度刷新 | 下载流程、backfill、剧集范围变化、显式刷新 | 普通电视剧按媒体库缺失结果刷新 `lack_episode`;电视剧洗版按目标范围和按集事实刷新 `lack_episode`,仅分集洗版从按集事实派生 `current_priority`。 | 进度字段 | | 完成判定 | 下载流程末端、强制完成 | 根据媒体类型和订阅模式判断是否完成,完成前允许完成检查事件否决。 | 订阅历史、完成状态 | | 重置/回填 | 用户操作、插件、维护工具 | reset 清空事实和进度;backfill 写入调用方确认的外部存在事实。 | 新事实快照和进度摘要 | @@ -39,7 +39,7 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普 | 电影洗版 | 电影 | 单个电影目标的整体质量 | `note=[1]` 表示电影已下载。 | `current_priority` 表示当前电影资源质量。 | `current_priority == 100` 或完成策略满足时完成。 | 用更高质量版本替换已有电影 | | 普通电视剧订阅 | 电视剧 | 季内目标集范围 | `note` 记录已存在或已下载集;`episode_priority` 记录每集已知下载质量。 | `lack_episode` 来自媒体库缺失结果。 | 目标范围无缺集时完成。 | 追剧、补缺集 | | 分集洗版 | 电视剧 | 季内目标集范围 | `note` 记录存在事实;`episode_priority` 记录每集质量。 | `lack_episode` 统计从未下载过任何版本的集;`current_priority` 是目标范围最低已知质量。 | 目标范围内每集达到顶级质量时完成洗版。 | 对单集逐步升级质量 | -| 全集洗版 | 电视剧 | 完整目标集范围的整体质量 | 整包下载确认完整覆盖后,按目标范围写 `note` 和 `episode_priority`。 | `current_priority` 作为整包候选整体质量门槛,并由按集事实刷新。 | 完整目标范围达到顶级质量时完成洗版。 | 用完整季包替换已有剧集 | +| 全集洗版 | 电视剧 | 完整目标集范围的整包质量 | 整包下载确认完整覆盖后,按目标范围写 `note` 和 `episode_priority`,并提升全集准入基线 `current_priority`。 | `current_priority` 表示全集模式的资源准入基线;保留的按集事实不参与候选过滤。 | `current_priority == 100` 或完成策略满足时完成。 | 用完整季包替换已有剧集 | ## 按集订阅与全集洗版 @@ -48,11 +48,13 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普 - `note` 表示某一集已经存在或已经下载。 - `episode_priority` 表示某一集已知的下载质量,缺失 key 表示没有质量事实。 - `lack_episode` 只表达“还缺多少集没有任何版本”,不表达“还有多少集需要升级质量”。 -- `current_priority` 对电视剧洗版是目标范围内最低已知质量摘要,缺失质量事实按 `0` 参与计算。 +- `current_priority` 对分集洗版是目标范围内最低已知质量摘要,缺失质量事实按 `0` 参与计算。 因此普通电视剧订阅和分集洗版可以互相转换。普通订阅下载产生的按集质量事实可被分集洗版继续使用;分集洗版产生的 `note` 也可被普通订阅继续作为已下载事实。 -全集洗版关注完整目标范围的整体资源质量。整包候选必须完整覆盖 `[start_episode, total_episode]`,不是只覆盖当前缺口集。下载层确认完整覆盖后,主程序才可在资源缺少显式集数时按目标范围补写事实。 +全集洗版关注完整目标范围的整体资源质量。整包候选必须完整覆盖 `[start_episode, total_episode]`,不是只覆盖当前缺口集。下载层确认完整覆盖后,主程序才可提升全集 `current_priority`,并在资源缺少显式集数时按目标范围补写按集事实。`episode_priority` 在全集模式中继续只升不降,供以后切回分集洗版使用,但不参与全集候选过滤和完成判断。 + +全集目标范围扩大后,原整包不再能证明覆盖新范围,因此全集准入基线 `current_priority` 重置为 `0`;目标范围缩小时,原整包仍覆盖新范围,保留该基线。 ## 字段语义 @@ -61,8 +63,8 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普 | `note` | 已存在或已下载的电影项/电视剧集。 | 电影使用 `[1]`;电视剧使用集数列表。 | | `episode_priority` | 电视剧按集质量事实,键为集数,值为下载优先级。 | 只升不降;普通订阅和洗版订阅都可以写入。 | | `lack_episode` | 电视剧目标范围内仍缺少任意版本的集数。 | 普通订阅来自媒体库缺失结果;洗版订阅按 `note` 和 `episode_priority > 0` 计算。 | -| `current_priority` | 洗版整体质量摘要。 | 电影洗版直接维护;电视剧洗版按目标范围内最低已知质量刷新。 | -| `completed_episode` | 展示用完成集数。 | 响应层按当前事实计算,不作为主要事实源。 | +| `current_priority` | 当前洗版模式的资源准入基线。 | 电影和全集洗版由对应模式的受控入口维护;分集洗版按目标范围内最低已知质量刷新。 | +| `completed_episode` | 展示用完成集数。 | 分集洗版按顶档剧集数计算,全集洗版按整包准入基线是否达到顶档计算;不作为主要事实源。 | | `state` | 订阅运行状态。 | 完成、暂停、重置等入口按业务状态维护。 | ## 用户视角 @@ -71,7 +73,7 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普 | --- | --- | --- | --- | | 新剧更新后自动下载 | 普通电视剧订阅 | 每集下载到任意版本即可,后续缺集继续追。 | 下载过的集会作为事实保留,后续可切到分集洗版。 | | 已有剧集想逐集升级质量 | 分集洗版 | 每集按质量继续升级,达到顶级质量后不再下载该集。 | 低质量已下载集不计入缺集,但仍会作为待升级目标。 | -| 想用完整季包替换整季 | 全集洗版 | 优先寻找完整覆盖目标范围的整包资源,并按整体质量门槛判断是否下载。 | 整包必须覆盖目标集范围,不只是覆盖当前缺口。 | +| 想用完整季包替换整季 | 全集洗版 | 优先寻找完整覆盖目标范围的整包资源,并按全集准入基线判断是否下载。 | 整包必须覆盖目标集范围,不只是覆盖当前缺口。 | | 新电影自动下载 | 电影普通订阅 | 下载到电影后完成订阅。 | 电影没有分集事实。 | | 电影已有版本想升级 | 电影洗版 | 用 `current_priority` 表达当前电影质量,下载更高质量版本。 | 完成策略按电影整体质量判断。 | | 手动入库或插件扫描到已有集 | backfill | 可信调用方写入已存在集;可选择是否提供 quality priority。 | `priority=None` 只表达存在事实;提供有效 priority 才表达质量事实。 | @@ -81,5 +83,5 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普 - 用户/API/Agent 的普通订阅编辑只负责目标和配置变更,不直接维护下载事实和进度摘要。 - 主程序下载链路产生的电视剧下载必须同时维护 `note` 和 `episode_priority`。 - backfill 入口只接收调用方确认的外部存在事实,不主动扫描媒体库。 -- progress 刷新入口负责把当前事实转换为 `lack_episode` 和 TV 洗版 `current_priority`。 +- progress 刷新入口负责把当前事实转换为 `lack_episode`,并仅为分集洗版派生 `current_priority`;全集准入基线由模式转换、确认完整覆盖的整包下载、目标范围变化,以及插件回滚或显式完成入口维护。 - 插件可以调用主程序公开入口补事实或刷新进度,不应复制主程序缺集、洗版完成或当前优先级计算规则。 diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 38da0ac6..9b3da71f 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -281,6 +281,9 @@ def _load_subscribe_chain_class(): helper_server_module.MoviePilotServerHelper = _MoviePilotServerHelper + helper_mediaserver_module = ensure_module("app.helper.mediaserver", types.ModuleType("app.helper.mediaserver")) + helper_mediaserver_module.MediaServerHelper = type("MediaServerHelper", (), {}) + helper_torrent_module = ensure_module("app.helper.torrent", types.ModuleType("app.helper.torrent")) helper_torrent_module.TorrentHelper = type("TorrentHelper", (), {}) @@ -288,6 +291,9 @@ def _load_subscribe_chain_class(): class _SubscribeModel: def __init__(self, **kwargs): + self.best_version_full = 0 + self.bangumiid = None + self.episode_group = None for key, value in kwargs.items(): setattr(self, key, value) @@ -338,6 +344,7 @@ def _load_subscribe_chain_class(): chain_dependencies = { "app.chain.download": "DownloadChain", "app.chain.media": "MediaChain", + "app.chain.mediaserver": "MediaServerChain", "app.chain.search": "SearchChain", "app.chain.tmdb": "TmdbChain", "app.chain.torrents": "TorrentsChain", @@ -403,6 +410,7 @@ class SubscribeChainTest(TestCase): selected_episodes=selected_episodes, meta_info=SimpleNamespace(season_list=[1], episode_list=meta_episodes or selected_episodes or []), media_info=SimpleNamespace(type=MediaType.TV, tmdb_id=1, douban_id=None), + confirmed_full_coverage=False, ) def test_default_kwargs_respects_explicit_zero_best_version(self): @@ -546,6 +554,7 @@ class SubscribeChainTest(TestCase): context = SimpleNamespace( media_info=torrent_media, media_recognize_fail_count=0, + match_source="unknown", meta_info=SimpleNamespace( begin_season=0, episode_list=[1], @@ -607,6 +616,16 @@ class SubscribeChainTest(TestCase): {"1": 80, "2": 80, "3": 80}, ) + def test_full_best_version_does_not_materialize_episode_priority_from_baseline(self): + subscribe = self._build_subscribe( + best_version_full=1, + current_priority=82, + episode_priority=None, + ) + + self.assertEqual(SubscribeChain.get_episode_priority(subscribe), {}) + self.assertEqual(SubscribeChain.get_best_version_current_priority(subscribe), 82) + def test_get_pending_best_version_episodes_uses_per_episode_status(self): subscribe = self._build_subscribe( total_episode=5, @@ -1427,8 +1446,8 @@ class SubscribeChainTest(TestCase): self.assertEqual(calls[0]["contexts"], [episode_context, full_pack_context]) self.assertIs(calls[0]["no_exists"], no_exists) - def test_episode_best_version_tries_full_pack_when_priority_exceeds_current_priority(self): - """整包候选按 current_priority 整体门槛判断,单集较高优先级不阻断整包优先。""" + def test_episode_best_version_falls_back_when_full_pack_does_not_exceed_every_target(self): + """整包候选未严格高于每个目标集时,应回退到按集下载。""" subscribe = self._build_subscribe( best_version_full=0, total_episode=3, @@ -1470,66 +1489,117 @@ class SubscribeChainTest(TestCase): self.assertEqual(downloads, []) self.assertIs(lefts, no_exists) - self.assertEqual(len(calls), 2) + self.assertEqual(len(calls), 1) self.assertEqual(calls[0]["contexts"], [full_pack_context]) - self.assertEqual(calls[0]["no_exists"]["media-key"][1].episodes, []) - self.assertIs(calls[1]["no_exists"], no_exists) + self.assertIs(calls[0]["no_exists"], no_exists) + + def test_episode_full_pack_priority_must_strictly_exceed_all_targets(self): + """缺失集按 0 参与比较,且候选与任一目标集相等时也不优先整包。""" + subscribe = self._build_subscribe( + best_version_full=0, + total_episode=3, + episode_priority={"1": 90, "2": 80}, + current_priority=0, + ) + + self.assertFalse( + SubscribeChain._SubscribeChain__should_prefer_full_pack_for_episode_best_version( + subscribe=subscribe, + priority=82, + ) + ) + self.assertFalse( + SubscribeChain._SubscribeChain__should_prefer_full_pack_for_episode_best_version( + subscribe=subscribe, + priority=90, + ) + ) + self.assertTrue( + SubscribeChain._SubscribeChain__should_prefer_full_pack_for_episode_best_version( + subscribe=subscribe, + priority=91, + ) + ) def test_full_pack_priority_check_uses_current_priority_fallback(self): """没有按集优先级状态时使用 current_priority 兜底判断。""" subscribe = self._build_subscribe(total_episode=3, current_priority=80, episode_priority=None) self.assertFalse( - SubscribeChain._SubscribeChain__is_full_season_priority_higher_than_all_targets( + SubscribeChain._SubscribeChain__should_prefer_full_pack_for_episode_best_version( subscribe=subscribe, priority=80, ) ) - def test_full_best_version_priority_check_uses_current_priority_directly(self): - """全集洗版优先判断使用 current_priority,避免部分 episode_priority 破坏整体优先级语义。""" + def test_full_best_version_completion_uses_baseline_priority(self): + """全集洗版完成只认整包准入基线,不受保留的按集事实影响。""" subscribe = self._build_subscribe( best_version_full=1, - total_episode=3, + current_priority=82, + episode_priority={"1": 100, "2": 100, "3": 100}, + ) + + self.assertFalse(SubscribeChain.is_best_version_complete(subscribe)) + subscribe.current_priority = 100 + subscribe.episode_priority = {"1": 80} + self.assertTrue(SubscribeChain.is_best_version_complete(subscribe)) + + def test_full_best_version_candidate_uses_baseline_without_allowed_episodes(self): + """全集候选只比较整包准入基线,不进入按集 interested/allowed_episodes 路径。""" + subscribe = self._build_subscribe( + best_version_full=1, + current_priority=82, + episode_priority={"1": 100, "2": 100, "3": 100}, + ) + context = SimpleNamespace( + selected_episodes=None, + meta_info=SimpleNamespace(episode_list=[1, 2, 3]), + allowed_episodes=None, + ) + + with patch.object( + SubscribeChain, + "_SubscribeChain__get_best_version_interested_episodes", + side_effect=AssertionError("full mode must not inspect episode priorities"), + ): + self.assertFalse( + SubscribeChain._SubscribeChain__prepare_best_version_tv_candidate( + subscribe=subscribe, + context=context, + priority=82, + ) + ) + self.assertTrue( + SubscribeChain._SubscribeChain__prepare_best_version_tv_candidate( + subscribe=subscribe, + context=context, + priority=83, + ) + ) + + self.assertIsNone(context.allowed_episodes) + + def test_episode_best_version_candidate_sets_allowed_episodes(self): + """分集候选继续把实际可提升剧集下传到下载层。""" + subscribe = self._build_subscribe( + best_version_full=0, current_priority=80, - episode_priority={"1": 100}, + episode_priority={"1": 90, "2": 80}, + ) + context = SimpleNamespace( + selected_episodes=None, + meta_info=SimpleNamespace(episode_list=[1, 2, 3]), ) - self.assertFalse( - SubscribeChain._SubscribeChain__is_full_season_priority_higher_than_all_targets( - subscribe=subscribe, - priority=80, - ) - ) - self.assertTrue( - SubscribeChain._SubscribeChain__is_full_season_priority_higher_than_all_targets( - subscribe=subscribe, - priority=81, - ) - ) - subscribe.current_priority = 0 - self.assertTrue( - SubscribeChain._SubscribeChain__is_full_season_priority_higher_than_all_targets( - subscribe=subscribe, - priority=10, - ) + accepted = SubscribeChain._SubscribeChain__prepare_best_version_tv_candidate( + subscribe=subscribe, + context=context, + priority=85, ) - def test_full_pack_priority_check_computes_gate_when_current_priority_is_none(self): - """没有持久 current_priority 时,整包门槛按当前按集事实计算。""" - subscribe = self._build_subscribe( - best_version_full=1, - total_episode=3, - current_priority=None, - episode_priority={"1": 100, "2": 80}, - ) - - self.assertTrue( - SubscribeChain._SubscribeChain__is_full_season_priority_higher_than_all_targets( - subscribe=subscribe, - priority=1, - ) - ) + self.assertTrue(accepted) + self.assertEqual(context.allowed_episodes, {2, 3}) def test_record_download_facts_uses_selected_episodes(self): subscribe = self._build_subscribe( @@ -2337,6 +2407,31 @@ class SubscribeProgressEntrypointTest(TestCase): self.assertEqual(subscribe.current_priority, 0) self.assertEqual(updates[-1]["current_priority"], 0) + def test_refresh_full_best_version_progress_preserves_baseline_priority(self): + """全集洗版刷新缺集进度时,不从按集事实覆盖整包准入基线。""" + subscribe = self._build_subscribe( + best_version_full=1, + total_episode=3, + current_priority=82, + episode_priority={"1": 100}, + lack_episode=0, + ) + updates = [] + + class _SubscribeOper: + def update(self, subscribe_id, payload): + updates.append(payload) + + with patch.object(self.module, "SubscribeOper", return_value=_SubscribeOper()): + self.SubscribeChain()._SubscribeChain__refresh_subscribe_progress_with_no_exists( + subscribe=subscribe, + no_exists={}, + scene="unit", + ) + + self.assertEqual(subscribe.current_priority, 82) + self.assertNotIn("current_priority", updates[-1]) + def test_refresh_subscribe_progress_normal_tv_uses_resolve_missing_successfully(self): subscribe = self._build_subscribe(best_version=0, lack_episode=5) mediainfo = SimpleNamespace( @@ -2667,6 +2762,94 @@ class SubscribeProgressConsolidationTest(TestCase): list(range(1, 11)), ) + def test_full_best_version_total_expansion_resets_baseline_priority(self): + module, SubscribeChain = _load_subscribe_chain_class() + subscribe = module.Subscribe( + id=41, + name="全集洗版扩展剧", + type=MediaType.TV.value, + season=1, + total_episode=3, + start_episode=1, + lack_episode=0, + best_version=1, + best_version_full=1, + current_priority=82, + episode_priority={"1": 90, "2": 80, "3": 82}, + note=[1, 2, 3], + tmdbid=31041, + doubanid=None, + manual_total_episode=0, + ) + + payload = SubscribeChain._SubscribeChain__prepare_best_version_total_change_fields( + subscribe=subscribe, + total_episode=5, + old_total_episode=3, + ) + + self.assertEqual(subscribe.current_priority, 0) + self.assertEqual(payload["current_priority"], 0) + self.assertEqual(subscribe.episode_priority, {"1": 90, "2": 80, "3": 82}) + + def test_full_best_version_total_expansion_does_not_derive_episode_map_from_baseline(self): + module, SubscribeChain = _load_subscribe_chain_class() + subscribe = module.Subscribe( + id=45, + name="全集洗版无按集事实扩展剧", + type=MediaType.TV.value, + season=1, + total_episode=3, + start_episode=1, + lack_episode=0, + best_version=1, + best_version_full=1, + current_priority=82, + episode_priority={}, + note=[], + tmdbid=31045, + doubanid=None, + manual_total_episode=0, + ) + + payload = SubscribeChain._SubscribeChain__prepare_best_version_total_expansion_fields( + subscribe=subscribe, + total_episode=5, + ) + + self.assertEqual(subscribe.current_priority, 0) + self.assertEqual(payload["episode_priority"], {}) + + def test_full_best_version_total_shrink_preserves_baseline_priority(self): + module, SubscribeChain = _load_subscribe_chain_class() + subscribe = module.Subscribe( + id=43, + name="全集洗版缩小剧", + type=MediaType.TV.value, + season=1, + total_episode=5, + start_episode=1, + lack_episode=0, + best_version=1, + best_version_full=1, + current_priority=82, + episode_priority={"1": 90, "2": 80, "3": 82, "4": 70, "5": 60}, + note=[1, 2, 3, 4, 5], + tmdbid=31043, + doubanid=None, + manual_total_episode=0, + ) + + payload = SubscribeChain._SubscribeChain__prepare_best_version_total_change_fields( + subscribe=subscribe, + total_episode=3, + old_total_episode=5, + ) + + self.assertEqual(subscribe.current_priority, 82) + self.assertEqual(payload["current_priority"], 82) + self.assertEqual(subscribe.episode_priority, {"1": 90, "2": 80, "3": 82}) + def test_refresh_total_episode_before_completion_resets_legacy_current_priority_when_filtered_empty(self): module, SubscribeChain = _load_subscribe_chain_class() subscribe = module.Subscribe( @@ -3163,6 +3346,23 @@ class SubscribeProgressConsolidationTest(TestCase): self.assertEqual(schema_subscribe.completed_episode, 3) self.assertEqual(schemas.compute_subscribe_completed_episode(chain_subscribe), 3) + def test_full_best_version_completed_episode_uses_baseline_not_episode_map(self): + data = { + "name": "全集洗版响应剧", + "type": MediaType.TV.value, + "best_version": 1, + "best_version_full": 1, + "start_episode": 1, + "total_episode": 3, + "current_priority": 82, + "episode_priority": {"1": 100, "2": 100, "3": 100}, + } + + self.assertEqual(schemas.compute_subscribe_completed_episode(SimpleNamespace(**data)), 0) + data["current_priority"] = 100 + data["episode_priority"] = {"1": 80} + self.assertEqual(schemas.compute_subscribe_completed_episode(SimpleNamespace(**data)), 3) + class SubscribeDownloadFactsTest(TestCase): def setUp(self): @@ -3291,7 +3491,27 @@ class SubscribeDownloadFactsTest(TestCase): self.assertEqual(snapshot["episodes"], [1, 2, 3, 4]) self.assertEqual(subscribe.note, [1, 2, 3, 4]) self.assertEqual(subscribe.episode_priority, {"1": 90, "2": 90, "3": 90, "4": 90}) - self.assertNotIn("current_priority", updates[-1]) + self.assertEqual(subscribe.current_priority, 90) + self.assertEqual(updates[-1]["current_priority"], 90) + + def test_full_best_version_does_not_write_baseline_without_confirmed_full_coverage(self): + subscribe = self._build_subscribe( + best_version=1, + best_version_full=1, + current_priority=82, + episode_priority={"1": 60}, + ) + + with patch.object(self.module, "SubscribeOper") as subscribe_oper_cls: + self.SubscribeChain()._SubscribeChain__record_subscribe_download_facts( + subscribe, + mediainfo=SimpleNamespace(title_year="下载事实剧 (2026)"), + downloads=[self._download(episodes=[1], pri_order=90, confirmed_full_coverage=False)], + ) + + payload = subscribe_oper_cls.return_value.update.call_args.args[1] + self.assertEqual(subscribe.current_priority, 82) + self.assertNotIn("current_priority", payload) def test_normal_subscription_without_episode_list_does_not_use_target_range_without_download_confirmation(self): subscribe = self._build_subscribe(best_version=0, best_version_full=0)