From 647c04956d2d27e0f0cbd43c9f785a21c43a49e3 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 22 Jun 2026 06:36:03 +0800 Subject: [PATCH] fix: preserve subscribe season zero targets (#5983) --- app/api/endpoints/subscribe.py | 3 +- app/chain/subscribe.py | 44 ++--- app/core/metainfo.py | 14 +- app/modules/douban/__init__.py | 6 +- tests/test_media_recognize_modules.py | 35 ++++ tests/test_metainfo.py | 13 ++ tests/test_subscribe_chain.py | 253 ++++++++++++++++++++++++++ tests/test_subscribe_endpoint.py | 31 ++++ 8 files changed, 364 insertions(+), 35 deletions(-) diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index a08fd3ab..4ad0f5f5 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -80,7 +80,8 @@ async def create_subscribe( if subscribe_in.doubanid or subscribe_in.bangumiid: meta = MetaInfo(subscribe_in.name) subscribe_in.name = meta.name - subscribe_in.season = meta.begin_season + if subscribe_in.season is None: + subscribe_in.season = meta.begin_season # 标题转换 if subscribe_in.name: title = subscribe_in.name diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index ceea2ec1..65ec3d6c 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -45,6 +45,17 @@ from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, Notifi subscribe_interaction_manager = SlashInteractionManager() +def build_subscribe_meta(subscribe: Subscribe) -> MetaBase: + """ + 按订阅对象构造主程序链路共用的 MetaInfo。 + """ + meta = MetaInfo(subscribe.name) + meta.year = subscribe.year + meta.begin_season = subscribe.season + meta.type = MediaType(subscribe.type) + return meta + + class SubscribeChain(ChainBase): """ 订阅管理处理链 @@ -1013,12 +1024,8 @@ class SubscribeChain(ChainBase): time.sleep(sleep_time) try: logger.info(f'开始搜索订阅,标题:{subscribe.name} ...') - # 生成元数据 - meta = MetaInfo(subscribe.name) - meta.year = subscribe.year - meta.begin_season = subscribe.season if subscribe.season is not None else None try: - meta.type = MediaType(subscribe.type) + meta = build_subscribe_meta(subscribe) except ValueError: logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue @@ -1408,12 +1415,8 @@ class SubscribeChain(ChainBase): break logger.info(f'开始匹配订阅,标题:{subscribe.name} ...') mediakey = subscribe.tmdbid or subscribe.doubanid - # 生成元数据 - meta = MetaInfo(subscribe.name) - meta.year = subscribe.year - meta.begin_season = subscribe.season or None try: - meta.type = MediaType(subscribe.type) + meta = build_subscribe_meta(subscribe) except ValueError: logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue @@ -1558,7 +1561,7 @@ class SubscribeChain(ChainBase): logger.debug(f'{torrent_info.title} 有多季,不处理') continue # 比对季 - if torrent_meta.begin_season: + if torrent_meta.begin_season is not None: if meta.begin_season != torrent_meta.begin_season: logger.debug(f'{torrent_info.title} 季不匹配') continue @@ -1706,12 +1709,8 @@ class SubscribeChain(ChainBase): if global_vars.is_system_stopped: break logger.info(f'开始更新订阅元数据:{subscribe.name} ...') - # 生成元数据 - meta = MetaInfo(subscribe.name) - meta.year = subscribe.year - meta.begin_season = subscribe.season or None try: - meta.type = MediaType(subscribe.type) + meta = build_subscribe_meta(subscribe) except ValueError: logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue @@ -1825,7 +1824,8 @@ class SubscribeChain(ChainBase): if subscribe_in.doubanid or subscribe_in.bangumiid: meta = MetaInfo(subscribe_in.name) subscribe_in.name = meta.name - subscribe_in.season = meta.begin_season + if subscribe_in.season is None: + subscribe_in.season = meta.begin_season # 标题转换 if subscribe_in.name: title = subscribe_in.name @@ -2553,7 +2553,7 @@ class SubscribeChain(ChainBase): """ if subscribe.type == MediaType.MOVIE.value: return "电影" - season = subscribe.season or 1 + season = subscribe.season if subscribe.season is not None else 1 if subscribe.total_episode: lack_episode = ( subscribe.lack_episode @@ -3056,12 +3056,8 @@ class SubscribeChain(ChainBase): else: episodes[0].download.append(file_info) - # 生成元数据 - meta = MetaInfo(subscribe.name) - meta.year = subscribe.year - meta.begin_season = subscribe.season or None try: - meta.type = MediaType(subscribe.type) + meta = build_subscribe_meta(subscribe) except ValueError: logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') return subscribe_info @@ -3156,7 +3152,7 @@ class SubscribeChain(ChainBase): if not subscribe.best_version: totals = {} - if subscribe.season and effective_total_episode: + if subscribe.season is not None and effective_total_episode: totals = { subscribe.season: effective_total_episode } diff --git a/app/core/metainfo.py b/app/core/metainfo.py index 0eeebd8c..3d642009 100644 --- a/app/core/metainfo.py +++ b/app/core/metainfo.py @@ -179,20 +179,20 @@ def _build_meta_info( if metainfo.get('doubanid'): meta.doubanid = metainfo['doubanid'] if metainfo.get('type'): - meta.type = metainfo['type'] + meta.type = MediaType(metainfo['type']) if isinstance(metainfo['type'], str) else metainfo['type'] if metainfo.get('episode_group'): meta.episode_group = metainfo['episode_group'] - if metainfo.get('begin_season'): + if metainfo.get('begin_season') is not None: meta.begin_season = metainfo['begin_season'] - if metainfo.get('end_season'): + if metainfo.get('end_season') is not None: meta.end_season = metainfo['end_season'] - if metainfo.get('total_season'): + if metainfo.get('total_season') is not None: meta.total_season = metainfo['total_season'] - if metainfo.get('begin_episode'): + if metainfo.get('begin_episode') is not None: meta.begin_episode = metainfo['begin_episode'] - if metainfo.get('end_episode'): + if metainfo.get('end_episode') is not None: meta.end_episode = metainfo['end_episode'] - if metainfo.get('total_episode'): + if metainfo.get('total_episode') is not None: meta.total_episode = metainfo['total_episode'] return meta diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 42d75d80..dec0ad8e 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -1050,7 +1050,7 @@ class DoubanModule(_ModuleBase): continue if mtype and mtype.value != type_name: continue - if mtype and mtype == MediaType.TV and not season: + if mtype and mtype == MediaType.TV and season is None: season = 1 item = item_obj.get("target") title = item.get("title") @@ -1059,9 +1059,9 @@ class DoubanModule(_ModuleBase): meta = MetaInfo(title) if type_name == MediaType.TV.value: meta.type = MediaType.TV - meta.begin_season = meta.begin_season or 1 + meta.begin_season = meta.begin_season if meta.begin_season is not None else 1 if meta.name == name \ - and ((not season and not meta.begin_season) or meta.begin_season == season) \ + and ((season is None and meta.begin_season is None) or meta.begin_season == season) \ and (not year or item.get('year') == year): logger.info(f"{name} 匹配到豆瓣信息:{item.get('id')} {item.get('title')}") return item diff --git a/tests/test_media_recognize_modules.py b/tests/test_media_recognize_modules.py index 4aa7c2bc..e4dce7e3 100644 --- a/tests/test_media_recognize_modules.py +++ b/tests/test_media_recognize_modules.py @@ -219,3 +219,38 @@ class MediaRecognizeModulesTest(TestCase): self.assertEqual(len(result), 1) self.assertEqual(result[0].title, "测试剧 第二季") self.assertEqual(result[0].season, 2) + + def test_douban_process_search_results_preserves_special_season_zero(self): + """豆瓣搜索匹配应把 season=0 当作明确季号,而不是默认回第 1 季。""" + result = { + "items": [ + { + "type_name": MediaType.TV.value, + "target": { + "id": "200", + "title": "测试剧 S01", + "type": "tv", + "year": "2024", + }, + }, + { + "type_name": MediaType.TV.value, + "target": { + "id": "201", + "title": "测试剧 S00", + "type": "tv", + "year": "2024", + }, + }, + ] + } + + matched = DoubanModule._process_search_results( + result, + "测试剧", + mtype=MediaType.TV, + year="2024", + season=0, + ) + + self.assertEqual(matched["id"], "201") diff --git a/tests/test_metainfo.py b/tests/test_metainfo.py index a1204470..5a2ab14f 100644 --- a/tests/test_metainfo.py +++ b/tests/test_metainfo.py @@ -146,6 +146,19 @@ class MetaInfoTest(TestCase): self.assertEqual(meta.episode_group, group_id) self.assertEqual(meta.apply_words, custom_words) + def test_custom_words_support_special_season_zero_parameter(self): + """显式媒体标签中的 s=0 应作为特别季写入元数据。""" + custom_words = [ + "Test Show => 测试剧 {[tmdbid=12345;type=tv;s=0]}" + ] + + with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None): + meta = MetaInfo(title="Test Show 01", custom_words=custom_words) + + self.assertEqual(meta.tmdbid, 12345) + self.assertEqual(meta.type.value, "电视剧") + self.assertEqual(meta.begin_season, 0) + def test_find_metainfo_supports_episode_group_parameter(self): """测试显式媒体标签支持 g 剧集组参数""" group_id = "5ad0ec240e0a26303f00d84d" diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 1af06f12..404eaa3c 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -121,7 +121,25 @@ def _load_subscribe_chain_class(): self.kwargs = kwargs class _SubscribeSchema: + _fields = { + "name", + "type", + "year", + "tmdbid", + "doubanid", + "bangumiid", + "season", + "best_version", + "save_path", + "search_imdbid", + "custom_words", + "media_category", + "filter_groups", + } + def __init__(self, **kwargs): + for field in self._fields: + setattr(self, field, None) for key, value in kwargs.items(): setattr(self, key, value) @@ -333,6 +351,7 @@ class SubscribeChainTest(TestCase): "year": "2026", "imdbid": None, "tvdbid": None, + "bangumiid": None, "episode_group": None, "poster": None, "backdrop": None, @@ -353,6 +372,22 @@ class SubscribeChainTest(TestCase): media_info=SimpleNamespace(type=MediaType.TV, tmdb_id=1, douban_id=None), ) + def test_format_subscribe_progress_preserves_special_season_zero(self): + """订阅列表展示必须把 S0 当作合法季号,而不是回退到第 1 季。""" + subscribe = self._build_subscribe(season=0, total_episode=5, lack_episode=2) + + progress = SubscribeChain._format_subscribe_progress(subscribe) + + self.assertEqual(progress, "第0季 [3/5]") + + def test_format_subscribe_progress_preserves_special_season_zero_without_total(self): + """S0 没有总集数时仍显示特别季季号。""" + subscribe = self._build_subscribe(season=0, total_episode=None, lack_episode=None) + + progress = SubscribeChain._format_subscribe_progress(subscribe) + + self.assertEqual(progress, "第0季") + def test_match_title_fallback_calls_torrent_match_from_class(self): """确保标题兜底匹配不依赖 TorrentHelper 实例绑定。""" @@ -420,6 +455,98 @@ class SubscribeChainTest(TestCase): ), self.assertRaises(_ReachedTitleMatch): chain.match({"test.example": [context]}) + def test_match_accepts_special_season_zero_candidate(self): + """S0 订阅应允许 S00 候选资源进入下载候选,不能按未指定季处理。""" + + class _TorrentHelper: + def filter_torrent(self, *args, **kwargs): + return True + + subscribe = self._build_subscribe( + best_version=0, + custom_words=None, + doubanid=None, + episode_group=None, + filter_groups=[], + keyword=None, + media_category=None, + save_path=None, + search_imdbid=False, + season=0, + sites=[], + tmdbid=1, + username="", + downloader=None, + ) + mediainfo = SimpleNamespace( + clear=lambda: None, + douban_id=None, + title_year="Test Show (2026)", + tmdb_id=1, + type=MediaType.TV, + ) + torrent_media = SimpleNamespace( + clear=lambda: None, + douban_id=None, + tmdb_id=1, + type=MediaType.TV, + ) + context = SimpleNamespace( + media_info=torrent_media, + media_recognize_fail_count=0, + meta_info=SimpleNamespace( + begin_season=0, + episode_list=[1], + org_string="Test Show S00E01", + season_list=[0], + ), + torrent_info=SimpleNamespace( + description="", + pri_order=100, + site=1, + site_name="TestSite", + title="Test Show S00E01", + ), + ) + download_calls = [] + + class _SubscribeOper: + """提供单条订阅,避免依赖真实数据库。""" + + def list(self, *args, **kwargs): + """返回当前测试构造的订阅列表。""" + return [subscribe] + + def get(self, *args, **kwargs): + """下载后仍返回当前订阅。""" + return subscribe + + def _download(self, **kwargs): + download_calls.append(kwargs) + return [context], {} + + chain = SubscribeChain() + chain.recognize_media = lambda **kwargs: mediainfo + chain.check_and_handle_existing_media = lambda **kwargs: (False, {}) + chain.get_sub_sites = lambda *_args, **_kwargs: [] + chain.get_params = lambda *_args, **_kwargs: {} + chain.filter_torrents = lambda **_kwargs: [context.torrent_info] + chain.finish_subscribe_or_not = lambda **_kwargs: None + + with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper", _SubscribeOper), patch.object( + SUBSCRIBE_CHAIN_MODULE, + "TorrentHelper", + _TorrentHelper, + ), patch.object( + SubscribeChain, + "_SubscribeChain__download_best_version_with_full_pack_first", + _download, + ): + chain.match({"test.example": [context]}) + + self.assertEqual(len(download_calls), 1) + self.assertEqual(download_calls[0]["contexts"][0].meta_info.begin_season, 0) + def test_get_episode_priority_falls_back_to_current_priority(self): subscribe = self._build_subscribe(current_priority=80, episode_priority=None) @@ -667,6 +794,132 @@ class SubscribeChainTest(TestCase): self.assertEqual(subscribe.lack_episode, 0) self.assertEqual(subscribe.note, list(range(1, 11))) + def test_resolve_subscribe_missing_preserves_special_season_zero_totals(self): + """特别季 S0 是合法订阅季,目标满足查询必须按订阅总集数裁剪媒体库缺集。""" + subscribe = self._build_subscribe( + best_version=0, + season=0, + total_episode=5, + lack_episode=2, + note=[1, 2, 3], + ) + meta = SimpleNamespace(type=MediaType.TV, begin_season=0, season=0) + mediainfo = SimpleNamespace( + type=MediaType.TV, + seasons={0: list(range(1, 4))}, + title_year="Test Show (2026)", + ) + captured_totals = [] + + class _DownloadChain: + def get_no_exists_info(self, **kwargs): + captured_totals.append(kwargs["totals"]) + if kwargs["totals"] == {0: 5}: + return False, { + 1: { + 0: SimpleNamespace( + season=0, + episodes=[4, 5], + total_episode=5, + start_episode=1, + require_complete_coverage=False, + ) + } + } + return True, {} + + with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _DownloadChain): + satisfied, no_exists = SubscribeChain().resolve_subscribe_missing( + subscribe=subscribe, + meta=meta, + mediainfo=mediainfo, + mediakey=1, + ) + + self.assertFalse(satisfied) + self.assertEqual(captured_totals, [{0: 5}]) + self.assertEqual(no_exists[1][0].episodes, [4, 5]) + + def test_build_subscribe_meta_preserves_special_season_zero(self): + """订阅构造 MetaInfo 的统一入口必须保留 S0。""" + subscribe = self._build_subscribe(season=0) + + meta = SUBSCRIBE_CHAIN_MODULE.build_subscribe_meta(subscribe) + + self.assertEqual(meta.begin_season, 0) + self.assertEqual(meta.type, MediaType.TV) + + def test_follow_preserves_shared_special_season_zero(self): + """follow 分享订阅携带 S0 时,标题规整不能把合法季号覆盖成未指定。""" + added_calls = [] + + class _SubscribeOper: + """提供订阅存在性查询,避免依赖真实数据库。""" + + def exists(self, *args, **kwargs): + return False + + def exist_history(self, *args, **kwargs): + return False + + class _SystemConfigOper: + """提供 follow 用户配置。""" + + def get(self, *args, **kwargs): + return ["follow-user"] + + class _MoviePilotServerHelper: + """提供单条 S0 分享订阅。""" + + @staticmethod + def get_subscribe_shares(): + return [ + { + "share_uid": "follow-user", + "name": "Test Show", + "type": MediaType.TV.value, + "year": "2026", + "tmdbid": None, + "doubanid": "12345", + "season": 0, + "best_version": 0, + "save_path": None, + "search_imdbid": False, + "custom_words": None, + "media_category": None, + "filter_groups": [], + } + ] + + def _add(self, **kwargs): + added_calls.append(kwargs) + return 1, None + + def _metainfo(title): + return SimpleNamespace(name=title, begin_season=None, episode_list=[]) + + with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper", _SubscribeOper), patch.object( + SUBSCRIBE_CHAIN_MODULE, + "SystemConfigOper", + _SystemConfigOper, + ), patch.object( + SUBSCRIBE_CHAIN_MODULE, + "MoviePilotServerHelper", + _MoviePilotServerHelper, + ), patch.object( + SUBSCRIBE_CHAIN_MODULE, + "MetaInfo", + _metainfo, + ), patch.object( + SubscribeChain, + "add", + _add, + ): + SubscribeChain.follow() + + self.assertEqual(len(added_calls), 1) + self.assertEqual(added_calls[0]["season"], 0) + def test_resolve_subscribe_missing_accepts_downloaded_episode_best_version_targets(self): """外部完成守卫可按任意已下载版本判定分集洗版目标已满足。""" subscribe = self._build_subscribe( diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 8190a041..b6f9fb5a 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -42,3 +42,34 @@ class SubscribeEndpointTest(TestCase): self.assertTrue(response.success) self.assertNotIn("completed_episode", async_add.await_args.kwargs) self.assertEqual(async_add.await_args.kwargs["username"], "moviepilot-user") + + def test_create_subscribe_preserves_special_season_zero_with_doubanid(self): + """ + 新增订阅带豆瓣 ID 且显式指定 S0 时,标题规整不应覆盖调用方传入的季号。 + """ + subscribe_in = Subscribe( + name="测试剧集", + year="2026", + type=MediaType.TV.value, + doubanid="12345", + season=0, + total_episode=5, + lack_episode=5, + ) + + with patch( + "app.api.endpoints.subscribe.MetaInfo", + return_value=SimpleNamespace(name="测试剧集", begin_season=None), + ), patch( + "app.api.endpoints.subscribe.SubscribeChain.async_add", + new=AsyncMock(return_value=(1, "新增订阅成功")), + ) as async_add: + response = asyncio.run( + create_subscribe( + subscribe_in=subscribe_in, + current_user=SimpleNamespace(name="moviepilot-user"), + ) + ) + + self.assertTrue(response.success) + self.assertEqual(async_add.await_args.kwargs["season"], 0)