From f5eeeebebae9900afec0cc6b3fd561114680c389 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 18 May 2026 17:09:56 +0800 Subject: [PATCH] fix(subscribe): persist best-version downloads to note and read it back (#5783) --- app/chain/subscribe.py | 15 +++- tests/test_subscribe_chain.py | 148 ++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 38a9a75d..03187921 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -1189,11 +1189,14 @@ class SubscribeChain(ChainBase): mediakey = subscribe.tmdbid or subscribe.doubanid # 是否有剩余集 no_lefts = not lefts or not lefts.get(mediakey) + # 不论是否洗版,只要本轮有下载产生就要把集数追加进 subscribe.note, + # 保证"已下载过哪些集"这条事实在所有订阅模式下都有可靠落点;洗版分支 + # 之前只写 episode_priority,导致用户切回普通订阅时丢失下载历史,并让 + # __get_downloaded 在洗版下无法从 note 拿到 priority 未达 100 但实际下过的集。 + if downloads: + self.__update_subscribe_note(subscribe=subscribe, downloads=downloads) # 是否完成订阅 if not subscribe.best_version: - # 订阅存在待定策略,不管是否已完成,均需更新订阅信息 - # 更新订阅已下载信息 - self.__update_subscribe_note(subscribe=subscribe, downloads=downloads) # 更新订阅剩余集数和时间 self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, mediainfo=mediainfo, update_date=bool(downloads)) @@ -1856,7 +1859,11 @@ class SubscribeChain(ChainBase): @staticmethod def __get_downloaded(subscribe: Subscribe) -> List[int]: """ - 获取已下载过的集数或电影 + 获取已下载过的集数或电影。 + + 洗版分支只返回 priority==100 的完成集;priority<100 的集仍要继续搜索更高 + 优先级版本,不能并入返回值(会让下游把 pending 减空、订阅卡死)。 + note 由非洗版分支消费,用于洗版关闭后的迁移读取。 """ if subscribe.best_version: if subscribe.type == MediaType.TV.value: diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index a9d3e310..bcc561f4 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -925,3 +925,151 @@ class SubscribeFilterAllowedEpisodesTest(TestCase): self.assertEqual(_context.allowed_episodes, set(range(84, 93))) # 浅拷贝 + 新字段写入不应反向污染源 context(match() 中 contexts 缓存可能跨多次匹配复用)。 self.assertIsNone(original_context.allowed_episodes) + + +class SubscribeNoteTrackingTest(TestCase): + """覆盖洗版与非洗版下 subscribe.note 的下载历史追踪。 + + 回归目标:finish_subscribe_or_not 必须在所有订阅模式下都把本轮下载的集数追加进 + subscribe.note;__get_downloaded 在洗版分支必须把 note 与 episode_priority==100 + 的完成集合并返回,避免迁移或低优先级下载场景下已下集被误判为"未下载"。 + """ + + def _build_subscribe(self, **overrides): + return SubscribeChainTest()._build_subscribe(**overrides) + + @staticmethod + def _build_download_context(episodes): + """构造一个最小化下载 context:只携带 finish_subscribe_or_not / __update_subscribe_note 路径会读到的字段。""" + return SimpleNamespace( + meta_info=SimpleNamespace(season_list=[1], episode_list=list(episodes)), + media_info=SimpleNamespace( + type=MediaType.TV, + tmdb_id=1, + douban_id=None, + ), + torrent_info=SimpleNamespace(pri_order=99, title="fake-torrent"), + selected_episodes=list(episodes), + ) + + def test_finish_subscribe_writes_note_for_best_version_downloads(self): + """洗版分支若产生 downloads,subscribe.note 必须被追加,不再被 best_version 标志拦截。 + + 旧逻辑只在非洗版分支调用 __update_subscribe_note,导致 best_version=1 时 + 下载历史只落在 episode_priority;用户切回普通订阅或排障对账时缺失"下过哪些集" + 的事实源。这条用例验证修复后两个分支都会写 note。 + """ + subscribe = self._build_subscribe( + best_version=1, + total_episode=92, + episode_priority={"1": 100}, + note=[1], + ) + chain = SubscribeChain() + downloads = [self._build_download_context([83])] + + captured_updates = [] + + class _SubscribeOper: + def update(self, subscribe_id, payload): + captured_updates.append((subscribe_id, payload)) + + def get(self, *args, **kwargs): + return subscribe + + with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper", _SubscribeOper), patch.object( + SubscribeChain, + "update_subscribe_priority", + ), patch.object( + SubscribeChain, + "_SubscribeChain__finish_subscribe", + ): + chain.finish_subscribe_or_not( + subscribe=subscribe, + meta=SimpleNamespace(type=MediaType.TV), + mediainfo=SimpleNamespace(title_year="Test Show (2026)", type=MediaType.TV, + tmdb_id=1, douban_id=None), + downloads=downloads, + lefts=None, + ) + + # note 更新必然发生在 SubscribeOper.update 上,定位"note" 键的最近一次写入。 + note_writes = [payload["note"] for _, payload in captured_updates if "note" in payload] + self.assertTrue(note_writes, "best_version downloads should still trigger note update") + self.assertIn(83, note_writes[-1]) + self.assertIn(1, note_writes[-1]) # 既有 note 保留 + + def test_finish_subscribe_skips_note_when_no_downloads(self): + """没有 downloads 时不应触碰 note,避免空写入或误清除。""" + subscribe = self._build_subscribe(best_version=1, total_episode=92, note=[1, 2]) + chain = SubscribeChain() + + captured_updates = [] + + class _SubscribeOper: + def update(self, subscribe_id, payload): + captured_updates.append((subscribe_id, payload)) + + def get(self, *args, **kwargs): + return subscribe + + with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper", _SubscribeOper), patch.object( + SubscribeChain, + "_SubscribeChain__is_best_version_complete", + return_value=False, + ), patch.object( + SubscribeChain, + "_SubscribeChain__finish_subscribe", + ): + chain.finish_subscribe_or_not( + subscribe=subscribe, + meta=SimpleNamespace(type=MediaType.TV), + mediainfo=SimpleNamespace(title_year="Test Show (2026)", type=MediaType.TV, + tmdb_id=1, douban_id=None), + downloads=None, + lefts=None, + ) + + # 无下载时不应该有 note 写入。 + self.assertFalse( + [payload for _, payload in captured_updates if "note" in payload], + "note must not be touched when downloads is empty", + ) + + def test_get_downloaded_best_version_returns_only_completed_episodes(self): + """关键回归:洗版分支不得把 note 合并进 __get_downloaded 返回值。 + + 否则 check_and_handle_existing_media → __get_subscribe_no_exits 会把 + priority<100 但已下载的集从 pending no_exists 中减掉,配合 force=True 但 + __is_best_version_complete=False 的 finish_subscribe_or_not,会让订阅每轮 + 都跳过搜索却又永远不完成。__get_downloaded 在洗版下的语义是"无需再处理的 + 集",只有 priority==100 才满足该语义。 + """ + subscribe = self._build_subscribe( + best_version=1, + total_episode=3, + episode_priority={"1": 100, "2": 100, "3": 99}, + note=[1, 2, 3], + ) + + downloaded = SubscribeChain._SubscribeChain__get_downloaded(subscribe) + + # E3 priority=99 仍是 pending,绝对不能合并到 downloaded 里 + self.assertEqual(downloaded, [1, 2]) + self.assertNotIn(3, downloaded) + + def test_get_downloaded_non_best_version_reads_note_after_wash_migration(self): + """迁移场景:洗版期间 finish_subscribe_or_not 把下载集写入 note; + 用户随后把 best_version 关掉,订阅切回普通模式时 __get_downloaded + 从非洗版分支读取 note,旧洗版集仍能作为"已下载"被识别,避免重新匹配。 + """ + subscribe = self._build_subscribe( + best_version=0, + total_episode=5, + episode_priority={"1": 100, "2": 99}, # 旧洗版残留,普通分支不读 + note=[1, 2, 3], + ) + + downloaded = SubscribeChain._SubscribeChain__get_downloaded(subscribe) + + self.assertEqual(downloaded, [1, 2, 3])