diff --git a/app/agent/tools/impl/add_subscribe.py b/app/agent/tools/impl/add_subscribe.py index 9ae2af573..879284e1b 100644 --- a/app/agent/tools/impl/add_subscribe.py +++ b/app/agent/tools/impl/add_subscribe.py @@ -97,7 +97,7 @@ class AddSubscribeTool(MoviePilotTool): message += f" ({year})" if media_type: message += f" [{media_type}]" - if season: + if season is not None: message += f" 第{season}季" elif media_type == "tv": message += " 第1季(默认)" diff --git a/app/agent/tools/impl/query_popular_subscribes.py b/app/agent/tools/impl/query_popular_subscribes.py index a3b8685b2..41e0555e9 100644 --- a/app/agent/tools/impl/query_popular_subscribes.py +++ b/app/agent/tools/impl/query_popular_subscribes.py @@ -118,7 +118,7 @@ class QueryPopularSubscribesTool(MoviePilotTool): # 处理标题 title = sub.get("name") season = sub.get("season") - if season and int(season) > 1 and media.tmdb_id: + if season not in (None, "") and int(season) != 1 and media.tmdb_id: # 小写数据转大写 season_str = cn2an.an2cn(season, "low") title = f"{title} 第{season_str}季" diff --git a/app/agent/tools/impl/search_media.py b/app/agent/tools/impl/search_media.py index ca26cfa8c..f5af59922 100644 --- a/app/agent/tools/impl/search_media.py +++ b/app/agent/tools/impl/search_media.py @@ -43,7 +43,7 @@ class SearchMediaTool(MoviePilotTool): message += f" ({year})" if media_type: message += f" [{media_type}]" - if season: + if season is not None: message += f" 第{season}季" return message diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index 5199311a0..bea61c398 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -40,6 +40,16 @@ def _parse_media_type(mtype: Optional[str]) -> Optional[MediaType]: return MediaType.from_agent(mtype) or MediaType(mtype) +def _resolve_media_season( + explicit_season: Optional[int], + recognized_season: Optional[int], +) -> Optional[int]: + """ + 合并显式季号与识别结果,显式值优先且季 0 属于有效业务值。 + """ + return explicit_season if explicit_season is not None else recognized_season + + def _sse_event(data: dict, locale: Optional[str] = None) -> str: """ 转换为SSE事件 @@ -298,8 +308,10 @@ async def search_by_id_stream( doubanid=doubanid, mtype=media_type ) if tmdbinfo: - if tmdbinfo.get("season") and not media_season: - media_season = tmdbinfo.get("season") + media_season = _resolve_media_season( + explicit_season=media_season, + recognized_season=tmdbinfo.get("season"), + ) torrents = search_chain.async_search_by_id_stream( tmdbid=tmdbinfo.get("id"), mtype=media_type, @@ -404,7 +416,7 @@ async def search_by_id_stream( meta.year = year if media_type: meta.type = media_type - if media_season: + if media_season is not None: meta.type = MediaType.TV meta.begin_season = media_season mediainfo = await media_chain.async_recognize_by_meta( @@ -505,8 +517,10 @@ async def search_by_id( doubanid=doubanid, mtype=media_type ) if tmdbinfo: - if tmdbinfo.get("season") and not media_season: - media_season = tmdbinfo.get("season") + media_season = _resolve_media_season( + explicit_season=media_season, + recognized_season=tmdbinfo.get("season"), + ) torrents = await search_chain.async_search_by_id( tmdbid=tmdbinfo.get("id"), mtype=media_type, @@ -598,7 +612,7 @@ async def search_by_id( meta.year = year if media_type: meta.type = media_type - if media_season: + if media_season is not None: meta.type = MediaType.TV meta.begin_season = media_season mediainfo = await media_chain.async_recognize_by_meta( @@ -770,8 +784,10 @@ async def _build_subtitle_search_source( ) if not tmdbinfo: return None, "未识别到TMDB媒体信息" - if tmdbinfo.get("season") and not media_season: - media_season = tmdbinfo.get("season") + media_season = _resolve_media_season( + explicit_season=media_season, + recognized_season=tmdbinfo.get("season"), + ) return call_search(tmdbid=tmdbinfo.get("id")), "" return call_search(doubanid=doubanid), "" @@ -813,7 +829,7 @@ async def _build_subtitle_search_source( meta.year = year if media_type: meta.type = media_type - if media_season: + if media_season is not None: meta.type = MediaType.TV meta.begin_season = media_season mediainfo = await media_chain.async_recognize_by_meta( diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 4576742b0..5b4d60655 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -639,7 +639,7 @@ async def popular_subscribes( # 处理标题 title = sub.get("name") season = sub.get("season") - if season and int(season) > 1 and media.tmdb_id: + if season not in (None, "") and int(season) != 1 and media.tmdb_id: # 小写数据转大写 season_str = cn2an.an2cn(season, "low") title = f"{title} 第{season_str}季" diff --git a/app/chain/download.py b/app/chain/download.py index 5cfd74568..16824361b 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -488,10 +488,13 @@ class DownloadChain(ChainBase): ) meta = getattr(context, "meta_info", None) site = getattr(torrent, "site", None) or getattr(torrent, "site_name", None) + meta_season = getattr(meta, "season", None) + media_season = getattr(media, "season", None) + season = meta_season if meta_season is not None else media_season payload = { "media_type": str(media_type or ""), "media_key": str(media_key or ""), - "season": str(getattr(meta, "season", None) or getattr(media, "season", None) or ""), + "season": str(season) if season is not None else "", "episodes": cls._format_failure_episodes(meta) or "", "site": str(site or ""), "resource": cls._torrent_resource_key(torrent), @@ -1177,7 +1180,7 @@ class DownloadChain(ChainBase): if not tv.episodes: if not need_seasons.get(need_mid): need_seasons[need_mid] = [] - need_seasons[need_mid].append(tv.season or 1) + need_seasons[need_mid].append(tv.season if tv.season is not None else 1) logger.info(f"缺失整季:{need_seasons}") # 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子 for need_mid, need_season in need_seasons.items(): diff --git a/app/chain/media.py b/app/chain/media.py index 509e39efe..5f98793b1 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -653,6 +653,16 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): self.obtain_images(mediainfo=mediainfo) return mediainfo + @staticmethod + def _parse_recognize_event_number(value) -> Optional[int]: + """ + 解析辅助识别返回的季集号,兼容整数和数字字符串并保留数值 0。 + """ + if value is None: + return None + text = str(value).strip() + return int(text) if text.isdigit() else None + def recognize_help( self, title: str, @@ -686,10 +696,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): title = str(event_data["name"]).split("/")[0].strip().replace(".", " ") if event_data.get("year"): year = str(event_data["year"]).split("/")[0].strip() - if event_data.get("season") and str(event_data["season"]).isdigit(): - season_number = int(event_data["season"]) - if event_data.get("episode") and str(event_data["episode"]).isdigit(): - episode_number = int(event_data["episode"]) + season_number = self._parse_recognize_event_number(event_data.get("season")) + episode_number = self._parse_recognize_event_number(event_data.get("episode")) if not title: return None if title == "Unknown": @@ -1635,10 +1643,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): title = str(event_data["name"]).split("/")[0].strip().replace(".", " ") if event_data.get("year"): year = str(event_data["year"]).split("/")[0].strip() - if event_data.get("season") and str(event_data["season"]).isdigit(): - season_number = int(event_data["season"]) - if event_data.get("episode") and str(event_data["episode"]).isdigit(): - episode_number = int(event_data["episode"]) + season_number = self._parse_recognize_event_number(event_data.get("season")) + episode_number = self._parse_recognize_event_number(event_data.get("episode")) if not title: return None if title == "Unknown": @@ -1654,7 +1660,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): org_meta.year = year org_meta.begin_season = season_number org_meta.begin_episode = episode_number - if org_meta.begin_season or org_meta.begin_episode: + if org_meta.begin_season is not None or org_meta.begin_episode is not None: org_meta.type = MediaType.TV # 重新识别 return await self.async_recognize_media( diff --git a/app/chain/message.py b/app/chain/message.py index fc5aa46c3..2d6511cbf 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -2087,7 +2087,7 @@ class MediaInteractionChain(ChainBase): mediakey = mediainfo.tmdb_id or mediainfo.douban_id no_exists = {mediakey: {}} - if meta.begin_season: + if meta.begin_season is not None: episodes = mediainfo.seasons.get(meta.begin_season) if not episodes: return {} diff --git a/app/chain/search.py b/app/chain/search.py index 6bc0306ce..47c1f56ef 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -203,7 +203,7 @@ class SearchChain(ChainBase): "area": str(params.get("area") or ""), "title": str(params.get("title") or ""), "year": str(params.get("year") or ""), - "season": str(params.get("season") or ""), + "season": str(params["season"]) if params.get("season") is not None else "", "episode": str(params.get("episode") or ""), "sites": str(params.get("sites") or ""), "result_type": str(params.get("result_type") or "torrent"), diff --git a/app/core/meta/metaanime.py b/app/core/meta/metaanime.py index 9f7e8a6a8..515dc8b65 100644 --- a/app/core/meta/metaanime.py +++ b/app/core/meta/metaanime.py @@ -38,6 +38,14 @@ class MetaAnime(MetaBase): _name_nostring_pattern = re.compile(_name_nostring_re, re.IGNORECASE) _fps_pattern = re.compile(r"(%s)" % _fps_re, re.IGNORECASE) + @staticmethod + def _parse_season_number(value): + """解析第三方动漫季号,仅接受整数或纯数字字符串并保留数值 0。""" + if value is None: + return None + text = str(value).strip() + return int(text) if text.isdigit() else None + def __init__(self, title: str, subtitle: str = None, isfile: bool = False): super().__init__(title, subtitle, isfile) if not title: @@ -111,22 +119,19 @@ class MetaAnime(MetaBase): # 季号 anime_season = anitopy_info.get("anime_season") if isinstance(anime_season, list): - if len(anime_season) == 1: - begin_season = anime_season[0] - end_season = None - else: - begin_season = anime_season[0] - end_season = anime_season[-1] - elif anime_season: - begin_season = anime_season - end_season = None + seasons = [ + season for item in anime_season + if (season := self._parse_season_number(item)) is not None + ] + begin_season = seasons[0] if seasons else None + end_season = seasons[-1] if len(seasons) > 1 else None else: - begin_season = None + begin_season = self._parse_season_number(anime_season) end_season = None - if begin_season: - self.begin_season = int(begin_season) - if end_season and int(end_season) != self.begin_season: - self.end_season = int(end_season) + if begin_season is not None: + self.begin_season = begin_season + if end_season is not None and end_season != self.begin_season: + self.end_season = end_season self.total_season = (self.end_season - self.begin_season) + 1 else: self.total_season = 1 diff --git a/app/core/meta/metavideo.py b/app/core/meta/metavideo.py index f9592ca36..b6a140604 100644 --- a/app/core/meta/metavideo.py +++ b/app/core/meta/metavideo.py @@ -251,7 +251,7 @@ class MetaVideo(MetaBase): if name.isdecimal() \ and int(name) < 1800 \ and not self.year \ - and not self.begin_season \ + and self.begin_season is None \ and not self.resource_pix \ and not self.resource_type \ and not self.audio_encode \ @@ -259,7 +259,7 @@ class MetaVideo(MetaBase): if self.begin_episode is None: self.begin_episode = int(name) name = None - elif self.is_in_episode(int(name)) and not self.begin_season: + elif self.is_in_episode(int(name)) and self.begin_season is None: name = None return name @@ -366,7 +366,7 @@ class MetaVideo(MetaBase): if not self.name: return if not self.year \ - and not self.begin_season \ + and self.begin_season is None \ and not self.begin_episode \ and not self.resource_pix \ and not self.resource_type: @@ -690,7 +690,7 @@ class MetaVideo(MetaBase): if not self.year \ and not self.resource_pix \ and not self.resource_type \ - and not self.begin_season \ + and self.begin_season is None \ and not self.begin_episode: return re_res = self._video_encode_pattern.search(token) @@ -738,7 +738,7 @@ class MetaVideo(MetaBase): if not self.year \ and not self.resource_pix \ and not self.resource_type \ - and not self.begin_season \ + and self.begin_season is None \ and not self.begin_episode: return video_bit = self.extract_video_bit(token) @@ -759,7 +759,7 @@ class MetaVideo(MetaBase): if not self.year \ and not self.resource_pix \ and not self.resource_type \ - and not self.begin_season \ + and self.begin_season is None \ and not self.begin_episode: return re_res = self._audio_encode_pattern.search(token) diff --git a/app/helper/server.py b/app/helper/server.py index f5825861e..4958325df 100644 --- a/app/helper/server.py +++ b/app/helper/server.py @@ -1464,7 +1464,8 @@ class MoviePilotServerHelper: params["type"] = media_type if year := cls._extract_year(meta=meta): params["year"] = year - if season := cls._extract_season(media_type=media_type, meta=meta): + season = cls._extract_season(media_type=media_type, meta=meta) + if season is not None: params["season"] = season return params diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index dec0ad8e0..391c3ae00 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -98,7 +98,7 @@ class DoubanModule(_ModuleBase): continue ret_medias.append(MediaInfo(douban_info=item_obj.get("target"))) # 将搜索词中的季写入标题中 - if ret_medias and meta.begin_season: + if ret_medias and meta.begin_season is not None: # 小写数据转大写 season_str = cn2an.an2cn(meta.begin_season, "low") for media in ret_medias: @@ -155,7 +155,7 @@ class DoubanModule(_ModuleBase): elif meta: info = {} for name in self._prepare_search_names(meta): - if meta.begin_season: + if meta.begin_season is not None: logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...") else: logger.info(f"正在识别 {name} ...") @@ -255,7 +255,7 @@ class DoubanModule(_ModuleBase): elif meta: info = {} for name in self._prepare_search_names(meta): - if meta.begin_season: + if meta.begin_season is not None: logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...") else: logger.info(f"正在识别 {name} ...") diff --git a/app/modules/douban/douban_cache.py b/app/modules/douban/douban_cache.py index d5f9ddb11..05e1b17d2 100644 --- a/app/modules/douban/douban_cache.py +++ b/app/modules/douban/douban_cache.py @@ -147,7 +147,7 @@ class DoubanCache(metaclass=WeakSingleton): mtype = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV else: meta = MetaInfo(cache_title) - if meta.begin_season: + if meta.begin_season is not None: mtype = MediaType.TV else: mtype = MediaType.MOVIE diff --git a/app/modules/filemanager/__init__.py b/app/modules/filemanager/__init__.py index 078bc7a31..6fd2f412d 100644 --- a/app/modules/filemanager/__init__.py +++ b/app/modules/filemanager/__init__.py @@ -632,7 +632,7 @@ class FileManagerModule(_ModuleBase): seasons: Dict[int, list] = {} for fileitem in fileitems: file_meta = MetaInfo(fileitem.basename) - season_index = file_meta.begin_season or 1 + season_index = file_meta.begin_season if file_meta.begin_season is not None else 1 episode_index = file_meta.begin_episode if not episode_index: continue diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 966b2b75b..9c58ec9a3 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -162,7 +162,7 @@ class TheMovieDbModule(_ModuleBase): if not results: return [] medias = [MediaInfo(tmdb_info=info) for info in results] - if meta.begin_season: + if meta.begin_season is not None: # 小写数据转大写 season_str = cn2an.an2cn(meta.begin_season, "low") for media in medias: @@ -267,7 +267,7 @@ class TheMovieDbModule(_ModuleBase): """ 根据名称搜索媒体信息 """ - if meta.begin_season: + if meta.begin_season is not None: logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...") else: logger.info(f"正在识别 {name} ...") @@ -303,7 +303,7 @@ class TheMovieDbModule(_ModuleBase): """ 根据名称搜索媒体信息(异步版本) """ - if meta.begin_season: + if meta.begin_season is not None: logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...") else: logger.info(f"正在识别 {name} ...") @@ -630,7 +630,7 @@ class TheMovieDbModule(_ModuleBase): :param name: 名称 :param mtype: 类型 :param year: 年份 - :param season: 季号 + :param season: 用于匹配指定季,0 表示特别季 """ # 搜索 logger.info(f"开始使用 名称:{name} 年份:{year} 匹配TMDB信息 ...") @@ -651,7 +651,7 @@ class TheMovieDbModule(_ModuleBase): :param name: 名称 :param mtype: 类型 :param year: 年份 - :param season: 季号 + :param season: 用于匹配指定季,0 表示特别季 """ # 搜索 logger.info(f"开始使用 名称:{name} 年份:{year} 匹配TMDB信息 ...") @@ -670,10 +670,10 @@ class TheMovieDbModule(_ModuleBase): 获取TMDB信息 :param tmdbid: int :param mtype: 媒体类型 - :param season: 季号 - :return: TVDB信息 + :param season: 季号;TV 的显式值(含 0)读取季详情,None 或电影的 0 读取媒体详情 + :return: TMDB信息 """ - if not season: + if season is None or (season == 0 and mtype != MediaType.TV): return self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid) else: return self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season) @@ -683,10 +683,10 @@ class TheMovieDbModule(_ModuleBase): 异步获取TMDB信息 :param tmdbid: int :param mtype: 媒体类型 - :param season: 季号 - :return: TVDB信息 + :param season: 季号;TV 的显式值(含 0)读取季详情,None 或电影的 0 读取媒体详情 + :return: TMDB信息 """ - if not season: + if season is None or (season == 0 and mtype != MediaType.TV): return await self.tmdb.async_get_info(mtype=mtype, tmdbid=tmdbid) else: return await self.tmdb.async_get_tv_season_detail(tmdbid=tmdbid, season=season) diff --git a/app/workflow/actions/fetch_torrents.py b/app/workflow/actions/fetch_torrents.py index c8a6c5dda..94234d142 100644 --- a/app/workflow/actions/fetch_torrents.py +++ b/app/workflow/actions/fetch_torrents.py @@ -74,7 +74,7 @@ class FetchTorrentsAction(BaseAction): continue if params.type and torrent.media_info and torrent.media_info.type != MediaType(params.type): continue - if params.season and torrent.meta_info.begin_season != params.season: + if params.season is not None and torrent.meta_info.begin_season != params.season: continue # 识别媒体信息 if params.match_media: diff --git a/tests/test_agent_add_subscribe_tool.py b/tests/test_agent_add_subscribe_tool.py index 8fb70b6e6..ede2ca28d 100644 --- a/tests/test_agent_add_subscribe_tool.py +++ b/tests/test_agent_add_subscribe_tool.py @@ -7,6 +7,15 @@ from app.schemas.types import MessageChannel class TestAgentAddSubscribeTool(unittest.TestCase): + def test_tool_message_displays_special_season_zero(self): + """Agent 提示必须把显式季 0 显示为特别季,而不是默认第一季。""" + tool = AddSubscribeTool(session_id="session-1", user_id="10001") + + message = tool.get_tool_message(title="测试剧", media_type="tv", season=0) + + self.assertIn("第0季", message) + self.assertNotIn("第1季(默认)", message) + def test_tv_subscription_without_season_reports_default_first_season(self): tool = AddSubscribeTool(session_id="session-1", user_id="10001") tool.set_message_attr( diff --git a/tests/test_agent_query_popular_subscribes_tool.py b/tests/test_agent_query_popular_subscribes_tool.py new file mode 100644 index 000000000..6643322c1 --- /dev/null +++ b/tests/test_agent_query_popular_subscribes_tool.py @@ -0,0 +1,29 @@ +import asyncio +import json + +from app.agent.tools.impl.query_popular_subscribes import QueryPopularSubscribesTool + + +def test_popular_subscribe_title_distinguishes_special_season_zero(monkeypatch): + """热门订阅结果应在标题中明确标识特别季,同时保留数值季号。""" + async def fake_statistics(**_kwargs): + return [{ + "type": "tv", + "name": "Demo Show", + "season": 0, + "tmdbid": 1, + "count": 5, + }] + + monkeypatch.setattr( + "app.agent.tools.impl.query_popular_subscribes." + "MoviePilotServerHelper.async_get_subscribe_statistic", + fake_statistics, + ) + + tool = QueryPopularSubscribesTool(session_id="session-1", user_id="10001") + result = asyncio.run(tool.run(media_type="tv")) + payload = json.loads(result.split("\n\n", 1)[1]) + + assert payload[0]["title"] == "Demo Show 第零季" + assert payload[0]["season"] == 0 diff --git a/tests/test_agent_search_media_tool.py b/tests/test_agent_search_media_tool.py new file mode 100644 index 000000000..cb6c2a1f9 --- /dev/null +++ b/tests/test_agent_search_media_tool.py @@ -0,0 +1,10 @@ +from app.agent.tools.impl.search_media import SearchMediaTool + + +def test_tool_message_displays_special_season_zero(): + """媒体搜索提示应展示显式季 0。""" + tool = SearchMediaTool(session_id="session-1", user_id="10001") + + message = tool.get_tool_message(title="测试剧", media_type="tv", season=0) + + assert "第0季" in message diff --git a/tests/test_douban_cache_management.py b/tests/test_douban_cache_management.py index 8218749ef..01347f41f 100644 --- a/tests/test_douban_cache_management.py +++ b/tests/test_douban_cache_management.py @@ -26,6 +26,10 @@ class _MemoryCacheStub: """删除指定缓存条目。""" self.data.pop(key, None) + def set(self, key: str, value): + """写入指定缓存条目。""" + self.data[key] = value + def clear(self): """清空全部缓存条目。""" self.data.clear() @@ -79,6 +83,19 @@ def test_douban_cache_list_items_normalizes_media_type_and_sorting(): assert items[1]["douban_id"] == 0 +def test_douban_cache_infers_special_season_title_as_tv(): + """缺少显式类型时,S00 标题仍应按电视剧写入缓存。""" + cache = _build_douban_cache({}) + + cache.update( + meta=None, + info={"id": "special", "title": "测试剧 S00", "year": "2024"}, + ) + + cached = next(iter(cache._cache.data.values())) + assert cached["type"] == MediaType.TV + + def test_douban_cache_delete_and_clear_persist_immediately(monkeypatch): """豆瓣管理操作应修改运行时缓存并立即触发本地持久化。""" cache = _build_douban_cache({"first": {"id": "1"}, "second": {"id": "2"}}) diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index 13fc6eedb..6c3a650a0 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -444,6 +444,40 @@ def test_batch_download_rejects_complete_coverage_when_files_do_not_cover_target chain.download_single.assert_not_called() +def test_batch_download_preserves_special_season_zero(monkeypatch): + """特别季整季需求必须以季 0 匹配候选,不能回退成第 1 季。""" + _FakeBatchTorrentHelper.episodes = list(range(1, 7)) + 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() + context.meta_info.season_list = [0] + context.meta_info.season_episode = "S00" + context.meta_info.org_string = "Test Show S00 2160p" + context.torrent_info.title = "Test Show S00 2160p" + no_exists = { + 1: { + 0: NotExistMediaInfo( + season=0, + episodes=[], + total_episode=6, + start_episode=1, + require_complete_coverage=True, + ) + } + } + + downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) + + assert downloads == [context] + assert lefts == {} + chain.download_single.assert_called_once() + + def test_batch_download_rejects_complete_coverage_when_only_missing_episodes_match(monkeypatch): """ 完整覆盖要求目标范围全集,不能只覆盖当前缺口集。 @@ -634,6 +668,33 @@ def test_download_single_records_failure_cooldown_when_downloader_rejects(monkey assert captured["next_retry_at"] > captured["now_time"] +def test_download_failure_fingerprint_distinguishes_special_season_zero(): + """失败冷却指纹应区分特别季与未指定季,避免错误共享冷却状态。""" + def build_context(season): + return SimpleNamespace( + media_info=SimpleNamespace( + type=MediaType.TV, + title="Demo Show", + year="2026", + tmdb_id=1, + season=None, + ), + meta_info=SimpleNamespace(season=season, episode=None, episode_list=[]), + torrent_info=SimpleNamespace( + site=12, + title="Demo Show Specials", + torrent_id="484660", + ), + ) + + special_fingerprint = DownloadChain._build_download_failure_fingerprint(build_context(0)) + unspecified_fingerprint = DownloadChain._build_download_failure_fingerprint(build_context(None)) + + assert special_fingerprint + assert unspecified_fingerprint + assert special_fingerprint != unspecified_fingerprint + + def test_batch_download_skips_failed_subscription_resource_and_tries_next(monkeypatch): """ 订阅自动下载应跳过冷却中的失败资源,但继续尝试同媒体的后续候选。 diff --git a/tests/test_filemanager_season_zero.py b/tests/test_filemanager_season_zero.py new file mode 100644 index 000000000..904676c40 --- /dev/null +++ b/tests/test_filemanager_season_zero.py @@ -0,0 +1,18 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from app.core.context import MediaInfo +from app.modules.filemanager import FileManagerModule +from app.schemas.types import MediaType + + +def test_local_media_exists_keeps_special_season_zero(): + """本地 S00 文件必须归入特别季,不能计入第一季。""" + module = FileManagerModule() + module.media_files = lambda _mediainfo: [SimpleNamespace(basename="Test.Show.S00E01.mkv")] + mediainfo = MediaInfo(title="Test Show", type=MediaType.TV) + + with patch("app.modules.filemanager.settings.LOCAL_EXISTS_SEARCH", True): + exists = module.media_exists(mediainfo) + + assert exists.seasons == {0: [1]} diff --git a/tests/test_media_interaction.py b/tests/test_media_interaction.py index 3648ea311..8fcca8f8d 100644 --- a/tests/test_media_interaction.py +++ b/tests/test_media_interaction.py @@ -138,6 +138,24 @@ def _build_single_download_dir() -> list[TransferDirectoryConf]: ] +def test_rebuild_download_scope_keeps_special_season_zero(): + """重新下载特别季时只能重建季 0 范围,不能扩展为整部剧的所有季。""" + meta = _build_meta("测试剧") + meta.begin_season = 0 + mediainfo = MediaInfo( + type=MediaType.TV, + title="测试剧", + year="2026", + tmdb_id=1, + seasons={0: [1, 2], 1: [1, 2, 3]}, + ) + + no_exists = MediaInteractionChain._get_noexits_info(meta, mediainfo) + + assert list(no_exists[1]) == [0] + assert no_exists[1][0].total_episode == 2 + + def test_message_routes_text_reply_to_media_interaction_before_ai(): """已有传统媒体交互时,用户回复应优先交给传统交互处理。""" chain = MessageChain() diff --git a/tests/test_media_recognize_modules.py b/tests/test_media_recognize_modules.py index e4dce7e3d..cbcbafbd3 100644 --- a/tests/test_media_recognize_modules.py +++ b/tests/test_media_recognize_modules.py @@ -254,3 +254,59 @@ class MediaRecognizeModulesTest(TestCase): ) self.assertEqual(matched["id"], "201") + + def test_search_result_builders_preserve_special_season_zero(self): + """TMDB 与豆瓣搜索结果都必须携带显式特别季。""" + meta = MetaBase("测试剧") + meta.name = "测试剧" + meta.begin_season = 0 + meta.type = MediaType.TV + + tmdb_results = TheMovieDbModule._build_search_medias_result( + meta, + [{"id": 100, "name": "测试剧", "media_type": "tv", "first_air_date": "2024-01-01"}], + ) + douban_results = DoubanModule._build_search_medias_result( + meta, + [{ + "type_name": MediaType.TV.value, + "target": {"id": "200", "title": "测试剧", "type": "tv", "year": "2024"}, + }], + ) + + self.assertEqual(tmdb_results[0].season, 0) + self.assertEqual(douban_results[0].season, 0) + + def test_tmdb_info_treats_special_season_zero_as_season_detail(self): + """TV 的显式季 0 读取特别季,None 及电影的 0 读取媒体详情。""" + module = TheMovieDbModule() + module.tmdb = Mock() + module.tmdb.get_info.return_value = {"scope": "series"} + module.tmdb.get_tv_season_detail.return_value = {"scope": "season", "season_number": 0} + + special = module.tmdb_info(tmdbid=100, mtype=MediaType.TV, season=0) + series = module.tmdb_info(tmdbid=100, mtype=MediaType.TV, season=None) + movie = module.tmdb_info(tmdbid=200, mtype=MediaType.MOVIE, season=0) + + self.assertEqual(special["scope"], "season") + self.assertEqual(series["scope"], "series") + self.assertEqual(movie["scope"], "series") + module.tmdb.get_tv_season_detail.assert_called_once_with(tmdbid=100, season=0) + + def test_async_tmdb_info_treats_special_season_zero_as_season_detail(self): + """异步 TMDB 接口必须与同步接口保持相同的季 0 契约。""" + module = TheMovieDbModule() + module.tmdb = Mock() + module.tmdb.async_get_info = AsyncMock(return_value={"scope": "series"}) + module.tmdb.async_get_tv_season_detail = AsyncMock( + return_value={"scope": "season", "season_number": 0} + ) + + special = asyncio.run(module.async_tmdb_info(tmdbid=100, mtype=MediaType.TV, season=0)) + series = asyncio.run(module.async_tmdb_info(tmdbid=100, mtype=MediaType.TV, season=None)) + movie = asyncio.run(module.async_tmdb_info(tmdbid=200, mtype=MediaType.MOVIE, season=0)) + + self.assertEqual(special["scope"], "season") + self.assertEqual(series["scope"], "series") + self.assertEqual(movie["scope"], "series") + module.tmdb.async_get_tv_season_detail.assert_awaited_once_with(tmdbid=100, season=0) diff --git a/tests/test_media_recognize_share.py b/tests/test_media_recognize_share.py index 16c870f6b..4dab9390b 100644 --- a/tests/test_media_recognize_share.py +++ b/tests/test_media_recognize_share.py @@ -243,6 +243,28 @@ class TestMediaRecognizeShare(unittest.TestCase): self.assertEqual(report_payload["year"], "2024") self.assertEqual(report_payload["season"], 2) + def test_query_and_report_preserve_special_season_zero(self): + """共享识别查询和上报都必须保留显式特别季。""" + meta = self._build_meta("测试剧特别篇", MediaType.TV) + meta.begin_season = 0 + mediainfo = MediaInfo(title="测试剧", tmdb_id=402, type=MediaType.TV, season=0) + + query_params = MoviePilotServerHelper._build_recognize_query_params(meta=meta) + report_payload = MoviePilotServerHelper._build_recognize_report_payload( + meta=meta, + mediainfo=mediainfo, + ) + + self.assertEqual(query_params["season"], 0) + self.assertEqual(report_payload["season"], 0) + + def test_plugin_recognize_number_parser_preserves_zero(self): + """插件辅助识别应同时接受整数和字符串形式的季 0。""" + self.assertEqual(self.media_chain._parse_recognize_event_number(0), 0) + self.assertEqual(self.media_chain._parse_recognize_event_number("0"), 0) + self.assertIsNone(self.media_chain._parse_recognize_event_number(None)) + self.assertIsNone(self.media_chain._parse_recognize_event_number("invalid")) + def test_report_shared_result_with_distinct_keyword_meta(self): """ 辅助识别成功后应按辅助前名称上报共享结果。 diff --git a/tests/test_metainfo.py b/tests/test_metainfo.py index f29b2268a..51972abc6 100644 --- a/tests/test_metainfo.py +++ b/tests/test_metainfo.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from unittest.mock import patch from app.core.metainfo import MetaInfo, MetaInfoPath, find_metainfo +from app.core.meta.metaanime import MetaAnime from app.helper.torrent import TorrentHelper from app.schemas.types import MediaType from tests.cases.meta import meta_cases @@ -357,6 +358,60 @@ def test_video_bit_extracted_for_video_title(): assert meta.video_bit == "10bit" +def test_special_season_zero_enables_whole_season_resource_parsing(): + """只有 S00、没有集号的整季标题仍应识别后续编码信息。""" + with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None): + meta = MetaInfo(title="Demo Show S00 X265 AAC") + + assert meta.begin_season == 0 + assert meta.video_encode == "x265" + assert meta.audio_encode == "AAC" + + +def test_anime_parser_preserves_numeric_special_season_zero(): + """第三方动漫解析器返回整数 0 时也应保留特别季。""" + parsed = { + "anime_title": "Demo Anime", + "anime_season": 0, + "episode_number": "1", + } + with patch("app.core.meta.metaanime.anitopy.parse", return_value=parsed): + meta = MetaAnime(title="Demo Anime S00E01") + + assert meta.begin_season == 0 + assert meta.begin_episode == 1 + assert meta.type == MediaType.TV + + parsed["anime_season"] = [0, "1"] + with patch("app.core.meta.metaanime.anitopy.parse", return_value=parsed): + ranged_meta = MetaAnime(title="Demo Anime S00-S01") + + assert ranged_meta.begin_season == 0 + assert ranged_meta.end_season == 1 + + +def test_anime_parser_ignores_empty_and_invalid_season_values(): + """第三方动漫季号的空值和非法列表项应按未指定处理且不得抛错。""" + empty = { + "anime_title": "Demo Anime", + "anime_season": "", + "episode_number": "1", + } + invalid_list = { + "anime_title": "Demo Anime", + "anime_season": ["", "invalid"], + "episode_number": "1", + } + + with patch("app.core.meta.metaanime.anitopy.parse", return_value=empty): + empty_meta = MetaAnime(title="Demo Anime E01") + with patch("app.core.meta.metaanime.anitopy.parse", return_value=invalid_list): + invalid_meta = MetaAnime(title="Demo Anime E01") + + assert empty_meta.begin_season is None + assert invalid_meta.begin_season is None + + def test_hdr_vivid_effect_extracted_for_video_title(): """测试合并写法 HDRVivid 可识别为资源效果。""" with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None): diff --git a/tests/test_search_ai_recommend.py b/tests/test_search_ai_recommend.py index 2550a8157..5fea1d837 100644 --- a/tests/test_search_ai_recommend.py +++ b/tests/test_search_ai_recommend.py @@ -447,6 +447,14 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase): ) self.assertTrue(any(filename == "__search_result__" for filename, _ in cached)) + def test_search_params_preserve_special_season_zero(self): + """最近搜索参数必须把显式季 0 保存为字符串 0,供页面刷新后重放。""" + params = SearchChain._normalize_search_params( + {"keyword": "tmdb:123", "season": 0} + ) + + self.assertEqual(params["season"], "0") + def test_tool_factory_excludes_message_tools_when_disabled(self): with patch( "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", diff --git a/tests/test_subtitle_search.py b/tests/test_subtitle_search.py index 8064b921f..61caa201a 100644 --- a/tests/test_subtitle_search.py +++ b/tests/test_subtitle_search.py @@ -1,6 +1,6 @@ import pytest -from app.api.endpoints.search import _parse_media_type +from app.api.endpoints.search import _parse_media_type, _resolve_media_season from app.chain.search import SearchChain from app.core.context import MediaInfo, SubtitleInfo from app.modules.indexer import IndexerModule @@ -24,6 +24,12 @@ AUDIENCES_SUBTITLE_HTML = """ """ + +def test_explicit_special_season_zero_overrides_recognized_season(): + """精确搜索中显式季 0 必须优先于跨源识别返回的其它季号。""" + assert _resolve_media_season(explicit_season=0, recognized_season=1) == 0 + assert _resolve_media_season(explicit_season=None, recognized_season=1) == 1 + HHANCLUB_SUBTITLE_HTML = """
diff --git a/tests/test_template_context_builder.py b/tests/test_template_context_builder.py index a9463168c..8cac71bdc 100644 --- a/tests/test_template_context_builder.py +++ b/tests/test_template_context_builder.py @@ -10,7 +10,10 @@ TemplateContextBuilder 的并发安全单元测试。 """ import threading +from app.core.context import MediaInfo +from app.core.metainfo import MetaInfo from app.helper.message import TemplateContextBuilder +from app.schemas.types import MediaType from app.schemas.tmdb import TmdbEpisode @@ -118,3 +121,20 @@ def test_build_exposes_total_episodes_from_current_season() -> None: ) assert context.get("total_episodes") == 3 + + +def test_build_preserves_special_season_context() -> None: + """显式 S00 必须优先于媒体回退季,并使用特别季年份。""" + meta = MetaInfo("Test Show S00E01") + mediainfo = MediaInfo( + title="Test Show", + type=MediaType.TV, + season=1, + season_years={0: "2024", 1: "2025"}, + ) + + context = TemplateContextBuilder().build(meta=meta, mediainfo=mediainfo) + + assert context["season"] == "0" + assert context["season_fmt"] == "S00" + assert context["season_year"] == "2024" diff --git a/tests/test_workflow_actions.py b/tests/test_workflow_actions.py index 770e927d7..7817c24fa 100644 --- a/tests/test_workflow_actions.py +++ b/tests/test_workflow_actions.py @@ -4,8 +4,10 @@ from app.schemas import ActionContext, DownloadTask, FileItem from app.schemas.workflow import ActionResult from app.workflow.actions import BaseAction from app.workflow.actions import fetch_downloads as fetch_downloads_module +from app.workflow.actions import fetch_torrents as fetch_torrents_module from app.workflow.actions import scrape_file as scrape_file_module from app.workflow.actions.fetch_downloads import FetchDownloadsAction +from app.workflow.actions.fetch_torrents import FetchTorrentsAction from app.workflow.actions.scrape_file import ScrapeFileAction from app.workflow.actions.fetch_rss import FetchRssAction from app.workflow import WorkFlowManager @@ -42,6 +44,40 @@ def test_fetch_downloads_updates_context_downloads(monkeypatch): assert result.downloads[0].path == "/downloads/movie.mkv" +def test_fetch_torrents_filters_special_season_zero(monkeypatch): + """工作流显式选择季 0 时只能保留特别季资源。""" + + class FakeSearchChain: + """返回特别季和第一季候选,验证动作层季过滤。""" + + def search_by_title(self, **_kwargs): + return [ + SimpleNamespace( + meta_info=SimpleNamespace(year=None, begin_season=0), + media_info=None, + torrent_info=SimpleNamespace(title="Test S00"), + ), + SimpleNamespace( + meta_info=SimpleNamespace(year=None, begin_season=1), + media_info=None, + torrent_info=SimpleNamespace(title="Test S01"), + ), + ] + + monkeypatch.setattr(fetch_torrents_module, "SearchChain", FakeSearchChain) + monkeypatch.setattr(fetch_torrents_module.global_vars, "is_workflow_stopped", lambda _workflow_id: False) + + action = FetchTorrentsAction("fetch-torrents") + action.job_done = lambda *_args, **_kwargs: None + result = action.execute( + workflow_id=1, + params={"search_type": "keyword", "name": "Test", "season": 0}, + context=ActionContext(), + ) + + assert [item.meta_info.begin_season for item in result.torrents] == [0] + + def test_scrape_file_keeps_workflow_action_context(monkeypatch): """刮削文件动作不应将工作流上下文替换为媒体识别上下文。""" scraped = []