From b76c4edc4ada64307ceb3cb0f2ae40f1704da2f6 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 15 May 2026 06:50:16 +0800 Subject: [PATCH] fix(subscribe): prefer full packs for episode upgrades (#5771) --- app/chain/subscribe.py | 152 ++++++++++++++++++++--- tests/test_subscribe_chain.py | 225 +++++++++++++++++++++++++++++++++- 2 files changed, 360 insertions(+), 17 deletions(-) diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index a727a28d..11e61265 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -304,13 +304,10 @@ class SubscribeChain(ChainBase): ) @classmethod - def __is_full_season_best_version_resource(cls, meta: MetaBase, subscribe: Subscribe) -> bool: + def __is_full_season_resource(cls, meta: MetaBase, subscribe: Subscribe) -> bool: """ - 判断候选资源是否覆盖订阅目标的全集范围。 + 判断候选资源是否覆盖订阅目标全集范围。 """ - if not cls.__is_full_best_version_enabled(subscribe): - return True - season_list = meta.season_list or [1] if len(season_list) != 1: return False @@ -327,6 +324,125 @@ class SubscribeChain(ChainBase): return False return target_episodes.issubset(set(episodes)) + @classmethod + def __is_full_season_best_version_resource(cls, meta: MetaBase, subscribe: Subscribe) -> bool: + """ + 判断候选资源是否符合全集洗版资源约束。 + """ + if not cls.__is_full_best_version_enabled(subscribe): + return True + + 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: + """ + 判断整季资源优先级是否高于订阅目标范围内所有分集。 + """ + if subscribe.type != MediaType.TV.value: + return False + + target_episodes = cls.__get_best_version_target_episodes(subscribe) + if not target_episodes: + return False + + try: + resource_priority = int(priority or 0) + except (TypeError, ValueError): + resource_priority = 0 + + episode_priority = cls.__get_episode_priority(subscribe) + for episode in target_episodes: + current_priority = episode_priority.get(str(episode), 0) + if resource_priority <= current_priority: + return False + return True + + @classmethod + def __build_full_pack_first_no_exists( + cls, + subscribe: Subscribe, + mediakey: Union[int, str], + ) -> Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]: + """ + 构造分集洗版优先全集时使用的整季缺失范围。 + """ + if ( + not subscribe.best_version + or cls.__is_full_best_version_enabled(subscribe) + or subscribe.type != MediaType.TV.value + ): + return None + + target_episodes = cls.__get_best_version_target_episodes(subscribe) + if not target_episodes: + return None + + return { + mediakey: { + subscribe.season: schemas.NotExistMediaInfo( + season=subscribe.season, + episodes=[], + total_episode=subscribe.total_episode, + start_episode=subscribe.start_episode or 1, + ) + } + } + + def __download_best_version_with_full_pack_first( + self, + contexts: List[Context], + no_exists: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]], + subscribe: Subscribe, + mediakey: Union[int, str], + username: Optional[str] = None, + save_path: Optional[str] = None, + downloader: Optional[str] = None, + source: Optional[str] = None, + ) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]: + """ + TV 分集洗版先尝试覆盖目标范围的全集资源,失败后回退到按集下载。 + """ + full_pack_no_exists = self.__build_full_pack_first_no_exists(subscribe=subscribe, mediakey=mediakey) + full_season_contexts = [ + context for context in contexts + if context.media_info.type == MediaType.TV + and self.__is_full_season_resource(meta=context.meta_info, subscribe=subscribe) + ] if full_pack_no_exists else [] + full_pack_contexts = [ + context for context in full_season_contexts + if self.__is_full_season_priority_higher_than_all_targets( + subscribe=subscribe, + priority=context.torrent_info.pri_order, + ) + ] + + if full_season_contexts and not full_pack_contexts: + logger.info(f"{subscribe.name} 全集候选优先级未高于所有目标集,回退到分集洗版") + + if full_pack_contexts: + logger.info(f"{subscribe.name} 分集洗版优先尝试全集资源,共匹配到 {len(full_pack_contexts)} 个候选") + downloads, lefts = DownloadChain().batch_download( + contexts=full_pack_contexts, + no_exists=full_pack_no_exists, + username=username, + save_path=save_path, + downloader=downloader, + source=source, + ) + if downloads: + return downloads, lefts + logger.info(f"{subscribe.name} 未下载到全集资源,回退到分集洗版") + + return DownloadChain().batch_download( + contexts=contexts, + no_exists=no_exists, + username=username, + save_path=save_path, + downloader=downloader, + source=source, + ) + @staticmethod def __get_event_media(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]: """ @@ -947,9 +1063,11 @@ class SubscribeChain(ChainBase): continue # 自动下载 - downloads, lefts = DownloadChain().batch_download( + downloads, lefts = self.__download_best_version_with_full_pack_first( contexts=matched_contexts, no_exists=no_exists, + subscribe=subscribe, + mediakey=mediakey, username=subscribe.username, save_path=subscribe.save_path, downloader=subscribe.downloader, @@ -1005,8 +1123,8 @@ class SubscribeChain(ChainBase): for download in downloads: download_priority = download.torrent_info.pri_order downloaded_episodes = self.__get_downloaded_episodes([download]) - if self.__is_full_best_version_enabled(subscribe) and not 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) if not downloaded_episodes: continue @@ -1474,14 +1592,16 @@ class SubscribeChain(ChainBase): # 开始批量择优下载 logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源') - downloads, lefts = DownloadChain().batch_download(contexts=_match_context, - no_exists=no_exists, - username=subscribe.username, - save_path=subscribe.save_path, - downloader=subscribe.downloader, - source=self.get_subscribe_source_keyword( - subscribe) - ) + downloads, lefts = self.__download_best_version_with_full_pack_first( + contexts=_match_context, + no_exists=no_exists, + subscribe=subscribe, + mediakey=mediakey, + username=subscribe.username, + save_path=subscribe.save_path, + downloader=subscribe.downloader, + source=self.get_subscribe_source_keyword(subscribe) + ) # 同步外部修改,更新订阅信息 subscribe = SubscribeOper().get(subscribe.id) diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 90bfbd9f..71bbe826 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -310,7 +310,7 @@ class SubscribeChainTest(TestCase): return SimpleNamespace( torrent_info=SimpleNamespace(pri_order=priority), selected_episodes=selected_episodes, - meta_info=SimpleNamespace(episode_list=meta_episodes or []), + meta_info=SimpleNamespace(season_list=[1], episode_list=meta_episodes or []), ) def test_get_episode_priority_falls_back_to_current_priority(self): @@ -408,6 +408,196 @@ class SubscribeChainTest(TestCase): ) ) + 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( + torrent_info=SimpleNamespace(pri_order=90), + media_info=SimpleNamespace(type=MediaType.TV), + meta_info=SimpleNamespace(season_list=[1], episode_list=[]), + ) + episode_context = SimpleNamespace( + torrent_info=SimpleNamespace(pri_order=90), + media_info=SimpleNamespace(type=MediaType.TV), + meta_info=SimpleNamespace(season_list=[1], episode_list=[2]), + ) + no_exists = { + "media-key": { + 1: SimpleNamespace(season=1, episodes=[2], total_episode=3, start_episode=1) + } + } + calls = [] + + class _FakeDownloadChain: + """记录批量下载调用,用于验证分集洗版会先尝试全集资源。""" + + def batch_download(self, **kwargs): + calls.append(kwargs) + return [full_pack_context], {} + + with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _FakeDownloadChain): + downloads, lefts = SubscribeChain()._SubscribeChain__download_best_version_with_full_pack_first( + contexts=[episode_context, full_pack_context], + no_exists=no_exists, + subscribe=subscribe, + mediakey="media-key", + username="user", + save_path="/downloads", + downloader="qb", + source="subscribe", + ) + + self.assertEqual(downloads, [full_pack_context]) + self.assertEqual(lefts, {}) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["contexts"], [full_pack_context]) + self.assertEqual(calls[0]["no_exists"]["media-key"][1].episodes, []) + + def test_episode_best_version_falls_back_when_full_pack_not_downloaded(self): + subscribe = self._build_subscribe(best_version_full=0, total_episode=3) + full_pack_context = SimpleNamespace( + torrent_info=SimpleNamespace(pri_order=90), + media_info=SimpleNamespace(type=MediaType.TV), + meta_info=SimpleNamespace(season_list=[1], episode_list=[]), + ) + episode_context = SimpleNamespace( + torrent_info=SimpleNamespace(pri_order=90), + media_info=SimpleNamespace(type=MediaType.TV), + meta_info=SimpleNamespace(season_list=[1], episode_list=[2]), + ) + no_exists = { + "media-key": { + 1: SimpleNamespace(season=1, episodes=[2], total_episode=3, start_episode=1) + } + } + calls = [] + + class _FakeDownloadChain: + """模拟全集下载失败,验证后续会回退到按集下载。""" + + def batch_download(self, **kwargs): + calls.append(kwargs) + if len(calls) == 1: + return [], kwargs["no_exists"] + return [episode_context], {} + + with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _FakeDownloadChain): + downloads, lefts = SubscribeChain()._SubscribeChain__download_best_version_with_full_pack_first( + contexts=[episode_context, full_pack_context], + no_exists=no_exists, + subscribe=subscribe, + mediakey="media-key", + ) + + self.assertEqual(downloads, [episode_context]) + self.assertEqual(lefts, {}) + self.assertEqual(len(calls), 2) + self.assertEqual(calls[0]["contexts"], [full_pack_context]) + self.assertIs(calls[1]["no_exists"], no_exists) + + def test_episode_best_version_skips_full_pack_first_when_pack_priority_equals_existing_episode(self): + """验证全集优先级等于目标分集时回退到分集下载。""" + subscribe = self._build_subscribe( + best_version_full=0, + total_episode=3, + episode_priority={"1": 80, "2": 80, "3": 80}, + current_priority=80, + ) + full_pack_context = SimpleNamespace( + torrent_info=SimpleNamespace(pri_order=80), + media_info=SimpleNamespace(type=MediaType.TV), + meta_info=SimpleNamespace(season_list=[1], episode_list=[]), + ) + episode_context = SimpleNamespace( + torrent_info=SimpleNamespace(pri_order=90), + media_info=SimpleNamespace(type=MediaType.TV), + meta_info=SimpleNamespace(season_list=[1], episode_list=[2]), + ) + no_exists = { + "media-key": { + 1: SimpleNamespace(season=1, episodes=[2], total_episode=3, start_episode=1) + } + } + calls = [] + + class _FakeDownloadChain: + """记录回退下载调用,确保全集候选仍可参与拆包匹配。""" + + def batch_download(self, **kwargs): + calls.append(kwargs) + return [episode_context], {} + + with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _FakeDownloadChain): + downloads, lefts = SubscribeChain()._SubscribeChain__download_best_version_with_full_pack_first( + contexts=[episode_context, full_pack_context], + no_exists=no_exists, + subscribe=subscribe, + mediakey="media-key", + ) + + self.assertEqual(downloads, [episode_context]) + self.assertEqual(lefts, {}) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["contexts"], [episode_context, full_pack_context]) + self.assertIs(calls[0]["no_exists"], no_exists) + + def test_episode_best_version_skips_full_pack_first_when_pack_priority_below_one_episode(self): + """验证全集低于任一目标分集优先级时不会整包优先。""" + subscribe = self._build_subscribe( + best_version_full=0, + total_episode=3, + episode_priority={"1": 90, "2": 80, "3": 80}, + current_priority=80, + ) + full_pack_context = SimpleNamespace( + torrent_info=SimpleNamespace(pri_order=85), + media_info=SimpleNamespace(type=MediaType.TV), + meta_info=SimpleNamespace(season_list=[1], episode_list=[]), + ) + no_exists = { + "media-key": { + 1: SimpleNamespace(season=1, episodes=[2], total_episode=3, start_episode=1) + } + } + calls = [] + + class _FakeDownloadChain: + """记录回退下载调用,验证低优先级全集不进入整包优先分支。""" + + def batch_download(self, **kwargs): + calls.append(kwargs) + return [], kwargs["no_exists"] + + with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _FakeDownloadChain): + downloads, lefts = SubscribeChain()._SubscribeChain__download_best_version_with_full_pack_first( + contexts=[full_pack_context], + no_exists=no_exists, + subscribe=subscribe, + mediakey="media-key", + ) + + self.assertEqual(downloads, []) + self.assertIs(lefts, no_exists) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["contexts"], [full_pack_context]) + self.assertIs(calls[0]["no_exists"], no_exists) + + 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( + subscribe=subscribe, + priority=80, + ) + ) + self.assertTrue( + SubscribeChain._SubscribeChain__is_full_season_priority_higher_than_all_targets( + subscribe=subscribe, + priority=81, + ) + ) + def test_update_subscribe_priority_uses_selected_episodes(self): subscribe = self._build_subscribe( total_episode=4, @@ -515,6 +705,39 @@ class SubscribeChainTest(TestCase): self.assertEqual(payload["lack_episode"], 0) finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo) + def test_episode_best_version_updates_all_episodes_when_full_pack_has_no_episode_metadata(self): + subscribe = self._build_subscribe( + best_version_full=0, + total_episode=3, + episode_priority={"1": 80, "2": 80, "3": 80}, + current_priority=80, + lack_episode=3, + ) + download = self._build_download(priority=100, selected_episodes=[], meta_episodes=[]) + chain = SubscribeChain() + meta = SimpleNamespace() + mediainfo = SimpleNamespace(title_year="Test Show (2026)") + + 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"], {"1": 100, "2": 100, "3": 100}) + self.assertEqual(payload["current_priority"], 100) + self.assertEqual(payload["lack_episode"], 0) + finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo) + def test_check_resets_current_priority_when_new_episodes_expand_target_range(self): subscribe = self._build_subscribe( total_episode=3,