From b7c78da2147a6f07552a993faa2954ca51c75b38 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:18:51 +0800 Subject: [PATCH] fix(subscribe): handle absolute numbered season packs (#5866) --- app/chain/download.py | 24 +- app/chain/subscribe.py | 142 ++++++++-- app/core/context.py | 85 +++++- tests/test_download_chain.py | 74 +++++- tests/test_subscribe_chain.py | 482 ++++++++++++++++++++++++++++++++-- 5 files changed, 758 insertions(+), 49 deletions(-) diff --git a/app/chain/download.py b/app/chain/download.py index 0113222a..4c8b34af 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -554,6 +554,14 @@ class DownloadChain(ChainBase): effective &= set(allowed) return effective + def __get_located_target_episodes(_context: Context) -> Set[int]: + """ + 返回集数定位后的目标季内集数;缺失定位不扩大候选范围。 + """ + if not _context.located_episodes: + return set() + return set(_context.located_episodes) + # 发送资源选择事件,允许外部修改上下文数据 logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}") event_data = ResourceSelectionEventData( @@ -621,8 +629,9 @@ class DownloadChain(ChainBase): # 没有季的默认为第1季 if not torrent_season: torrent_season = [1] - # 种子有集的不要 - if meta.episode_list: + # 常规标题带集数的资源不走整季路径;已完成高置信集数定位的候选仍需打开种子文件确认。 + located_target_episodes = __get_located_target_episodes(context) + if meta.episode_list and not located_target_episodes: continue # 匹配TMDBID if need_mid == media.tmdb_id or need_mid == media.douban_id: @@ -743,8 +752,9 @@ class DownloadChain(ChainBase): # 只处理单季含集的种子 if len(torrent_season) != 1 or torrent_season[0] != need_season: continue - # 种子集列表 - torrent_episodes = set(meta.episode_list) + # 种子集列表;累计总集编号等场景优先使用订阅阶段定位出的季内目标集。 + located_target_episodes = __get_located_target_episodes(context) + torrent_episodes = located_target_episodes or set(meta.episode_list) # 整季的不处理 if not torrent_episodes: continue @@ -826,10 +836,12 @@ class DownloadChain(ChainBase): effective_need = __apply_allowed_episodes(need_episodes, context) if not effective_need: continue + located_target_episodes = __get_located_target_episodes(context) + match_episode_candidates = located_target_episodes or set(meta.episode_list) # 选中一个单季整季的或单季包括需要的所有集的 if (media.tmdb_id == need_mid or media.douban_id == need_mid) \ - and (not meta.episode_list - or set(meta.episode_list).intersection(effective_need)) \ + and (not match_episode_candidates + or match_episode_candidates.intersection(effective_need)) \ and len(meta.season_list) == 1 \ and meta.season_list[0] == need_season: # 检查种子看是否有需要的集 diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 1ae69e02..74f9f4a8 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -24,7 +24,7 @@ from app.helper.interaction import ( from app.chain.tmdb import TmdbChain from app.chain.torrents import TorrentsChain from app.core.config import settings, global_vars -from app.core.context import TorrentInfo, Context, MediaInfo +from app.core.context import TorrentInfo, Context, MediaInfo, EpisodeLocation from app.core.event import eventmanager, Event from app.core.meta import MetaBase from app.core.meta.words import WordsMatcher @@ -283,6 +283,7 @@ class SubscribeChain(ChainBase): subscribe: Subscribe, context: Context, priority: int, + episode_location: Optional[EpisodeLocation] = None, ) -> List[int]: """ 获取当前资源中仍值得继续洗版的剧集。 @@ -296,7 +297,11 @@ class SubscribeChain(ChainBase): selected_episodes = getattr(context, "selected_episodes", None) if selected_episodes is None: - selected_episodes = context.meta_info.episode_list if context.meta_info else [] + if episode_location is None: + episode_location = cls.__locate_context_episodes(context=context, subscribe=subscribe) + selected_episodes = episode_location.target_episodes \ + if cls.__is_high_confidence_episode_location(episode_location) \ + else (context.meta_info.episode_list if context.meta_info else []) if not selected_episodes: episode_priority = cls.__get_episode_priority(subscribe) return sorted([ @@ -318,6 +323,59 @@ class SubscribeChain(ChainBase): interested.append(episode_num) return sorted(set(interested)) + @classmethod + def __normalize_best_version_resource_episodes( + cls, + meta: MetaBase, + subscribe: Subscribe, + episodes: Optional[List[int]] = None, + ) -> List[int]: + """ + 将同季累计总集编号映射为订阅季内集数。 + """ + raw_episodes = episodes if episodes is not None else (meta.episode_list if meta else []) + if not raw_episodes: + return [] + + try: + resource_episodes = sorted(set(int(episode) for episode in raw_episodes)) + except (TypeError, ValueError): + return list(raw_episodes) + + episode_location = EpisodeLocation.locate( + meta=meta, + target_season=subscribe.season, + target_episodes=cls.__get_best_version_target_episodes(subscribe), + episodes=resource_episodes, + ) + if episode_location: + return episode_location.target_episodes + return resource_episodes + + @classmethod + def __locate_context_episodes(cls, context: Context, subscribe: Subscribe) -> Optional[EpisodeLocation]: + """ + 为候选上下文缓存集数定位结果,供订阅过滤和下载链保持同一套集数语义。 + """ + if not context or not context.meta_info: + return None + + episode_location = context.locate_episode( + target_season=subscribe.season, + target_episodes=cls.__get_best_version_target_episodes(subscribe), + ) + # located_episodes 是面向后续流程的目标季内集数视图,只接受可直接参与匹配的高置信定位结果。 + context.located_episodes = set(episode_location.target_episodes) \ + if episode_location and episode_location.confidence == EpisodeLocation.CONFIDENCE_HIGH else None + return episode_location + + @staticmethod + def __is_high_confidence_episode_location(episode_location: Optional[EpisodeLocation]) -> bool: + """ + 判断集数定位结果是否可直接参与订阅过滤与状态更新。 + """ + return bool(episode_location and episode_location.confidence == EpisodeLocation.CONFIDENCE_HIGH) + @classmethod def __is_full_best_version_enabled(cls, subscribe: Subscribe) -> bool: """ @@ -330,17 +388,24 @@ class SubscribeChain(ChainBase): ) @classmethod - def __is_full_season_resource(cls, meta: MetaBase, subscribe: Subscribe) -> bool: + def __is_full_season_resource( + cls, + meta: MetaBase, + subscribe: Subscribe, + episode_location: Optional[EpisodeLocation] = None, + ) -> bool: """ 判断候选资源是否覆盖订阅目标全集范围。 """ season_list = meta.season_list or [1] - if len(season_list) != 1: - return False - if subscribe.season is not None and season_list[0] != subscribe.season: - return False + if subscribe.season is not None: + if len(season_list) != 1: + return False + if season_list[0] != subscribe.season: + return False - episodes = meta.episode_list + episodes = episode_location.target_episodes if cls.__is_high_confidence_episode_location(episode_location) \ + else cls.__normalize_best_version_resource_episodes(meta=meta, subscribe=subscribe) if not episodes: # 资源未标出单集时按整季包处理,后续下载前仍会解析种子文件确认完整性。 return True @@ -351,14 +416,23 @@ class SubscribeChain(ChainBase): return target_episodes.issubset(set(episodes)) @classmethod - def __is_full_season_best_version_resource(cls, meta: MetaBase, subscribe: Subscribe) -> bool: + def __is_full_season_best_version_resource( + cls, + meta: MetaBase, + subscribe: Subscribe, + episode_location: Optional[EpisodeLocation] = None, + ) -> bool: """ 判断候选资源是否符合全集洗版资源约束。 """ if not cls.__is_full_best_version_enabled(subscribe): return True - return cls.__is_full_season_resource(meta=meta, subscribe=subscribe) + return cls.__is_full_season_resource( + meta=meta, + subscribe=subscribe, + episode_location=episode_location, + ) @classmethod def __is_full_season_priority_higher_than_all_targets(cls, subscribe: Subscribe, priority: int) -> bool: @@ -1036,13 +1110,21 @@ class SubscribeChain(ChainBase): torrent_meta = context.meta_info torrent_info = context.torrent_info torrent_mediainfo = context.media_info + episode_location = None + if torrent_mediainfo.type == MediaType.TV: + episode_location = self.__locate_context_episodes( + context=context, + subscribe=subscribe, + ) # 洗版 if subscribe.best_version: if ( torrent_mediainfo.type == MediaType.TV and not self.__is_full_season_best_version_resource( - meta=torrent_meta, subscribe=subscribe + meta=torrent_meta, + subscribe=subscribe, + episode_location=episode_location, ) ): logger.info( @@ -1053,7 +1135,9 @@ class SubscribeChain(ChainBase): if ( torrent_mediainfo.type == MediaType.TV and not self._is_episode_range_covered( - meta=torrent_meta, subscribe=subscribe + meta=torrent_meta, + subscribe=subscribe, + episode_location=episode_location, ) ): logger.info( @@ -1066,6 +1150,7 @@ class SubscribeChain(ChainBase): subscribe=subscribe, context=context, priority=torrent_info.pri_order, + episode_location=episode_location, ) if not interested_episodes: logger.info( @@ -1158,7 +1243,15 @@ class SubscribeChain(ChainBase): updated = False for download in downloads: download_priority = download.torrent_info.pri_order - downloaded_episodes = self.__get_downloaded_episodes([download]) + if download.located_episodes: + downloaded_episodes = sorted(download.located_episodes) + else: + downloaded_episodes = self.__get_downloaded_episodes([download]) + downloaded_episodes = self.__normalize_best_version_resource_episodes( + meta=download.meta_info, + subscribe=subscribe, + episodes=downloaded_episodes, + ) if not downloaded_episodes and self.__is_full_season_resource(download.meta_info, subscribe): # 整包下载时资源标题常不携带集数,视为覆盖当前订阅的全部目标集。 downloaded_episodes = self.__get_best_version_target_episodes(subscribe) @@ -1519,6 +1612,7 @@ class SubscribeChain(ChainBase): # 如果是电视剧 if torrent_mediainfo.type == MediaType.TV: + episode_location = self.__locate_context_episodes(context=_context, subscribe=subscribe) # 有多季的不要 if len(torrent_meta.season_list) > 1: logger.debug(f'{torrent_info.title} 有多季,不处理') @@ -1538,20 +1632,24 @@ class SubscribeChain(ChainBase): # 缺失集 no_exists_info = no_exists.get(mediakey).get(subscribe.season) if no_exists_info: + torrent_episodes = episode_location.target_episodes \ + if self.__is_high_confidence_episode_location(episode_location) \ + else torrent_meta.episode_list # 是否有交集 if no_exists_info.episodes and \ - torrent_meta.episode_list and \ + torrent_episodes and \ not set(no_exists_info.episodes).intersection( - set(torrent_meta.episode_list) + set(torrent_episodes) ): logger.debug( - f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集' + f'{torrent_info.title} 对应剧集 {torrent_episodes} 未包含缺失的剧集' ) continue else: if not self.__is_full_season_best_version_resource( meta=torrent_meta, subscribe=subscribe, + episode_location=episode_location, ): logger.debug( f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源" @@ -1563,6 +1661,7 @@ class SubscribeChain(ChainBase): and not self._is_episode_range_covered( meta=torrent_meta, subscribe=subscribe, + episode_location=episode_location, ) ): logger.debug( @@ -1598,6 +1697,7 @@ class SubscribeChain(ChainBase): subscribe=subscribe, context=_context, priority=torrent_info.pri_order, + episode_location=episode_location, ) if not interested_episodes: logger.info( @@ -3228,11 +3328,17 @@ class SubscribeChain(ChainBase): ) @classmethod - def _is_episode_range_covered(cls, meta: MetaBase, subscribe: Subscribe) -> bool: + def _is_episode_range_covered( + cls, + meta: MetaBase, + subscribe: Subscribe, + episode_location: Optional[EpisodeLocation] = None, + ) -> bool: """ 判断种子是否覆盖当前仍需洗版的剧集范围。 """ - episodes = meta.episode_list + episodes = episode_location.target_episodes if cls.__is_high_confidence_episode_location(episode_location) \ + else cls.__normalize_best_version_resource_episodes(meta=meta, subscribe=subscribe) if not episodes: # 没有剧集信息,表示该种子为合集 return True diff --git a/app/core/context.py b/app/core/context.py index a6f62005..15417a6a 100644 --- a/app/core/context.py +++ b/app/core/context.py @@ -1,7 +1,7 @@ import re from dataclasses import dataclass, field from datetime import datetime -from typing import List, Dict, Any, Tuple, Optional, Set +from typing import List, Dict, Any, Tuple, Optional, Set, ClassVar from app.core.config import settings from app.core.meta import MetaBase @@ -808,6 +808,72 @@ class MediaInfo: self.episode_groups = [] +@dataclass +class EpisodeLocation: + """ + 候选资源标题集数与订阅目标季内集数之间的定位结果。 + """ + + # 定位模式:absolute_to_season 表示同季累计总集编号映射为订阅季内集数。 + MODE_ABSOLUTE_TO_SEASON: ClassVar[str] = "absolute_to_season" + # 置信度:high 表示可直接用于订阅过滤和下载匹配;low 仅作为未来种子文件确认前的弱提示预留。 + CONFIDENCE_HIGH: ClassVar[str] = "high" + CONFIDENCE_LOW: ClassVar[str] = "low" + + # 标题或副标题中解析出的原始集数,例如累计总集编号 57-82 + source_episodes: List[int] = field(default_factory=list) + # 映射到订阅目标季内的集数,例如第 5 季的 1-26 + target_episodes: List[int] = field(default_factory=list) + # 定位模式,当前有效值:absolute_to_season。 + mode: str = MODE_ABSOLUTE_TO_SEASON + # 定位置信度,当前消费值:high;low 预留给只允许进入种子文件确认的弱定位。 + confidence: str = CONFIDENCE_HIGH + + @classmethod + def locate( + cls, + meta: MetaBase, + target_season: Optional[int], + target_episodes: List[int], + episodes: Optional[List[int]] = None, + ) -> Optional["EpisodeLocation"]: + """ + 识别候选标题集数与订阅目标季内集数之间的确定性映射关系。 + """ + if not meta: + return None + + raw_episodes = episodes if episodes is not None else meta.episode_list + if not raw_episodes or not target_episodes: + return None + + try: + source_episodes = sorted(set(int(episode) for episode in raw_episodes)) + except (TypeError, ValueError): + return None + + if set(source_episodes).intersection(set(target_episodes)): + return None + + season_list = meta.season_list or [] + if target_season is None or len(season_list or []) != 1 or season_list[0] != target_season: + return None + + if len(source_episodes) != len(target_episodes): + return None + if source_episodes != list(range(source_episodes[0], source_episodes[-1] + 1)): + return None + + # 累计总集编号通常表现为 Sxx + 连续高位区间,例如 S05 的 57-82; + # 只有当候选区间完整覆盖订阅目标长度时才定位,避免部分分集包被误当全集。 + return cls( + source_episodes=source_episodes, + target_episodes=target_episodes, + mode=cls.MODE_ABSOLUTE_TO_SEASON, + confidence=cls.CONFIDENCE_HIGH, + ) + + @dataclass class Context: """ @@ -832,6 +898,22 @@ class Context: media_info_is_target: bool = False # 调用方对本候选允许下载的剧集集合,None 表示不限制,空集合表示拒绝交付任何集。 allowed_episodes: Optional[Set[int]] = None + # 调用方完成高置信集数定位后写入的目标季内集数集合,None 表示未定位或不应放大候选范围。 + located_episodes: Optional[Set[int]] = None + + def locate_episode( + self, + target_season: Optional[int], + target_episodes: List[int], + ) -> Optional[EpisodeLocation]: + """ + 根据当前候选元数据和订阅目标范围计算集数定位结果。 + """ + return EpisodeLocation.locate( + meta=self.meta_info, + target_season=target_season, + target_episodes=target_episodes, + ) def to_dict(self): """ @@ -848,4 +930,5 @@ class Context: "media_info_is_target": self.media_info_is_target, # 保留 None / 空集 / 非空集 三态语义,避免下游误把"显式拒绝"当成"不限制"。 "allowed_episodes": sorted(self.allowed_episodes) if self.allowed_episodes is not None else None, + "located_episodes": sorted(self.located_episodes) if self.located_episodes is not None else None, } diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index d173b947..f03c7fdf 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock import app.chain.download as download_module from app.chain.download import DownloadChain from app.core.context import Context, MediaInfo, TorrentInfo -from app.core.metainfo import MetaInfo from app.schemas import NotExistMediaInfo from app.schemas.types import MediaType @@ -59,7 +58,7 @@ def test_download_single_submits_download_added_to_background(monkeypatch): chain.post_message = MagicMock() context = Context( - meta_info=MetaInfo("Demo Movie 2024"), + meta_info=SimpleNamespace(episode_list=[], season=None, episode=None), media_info=MediaInfo( type=MediaType.MOVIE, title="Demo Movie", @@ -129,6 +128,7 @@ def _build_tv_context(episode_list=None): ), torrent_info=SimpleNamespace(title="Test Show S01 2160p", site_name="TestSite"), allowed_episodes=None, + located_episodes=None, ) @@ -325,3 +325,73 @@ def test_batch_download_keeps_count_check_without_complete_coverage(monkeypatch) assert downloads == [context] assert lefts == {} chain.download_single.assert_called_once() + + +def test_batch_download_checks_files_for_located_absolute_numbered_full_pack(monkeypatch): + """ + 普通订阅可借助集数定位把累计总集编号候选放入整包检查,再由种子文件确认季内集数覆盖。 + """ + _FakeBatchTorrentHelper.episodes = list(range(1, 27)) + monkeypatch.setattr(download_module, "TorrentHelper", _FakeBatchTorrentHelper) + monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) + + chain = DownloadChain.__new__(DownloadChain) + chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"])) + chain.download_single = MagicMock(return_value="hash") + + context = _build_tv_context(episode_list=list(range(57, 83))) + context.meta_info.season_list = [5] + context.located_episodes = set(range(1, 27)) + no_exists = { + 1: { + 5: NotExistMediaInfo( + season=5, + episodes=[], + total_episode=26, + start_episode=1, + ) + } + } + + downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) + + assert downloads == [context] + assert lefts == {} + chain.download_torrent.assert_called_once() + chain.download_single.assert_called_once() + + +def test_batch_download_selects_needed_files_for_located_absolute_numbered_pack(monkeypatch): + """ + 分集洗版可借助集数定位进入拆包检查,并只选择订阅仍需要的季内集数。 + """ + _FakeBatchTorrentHelper.episodes = list(range(1, 27)) + monkeypatch.setattr(download_module, "TorrentHelper", _FakeBatchTorrentHelper) + monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) + + chain = DownloadChain.__new__(DownloadChain) + chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"])) + chain.download_single = MagicMock(return_value="hash") + + context = _build_tv_context(episode_list=list(range(57, 83))) + context.meta_info.season_list = [5] + context.allowed_episodes = {1, 2} + context.located_episodes = set(range(1, 27)) + no_exists = { + 1: { + 5: NotExistMediaInfo( + season=5, + episodes=[1, 2], + total_episode=26, + start_episode=1, + ) + } + } + + downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) + + assert downloads == [context] + assert lefts == {} + chain.download_torrent.assert_called_once() + chain.download_single.assert_called_once() + assert chain.download_single.call_args.kwargs["episodes"] == {1, 2} diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 496b496e..cc78210c 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -13,8 +13,7 @@ def _load_subscribe_chain_class(): """隔离加载 SubscribeChain,避免测试依赖完整运行时环境。""" module_name = "_test_subscribe_chain" if module_name in sys.modules: - module = sys.modules[module_name] - return module, module.SubscribeChain + return sys.modules[module_name], sys.modules[module_name].SubscribeChain original_modules = {} @@ -74,6 +73,33 @@ def _load_subscribe_chain_class(): context_module.Context = SimpleNamespace context_module.MediaInfo = SimpleNamespace + class _EpisodeLocation(SimpleNamespace): + CONFIDENCE_HIGH = "high" + CONFIDENCE_LOW = "low" + MODE_ABSOLUTE_TO_SEASON = "absolute_to_season" + + @classmethod + def locate(cls, meta, target_season, target_episodes, episodes=None): + raw_episodes = episodes if episodes is not None else meta.episode_list + source_episodes = sorted(set(int(episode) for episode in raw_episodes)) + if set(source_episodes).intersection(set(target_episodes)): + return None + season_list = meta.season_list or [] + if target_season is None or len(season_list) != 1 or season_list[0] != target_season: + return None + if len(source_episodes) != len(target_episodes): + return None + if source_episodes != list(range(source_episodes[0], source_episodes[-1] + 1)): + return None + return cls( + source_episodes=source_episodes, + target_episodes=target_episodes, + mode=cls.MODE_ABSOLUTE_TO_SEASON, + confidence=cls.CONFIDENCE_HIGH, + ) + + context_module.EpisodeLocation = _EpisodeLocation + event_module = ensure_module("app.core.event", types.ModuleType("app.core.event")) class _EventManager: @@ -158,6 +184,14 @@ def _load_subscribe_chain_class(): self.convert_type = kwargs.get("convert_type") self.media_dict = kwargs.get("media_dict") + class _SubscribeEpisodesRefreshEventData: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + class _SubscribeCompletionCheckEventData: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + schemas_module.Notification = _Notification schemas_module.Subscribe = _SubscribeSchema schemas_module.NotExistMediaInfo = _NotExistMediaInfo @@ -166,6 +200,8 @@ def _load_subscribe_chain_class(): schemas_module.SubscribeDownloadFileInfo = _SubscribeDownloadFileInfo schemas_module.SubscribeLibraryFileInfo = _SubscribeLibraryFileInfo schemas_module.MediaRecognizeConvertEventData = _MediaRecognizeConvertEventData + schemas_module.SubscribeEpisodesRefreshEventData = _SubscribeEpisodesRefreshEventData + schemas_module.SubscribeCompletionCheckEventData = _SubscribeCompletionCheckEventData logger_module = ensure_module("app.log", types.ModuleType("app.log")) @@ -306,6 +342,25 @@ def _load_subscribe_chain_class(): SUBSCRIBE_CHAIN_MODULE, SubscribeChain = _load_subscribe_chain_class() +def _build_context(meta_info, selected_episodes=None, allowed_episodes=None): + """构造带集数定位能力的最小候选上下文。""" + + class _ContextStub(SimpleNamespace): + def locate_episode(self, target_season, target_episodes): + return SUBSCRIBE_CHAIN_MODULE.EpisodeLocation.locate( + meta=self.meta_info, + target_season=target_season, + target_episodes=target_episodes, + ) + + return _ContextStub( + meta_info=meta_info, + selected_episodes=selected_episodes, + allowed_episodes=allowed_episodes, + located_episodes=None, + ) + + class SubscribeChainTest(TestCase): def _build_subscribe(self, **overrides): data = { @@ -344,6 +399,7 @@ class SubscribeChainTest(TestCase): return SimpleNamespace( torrent_info=SimpleNamespace(pri_order=priority), selected_episodes=selected_episodes, + located_episodes=None, meta_info=SimpleNamespace(season_list=[1], episode_list=meta_episodes or []), ) @@ -481,6 +537,32 @@ class SubscribeChainTest(TestCase): ) ) + absolute_subscribe = self._build_subscribe( + season=5, + total_episode=26, + episode_priority={}, + ) + self.assertTrue( + SubscribeChain._is_episode_range_covered( + meta=SimpleNamespace( + season_list=[5], + episode_list=list(range(57, 83)), + total_episode=26, + ), + subscribe=absolute_subscribe, + ) + ) + self.assertFalse( + SubscribeChain._is_episode_range_covered( + meta=SimpleNamespace( + season_list=[5], + episode_list=list(range(57, 61)), + total_episode=4, + ), + subscribe=absolute_subscribe, + ) + ) + def test_full_best_version_rejects_episode_resource(self): subscribe = self._build_subscribe(best_version_full=1, total_episode=3) @@ -507,6 +589,333 @@ class SubscribeChainTest(TestCase): ) ) + def test_full_best_version_accepts_absolute_numbered_full_pack(self): + """同季全集资源使用累计总集编号时,洗版判断应按订阅季内集数定位。""" + subscribe = self._build_subscribe( + best_version_full=1, + season=5, + total_episode=26, + episode_priority={}, + ) + meta = SimpleNamespace( + season_list=[5], + episode_list=list(range(57, 83)), + total_episode=26, + ) + context = _build_context(meta_info=meta) + + self.assertTrue( + SubscribeChain._SubscribeChain__is_full_season_best_version_resource( + meta=meta, + subscribe=subscribe, + ) + ) + self.assertTrue( + SubscribeChain._is_episode_range_covered( + meta=meta, + subscribe=subscribe, + ) + ) + self.assertEqual( + SubscribeChain._SubscribeChain__get_best_version_interested_episodes( + subscribe=subscribe, + context=context, + priority=100, + ), + list(range(1, 27)), + ) + + def test_low_confidence_episode_location_does_not_drive_best_version_filter(self): + """低置信定位不能直接参与洗版过滤,只能作为后续弱提示保留。""" + subscribe = self._build_subscribe( + best_version_full=1, + season=5, + total_episode=26, + episode_priority={}, + ) + meta = SimpleNamespace( + season_list=[1], + episode_list=list(range(57, 83)), + total_episode=26, + ) + context = _build_context(meta_info=meta) + low_location = SUBSCRIBE_CHAIN_MODULE.EpisodeLocation( + source_episodes=list(range(57, 83)), + target_episodes=list(range(1, 27)), + mode=SUBSCRIBE_CHAIN_MODULE.EpisodeLocation.MODE_ABSOLUTE_TO_SEASON, + confidence=SUBSCRIBE_CHAIN_MODULE.EpisodeLocation.CONFIDENCE_LOW, + ) + + self.assertFalse( + SubscribeChain._is_episode_range_covered( + meta=meta, + subscribe=subscribe, + episode_location=low_location, + ) + ) + self.assertEqual( + SubscribeChain._SubscribeChain__get_best_version_interested_episodes( + subscribe=subscribe, + context=context, + priority=100, + episode_location=low_location, + ), + [], + ) + + def test_full_best_version_rejects_partial_absolute_numbered_pack(self): + """累计总集编号只有完整覆盖订阅目标长度时才可按整包洗版处理。""" + subscribe = self._build_subscribe( + best_version_full=1, + season=5, + total_episode=26, + episode_priority={}, + ) + meta = SimpleNamespace( + season_list=[5], + episode_list=list(range(57, 61)), + total_episode=4, + ) + + self.assertFalse( + SubscribeChain._SubscribeChain__is_full_season_best_version_resource( + meta=meta, + subscribe=subscribe, + ) + ) + self.assertFalse( + SubscribeChain._is_episode_range_covered( + meta=meta, + subscribe=subscribe, + ) + ) + + def test_full_best_version_rejects_absolute_numbered_pack_without_season_match(self): + """累计总集编号缺少同季信号时不自动映射,避免跨季合集误匹配。""" + subscribe = self._build_subscribe( + best_version_full=1, + season=5, + total_episode=26, + episode_priority={}, + ) + meta = SimpleNamespace( + season_list=[1], + episode_list=list(range(57, 83)), + total_episode=26, + ) + + self.assertFalse( + SubscribeChain._SubscribeChain__is_full_season_best_version_resource( + meta=meta, + subscribe=subscribe, + ) + ) + + def test_full_season_resource_matches_legacy_metainfo_cases(self): + """保留 #5648 覆盖过的真实标题合集识别样本,避免 MetaInfo 集成回归。""" + import app.core.metainfo as metainfo_module + from app.core.metainfo import MetaInfo + + class _SystemConfigOper: + """提供空系统配置,避免真实 MetaInfo 解析依赖测试数据库。""" + + def get(self, *args, **kwargs): + return None + + cases = [ + { + "title": "Cherry Season S01 2014 2160p 60fps WEB-DL H265 AAC-XXX", + "subtitle": "", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 51}, + "expected": True, + }, + { + "title": "【爪爪字幕组】★7月新番[欢迎来到实力至上主义的教室 第二季/Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e S2][11][1080p][HEVC][GB][MP4][招募翻译校对]", + "subtitle": "", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 13}, + "expected": False, + }, + { + "title": "[秋叶原冥途战争][Akiba Maid Sensou][2022][WEB-DL][1080][TV Series][第01话][LeagueWEB]", + "subtitle": "", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 12}, + "expected": False, + }, + { + "title": "Qi Refining for 3000 Years S01E06 2022 1080p B-Blobal WEB-DL X264 AAC-AnimeS@AdWeb", + "subtitle": "", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 16}, + "expected": False, + }, + { + "title": "The Heart of Genius S01 13-14 2022 1080p WEB-DL H264 AAC", + "subtitle": "", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 34}, + "expected": False, + }, + { + "title": "[xyx98]传颂之物/Utawarerumono/うたわれるもの[BDrip][1920x1080][TV 01-26 Fin][hevc-yuv420p10 flac_ac3][ENG PGS]", + "subtitle": "", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 26}, + "expected": True, + }, + { + "title": "I Woke Up a Vampire S02 2023 2160p NF WEB-DL DDP5.1 Atmos H 265-HHWEB", + "subtitle": "醒来变成吸血鬼 第二季 | 全8集 | 4K | 类型: 喜剧/家庭/奇幻 | 导演: TommyLynch | 主演: NikoCeci/ZebastinBorjeau/安娜·阿劳约/KaileenAngelicChang/KrisSiddiqi", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 8}, + "expected": True, + }, + { + "title": "Shadows of the Void S01 2024 1080p WEB-DL H264 AAC-HHWEB", + "subtitle": "虚无边境 | 第01-02集 | 1080p | 类型: 动画 | 导演: 巴西 | 主演: 山新/周一菡/皇贞季/Kenz/李佳怡 [内嵌中字]", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 13}, + "expected": False, + }, + { + "title": "Mai Xiang S01 2019 2160p WEB-DL H.265 DDP2.0-HHWEB", + "subtitle": "麦香 | 全36集 | 4K | 类型:剧情/爱情/家庭 | 主演:傅晶/章呈赫/王伟/沙景昌/何音", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 36}, + "expected": True, + }, + { + "title": "Jigokuraku S01E14-E25 2023 1080p CR WEB-DL x264 AAC-Nest@ADWeb", + "subtitle": "地狱乐 / 地獄楽 / Hell’s Paradise [14-25Fin] [中日双语字幕]", + "subscribe": {"season": None, "start_episode": 14, "total_episode": 25}, + "expected": True, + }, + { + "title": "Jigokuraku S01 2023 1080p BluRay Remux AVC FLAC 2.0-AnimeF@ADE", + "subtitle": "地狱乐/Hell's Paradise: Jigokuraku [01-13Fin] [中日双语字幕]", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 13}, + "expected": True, + }, + { + "title": "Jigokuraku S02E12 2026 1080p NF WEB-DL x264 AAC-ADWeb", + "subtitle": "地狱乐 第二季 地獄楽 第二期 第12集 | 类型: 动画", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 12}, + "expected": False, + }, + { + "title": "Jigokuraku S02E05-E07 2026 1080p NF WEB-DL x264 AAC-ADWeb", + "subtitle": "地狱乐 第二季 地獄楽 第二期 第05-07集 | 类型: 动画", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 12}, + "expected": False, + }, + { + "title": "Bungo Stray Dogs S01 2016 1080p KKTV WEB-DL x264 AAC-ADWeb", + "subtitle": "文豪野犬 文豪ストレイドッグス 又名: 文豪Stray Dogs 第一季 全12集 | 类型: 剧情 / 动作 / 动画 主演: 上村祐翔 / 宫野真守 / 细谷佳正 *内嵌繁体字幕*", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 12}, + "expected": True, + }, + { + "title": "Bungou Stray Dogs S1+S2+S3+OAD 1080p BDRip HEVC FLAC-Snow-Raws", + "subtitle": "文豪野犬 第1-3季", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 36}, + "expected": True, + }, + { + "title": "Bungou Stray Dogs S1+S2+S3+OAD 1080p BDRip HEVC FLAC-Snow-Raws", + "subtitle": "文豪野犬 第1-3季", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 60}, + "expected": True, + }, + { + "title": "Fu Gui S01 2005 2160p WEB-DL H265 AAC-HHWEB", + "subtitle": "福贵 | 全33集 | 4K | 类型: 剧情/家庭 | 导演: 朱正/袁进 | 主演: 陈创/刘敏涛/李丁/张鹰/温玉娟", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 33}, + "expected": True, + }, + { + "title": "The Story of Ming Lan S01 2018 2160p WEB-DL CHDWEB", + "subtitle": "知否知否应是绿肥红瘦 全78集 | 2160p | 国语/中字 | 60帧高码TV版 | 类型:剧情/爱情/古装 | 主演:赵丽颖/冯绍峰/朱一龙/施诗/张佳宁", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 78}, + "expected": True, + }, + { + "title": "Love Beyond the Grave S01 2026 2160p WEB-DL H265 AAC-HHWEB", + "subtitle": "白日提灯 / 慕胥辞 | 第18集 | 4K | 类型: 剧情 | 导演: 秦榛 | 主演: 迪丽热巴/陈飞宇/魏哲鸣/张俪/高鹤元", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 40}, + "expected": False, + }, + { + "title": "The Long Ballad S01 2021 2160p WEB-DL H265 AAC-HHWEB", + "subtitle": "长歌行 | 全49集 | 4K | 类型: 剧情/爱情/古装 | 主演: 迪丽热巴/吴磊/刘宇宁/赵露思/方逸伦", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 49}, + "expected": True, + }, + { + "title": "The Long Ballad S01E01-E04 2021 2160p WEB-DL H265 AAC-HHWEB", + "subtitle": "长歌行 | 第01-04集 | 4K | 类型: 剧情/爱情/古装 | 主演: 迪丽热巴/吴磊/刘宇宁/赵露思/方逸伦", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 49}, + "expected": False, + }, + { + "title": "Spy x Family S02 2023 1080p Baha WEB-DL x264 AAC-ADWeb", + "subtitle": "间谍过家家 第二季 / SPY×FAMILY Season 2 [01-12Fin] [简繁内封字幕]", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 12}, + "expected": True, + }, + { + "title": "Spy x Family S02E03-E07 2023 1080p Baha WEB-DL x264 AAC-ADWeb", + "subtitle": "间谍过家家 第二季 / SPY×FAMILY Season 2 第03-07集 [简繁内封字幕]", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 12}, + "expected": False, + }, + { + "title": "Naruto Shippuden S01-S21 Complete 1080p BluRay x264 AAC-ADWeb", + "subtitle": "火影忍者 疾风传 全500集 [1080p][简中字幕]", + "subscribe": {"season": None, "start_episode": 1, "total_episode": 500}, + "expected": True, + }, + { + "title": "Naruto Shippuden S01-S21 Complete 1080p BluRay x264 AAC-ADWeb", + "subtitle": "火影忍者 疾风传 第01-500集 [1080p][简中字幕]", + "subscribe": {"season": None, "start_episode": 201, "total_episode": 500}, + "expected": True, + }, + { + "title": "Immortality S05 2025 2160p WEB-DL HDR H265 AAC-ADWeb", + "subtitle": "永生 第五季 全26集 总第57-82集 / 永生之太元仙府 / 永生 新篇章 / Immortality: Taiyuan Immortal Mansion [Bilibili大陆] | 类型:动作 动画 奇幻", + "subscribe": {"season": 5, "start_episode": 1, "total_episode": 26}, + "expected": True, + }, + { + "title": "Immortality S05 2025 2160p WEB-DL HDR H265 AAC-ADWeb", + "subtitle": "永生 第五季 第57-60集 / 永生之太元仙府", + "subscribe": {"season": 5, "start_episode": 1, "total_episode": 26}, + "expected": False, + }, + ] + + metainfo_module._rust_default_parse_options.cache_clear() + metainfo_module._rust_custom_parse_options.cache_clear() + with patch("app.db.systemconfig_oper.SystemConfigOper", _SystemConfigOper), patch( + "app.core.meta.releasegroup.SystemConfigOper", _SystemConfigOper + ), patch( + "app.core.meta.words.SystemConfigOper", _SystemConfigOper + ), patch( + "app.core.meta.customization.SystemConfigOper", _SystemConfigOper + ): + for case in cases: + meta = MetaInfo( + title=case["title"], subtitle=case["subtitle"], custom_words=["#"] + ) + subscribe = self._build_subscribe( + best_version_full=1, + episode_priority={}, + current_priority=0, + **case["subscribe"], + ) + + with self.subTest(title=case["title"], subtitle=case["subtitle"]): + self.assertEqual( + SubscribeChain._SubscribeChain__is_full_season_resource( + meta=meta, + subscribe=subscribe, + ), + case["expected"], + ) + def test_episode_best_version_downloads_full_pack_before_episode_fallback(self): subscribe = self._build_subscribe(best_version_full=0, total_episode=3) full_pack_context = SimpleNamespace( @@ -773,6 +1182,49 @@ class SubscribeChainTest(TestCase): self.assertNotIn("lack_episode", payload) finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo) + def test_update_subscribe_priority_normalizes_absolute_numbered_full_pack(self): + """累计总集编号整包下载完成后,应按订阅季内集数更新洗版状态。""" + subscribe = self._build_subscribe( + best_version_full=1, + season=5, + total_episode=26, + episode_priority={}, + current_priority=0, + lack_episode=26, + ) + download = SimpleNamespace( + torrent_info=SimpleNamespace(pri_order=100), + selected_episodes=None, + located_episodes=None, + meta_info=SimpleNamespace( + season_list=[5], + episode_list=list(range(57, 83)), + total_episode=26, + ), + ) + chain = SubscribeChain() + meta = SimpleNamespace() + mediainfo = SimpleNamespace(title_year="Immortality (2025)") + + with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper") as subscribe_oper_cls, patch.object( + SubscribeChain, + "_SubscribeChain__finish_subscribe", + ) as finish_mock: + subscribe_oper = subscribe_oper_cls.return_value + subscribe_oper.update.return_value = None + + chain.update_subscribe_priority( + subscribe=subscribe, + meta=meta, + mediainfo=mediainfo, + downloads=[download], + ) + + payload = subscribe_oper.update.call_args.args[1] + self.assertEqual(payload["episode_priority"], {str(ep): 100 for ep in range(1, 27)}) + self.assertEqual(payload["current_priority"], 100) + finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo) + def test_full_best_version_updates_all_episodes_when_pack_has_no_episode_metadata(self): subscribe = self._build_subscribe( best_version_full=1, @@ -887,10 +1339,7 @@ class SubscribeChainTest(TestCase): episode_priority={"1": 100, "2": 99}, current_priority=100, ) - context = SimpleNamespace( - meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]), - selected_episodes=None, - ) + context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3])) interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes( subscribe=subscribe, @@ -915,10 +1364,7 @@ class SubscribeChainTest(TestCase): }, current_priority=99, ) - context = SimpleNamespace( - meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))), - selected_episodes=None, - ) + context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105)))) interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes( subscribe=subscribe, @@ -949,10 +1395,7 @@ class SubscribeFilterAllowedEpisodesTest(TestCase): }, current_priority=99, ) - context = SimpleNamespace( - meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))), - selected_episodes=None, - ) + context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105)))) interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes( subscribe=subscribe, @@ -975,10 +1418,7 @@ class SubscribeFilterAllowedEpisodesTest(TestCase): episode_priority={"1": 100, "2": 99, "3": 99}, current_priority=99, ) - context = SimpleNamespace( - meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]), - selected_episodes=None, - ) + context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3])) interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes( subscribe=subscribe, @@ -1007,10 +1447,8 @@ class SubscribeFilterAllowedEpisodesTest(TestCase): }, current_priority=99, ) - original_context = SimpleNamespace( - meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))), - selected_episodes=None, - allowed_episodes=None, + original_context = _build_context( + meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))) ) _context = copy.copy(original_context)