From 30b932e07e84d20df247258ebc0923c712ab6f5f Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:18:29 +0800 Subject: [PATCH] fix(subscribe): preserve confirmed episode floor (#6102) --- app/chain/subscribe.py | 91 ++++++++++++++++++++++++++++++++++- tests/test_subscribe_chain.py | 71 +++++++++++++++++++++------ 2 files changed, 146 insertions(+), 16 deletions(-) diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index fb73db6c..dc60f313 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -1993,6 +1993,14 @@ class SubscribeChain(ChainBase): tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, subscribe_id=subscribe.id, scene="refresh") old_total_episode = subscribe.total_episode or 0 + if total_episode and total_episode < old_total_episode: + total_episode = self.__resolve_total_episode_decrease( + subscribe=subscribe, + candidate_total=total_episode, + meta=meta, + mediainfo=mediainfo, + mediakey=subscribe.tmdbid or subscribe.doubanid, + ) if total_episode and total_episode != old_total_episode: progress_update = self.__prepare_total_episode_change_fields( subscribe=subscribe, @@ -3655,7 +3663,12 @@ class SubscribeChain(ChainBase): - exist_flag (bool): 布尔值,表示媒体是否已经完全下载或已存在 - no_exists (dict): 缺失的媒体信息,包含缺失的集数或其他相关信息 """ - self.__refresh_total_episode_before_completion(subscribe=subscribe, mediainfo=mediainfo) + self.__refresh_total_episode_before_completion( + subscribe=subscribe, + mediainfo=mediainfo, + meta=meta, + mediakey=mediakey, + ) exist_flag, no_exists = self.resolve_subscribe_missing( subscribe=subscribe, @@ -3759,6 +3772,66 @@ class SubscribeChain(ChainBase): return bool(downloaded), no_exists return False, no_exists + def __resolve_total_episode_decrease( + self, + subscribe: Subscribe, + candidate_total: int, + meta: MetaBase, + mediainfo: MediaInfo, + mediakey: Optional[Union[str, int]] = None, + ) -> int: + """以旧目标范围内已确认存在的最高集号限制总集数回落。""" + old_total = subscribe.total_episode or 0 + if candidate_total >= old_total or not old_total: + return candidate_total + if subscribe.type != MediaType.TV.value or self.__is_full_best_version_enabled(subscribe): + return candidate_total + + target_key = mediakey or subscribe.tmdbid or subscribe.doubanid + target_season = subscribe.season + target_start = subscribe.start_episode or 1 + snapshot = copy.copy(subscribe) + snapshot.total_episode = old_total + try: + satisfied, no_exists = self.resolve_subscribe_missing( + subscribe=snapshot, + meta=meta, + mediainfo=mediainfo, + mediakey=target_key, + best_version_accept_downloaded=bool(subscribe.best_version), + ) + except Exception as err: + logger.warning(f"订阅 {subscribe.name} 已存在分集事实查询失败,按元数据总集数继续:{err}") + return candidate_total + + if satisfied: + return old_total + if not isinstance(no_exists, dict): + return candidate_total + seasons = no_exists.get(target_key) + if not isinstance(seasons, dict): + return candidate_total + missing_info = seasons.get(target_season) + if not missing_info: + return candidate_total + try: + scope_matches = missing_info.season == target_season \ + and missing_info.start_episode == target_start \ + and missing_info.total_episode == old_total + episodes = missing_info.episodes + except AttributeError: + return candidate_total + if not scope_matches: + return candidate_total + if not isinstance(episodes, list) or not episodes: + return candidate_total + if any(isinstance(episode, bool) or not isinstance(episode, int) + or episode < target_start or episode > old_total for episode in episodes): + return candidate_total + + confirmed = set(range(target_start, old_total + 1)).difference(episodes) + return max(candidate_total, max(confirmed) if confirmed else 0) + @staticmethod def __resolve_effective_total_episode(subscribe: Subscribe, mediainfo: MediaInfo) -> int: """ @@ -3828,7 +3901,13 @@ class SubscribeChain(ChainBase): return result.total_episode return current_total - def __refresh_total_episode_before_completion(self, subscribe: Subscribe, mediainfo: MediaInfo): + def __refresh_total_episode_before_completion( + self, + subscribe: Subscribe, + mediainfo: MediaInfo, + meta: Optional[MetaBase] = None, + mediakey: Optional[Union[str, int]] = None, + ) -> None: """ 在完成判断前,按最新识别结果兜底修正订阅总集数,防止旧总集数导致误完成。 """ @@ -3846,6 +3925,14 @@ class SubscribeChain(ChainBase): tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, subscribe_id=subscribe.id, scene="precheck") old_total_episode = subscribe.total_episode or 0 + if meta is not None and new_total_episode and new_total_episode < old_total_episode: + new_total_episode = self.__resolve_total_episode_decrease( + subscribe=subscribe, + candidate_total=new_total_episode, + meta=meta, + mediainfo=mediainfo, + mediakey=mediakey, + ) if not new_total_episode or new_total_episode == old_total_episode: return diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 1f87f62d..38da0ac6 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -1004,6 +1004,25 @@ class SubscribeChainTest(TestCase): self.assertTrue(satisfied) self.assertEqual(no_exists, {}) + def test_total_episode_decrease_rejects_invalid_missing_scope(self): + subscribe = self._build_subscribe(best_version=0, total_episode=100, note=[]) + missing_info = SimpleNamespace( + episodes=list(range(91, 101)), + require_complete_coverage=False, + ) + chain = SubscribeChain() + + with patch.object(chain, "resolve_subscribe_missing", return_value=(False, {1: {1: missing_info}})): + total_episode = chain._SubscribeChain__resolve_total_episode_decrease( + subscribe=subscribe, + candidate_total=1, + meta=SimpleNamespace(type=MediaType.TV, begin_season=1, season=1), + mediainfo=SimpleNamespace(type=MediaType.TV, seasons={1: [1]}), + mediakey=1, + ) + + self.assertEqual(total_episode, 1) + def test_resolve_subscribe_missing_accepts_downloaded_legacy_current_priority_targets(self): """外部完成守卫读取按集事实时,应保留 current_priority 整体快照兼容。""" subscribe = self._build_subscribe( @@ -1149,7 +1168,7 @@ class SubscribeChainTest(TestCase): mediainfo = SimpleNamespace(type=MediaType.TV, title_year="Test Show (2026)") calls = [] - def fake_refresh(_self, subscribe, mediainfo): + def fake_refresh(_self, subscribe, mediainfo, meta=None, mediakey=None): calls.append(("refresh", subscribe.total_episode)) subscribe.total_episode = 20 @@ -2548,7 +2567,7 @@ class SubscribeProgressConsolidationTest(TestCase): self.assertEqual(updates[-1][1]["lack_episode"], 2) self.assertEqual(updates[-1][1]["current_priority"], 0) - def test_refresh_total_episode_before_completion_follows_recognized_total_decrease(self): + def test_refresh_total_episode_before_completion_keeps_downloaded_best_version_floor(self): module, SubscribeChain = _load_subscribe_chain_class() subscribe = module.Subscribe( id=34, @@ -2558,10 +2577,10 @@ class SubscribeProgressConsolidationTest(TestCase): total_episode=100, start_episode=1, lack_episode=100, - best_version=0, + best_version=1, best_version_full=0, current_priority=None, - episode_priority={}, + episode_priority={str(episode): 80 for episode in range(1, 101)}, note=[], tmdbid=31034, doubanid=None, @@ -2574,21 +2593,31 @@ class SubscribeProgressConsolidationTest(TestCase): def update(self, subscribe_id, payload): updates.append((subscribe_id, payload)) + chain = SubscribeChain() + resolve_calls = [] + + def _resolve_missing(**kwargs): + resolve_calls.append(kwargs) + return True, {} + + chain.resolve_subscribe_missing = _resolve_missing + with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object( module, "eventmanager", eventmanager, ): - SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion( + chain._SubscribeChain__refresh_total_episode_before_completion( subscribe, - self._mediainfo(total_episode=90), + self._mediainfo(total_episode=1), + meta=SimpleNamespace(type=MediaType.TV, begin_season=1, season=1), + mediakey=31034, ) - self.assertEqual(captured[0][1].current_total_episode, 90) - self.assertEqual(subscribe.total_episode, 90) - self.assertEqual(subscribe.lack_episode, 90) - self.assertEqual(updates[-1][1]["total_episode"], 90) - self.assertEqual(updates[-1][1]["lack_episode"], 90) + self.assertEqual(captured[0][1].current_total_episode, 1) + self.assertEqual(subscribe.total_episode, 100) + self.assertEqual(updates, []) + self.assertTrue(resolve_calls[0]["best_version_accept_downloaded"]) def test_refresh_total_episode_before_completion_filters_best_version_priority_on_decrease(self): module, SubscribeChain = _load_subscribe_chain_class() @@ -2950,7 +2979,7 @@ class SubscribeProgressConsolidationTest(TestCase): self.assertEqual(payload["total_episode"], 120) self.assertEqual(payload["lack_episode"], 120) - def test_check_total_refresh_follows_recognized_total_decrease(self): + def test_check_total_refresh_uses_confirmed_episode_floor(self): module, SubscribeChain = _load_subscribe_chain_class() subscribe = module.Subscribe( id=43, @@ -2982,7 +3011,21 @@ class SubscribeProgressConsolidationTest(TestCase): updates.append((subscribe_id, payload)) chain = SubscribeChain() - chain.recognize_media = lambda **kwargs: self._mediainfo(total_episode=90) + chain.recognize_media = lambda **kwargs: self._mediainfo(total_episode=1) + chain.resolve_subscribe_missing = lambda **kwargs: ( + False, + { + 31043: { + 1: SimpleNamespace( + season=1, + episodes=list(range(91, 101)), + total_episode=100, + start_episode=1, + require_complete_coverage=False, + ) + } + }, + ) with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object( module, @@ -2992,7 +3035,7 @@ class SubscribeProgressConsolidationTest(TestCase): chain.check() payload = updates[-1][1] - self.assertEqual(captured[0][1].current_total_episode, 90) + self.assertEqual(captured[0][1].current_total_episode, 1) self.assertEqual(payload["total_episode"], 90) self.assertEqual(payload["lack_episode"], 90)