diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index 322e5ecf5..fa8e464d0 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -687,7 +687,10 @@ async def site_resource( _: ApiPrincipal = Depends(get_current_active_manage_user_async), ) -> Any: """ - 浏览站点资源 + 浏览站点资源,沿用前端和索引模块从 0 开始的分页约定。 + + :param cat: 逗号分隔的站点分类 ID,未指定时不限制分类 + :param page: 从 0 开始的页码,默认查询第一页 """ site = await query.get(site_id) if not site: diff --git a/app/modules/indexer/__init__.py b/app/modules/indexer/__init__.py index ceaec8f71..cd54d2202 100644 --- a/app/modules/indexer/__init__.py +++ b/app/modules/indexer/__init__.py @@ -80,11 +80,11 @@ SPIDER_PARSER_CLASSES: dict[str, type[_PagedSpider]] = { _SPECIALIZED_SEARCH_ARGUMENTS = { "TNodeSpider": ("keyword", "page"), "TorrentLeech": ("keyword", "mtype", "page"), - "mTorrent": ("keyword", "mtype", "page"), + "mTorrent": ("keyword", "mtype", "cat", "page"), "SunnyPT": ("keyword", "mtype", "cat", "page"), - "Yema": ("keyword", "mtype", "page"), + "Yema": ("keyword", "mtype", "cat", "page"), "Haidan": ("keyword", "mtype"), - "HDDolby": ("keyword", "mtype", "page"), + "HDDolby": ("keyword", "mtype", "cat", "page"), "RousiPro": ("keyword", "mtype", "cat", "page"), "Milkie": ("keyword", "mtype", "page"), } diff --git a/app/modules/indexer/spider/hddolby.py b/app/modules/indexer/spider/hddolby.py index e08dfff23..5b543ca1d 100644 --- a/app/modules/indexer/spider/hddolby.py +++ b/app/modules/indexer/spider/hddolby.py @@ -92,11 +92,20 @@ class HddolbySpider: self._searchurl = f"https://api.{self._domain_host}/api/v1/torrent/search" self._pageurl = f"{self._domain}details.php?id=%s&hit=1" - def __get_params(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0) -> dict[str, object]: + def __get_params(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, + cat: Optional[str] = None) -> dict[str, object]: """ - 获取请求参数。 + 构造请求参数,显式站点分类优先于媒体类型预置分类。 + + :param keyword: 搜索关键词 + :param mtype: 未指定站点分类时使用的媒体类型 + :param page: 从 0 开始的搜索页码 + :param cat: 逗号分隔的站点分类 ID,未指定时沿用媒体类型筛选 + :return: HDDolby 搜索请求体 """ - if mtype == MediaType.TV: + if cat: + categories = [int(category) for category in cat.split(",")] + elif mtype == MediaType.TV: categories = self._tv_category elif mtype == MediaType.MOVIE: categories = self._movie_category @@ -200,17 +209,19 @@ class HddolbySpider: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] - def search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: + def search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, + cat: Optional[str] = None) -> Tuple[bool, List[dict]]: """ 同步搜索 HDDolby 种子。 :param keyword: 搜索关键词;为空时按站点接口规则浏览资源 :param mtype: 目标媒体类型,未指定时不额外限制媒体类型 :param page: 从 0 开始的搜索页码 + :param cat: 逗号分隔的站点分类 ID,优先于媒体类型 :return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源 """ - params = self.__get_params(keyword, mtype, page) + params = self.__get_params(keyword, mtype, page, cat) res = RequestUtils( headers={ @@ -225,16 +236,18 @@ class HddolbySpider: ).post_res(url=self._searchurl, json=params) return self.__process_response(res) - async def async_search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: + async def async_search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, + cat: Optional[str] = None) -> Tuple[bool, List[dict]]: """ 异步搜索 HDDolby 种子。 :param keyword: 搜索关键词;为空时按站点接口规则浏览资源 :param mtype: 目标媒体类型,未指定时不额外限制媒体类型 :param page: 从 0 开始的搜索页码 + :param cat: 逗号分隔的站点分类 ID,优先于媒体类型 :return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源 """ - params = self.__get_params(keyword, mtype, page) + params = self.__get_params(keyword, mtype, page, cat) res = await AsyncRequestUtils( headers={ diff --git a/app/modules/indexer/spider/mtorrent.py b/app/modules/indexer/spider/mtorrent.py index 6cbea14f4..e5b7734c4 100644 --- a/app/modules/indexer/spider/mtorrent.py +++ b/app/modules/indexer/spider/mtorrent.py @@ -121,11 +121,20 @@ class MTorrentSpider: registered_domain = site_rules.extract_domain(domain_url) return f"api.{registered_domain}" if registered_domain else host - def __get_params(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0) -> dict[str, object]: + def __get_params(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, + cat: Optional[str] = None) -> dict[str, object]: """ - 获取请求参数。 + 构造请求参数,显式站点分类优先于媒体类型预置分类。 + + :param keyword: 搜索关键词,IMDb 标识会转换为完整链接 + :param mtype: 未指定站点分类时使用的媒体类型 + :param page: 从 0 开始的页码,提交到站点时转换为从 1 开始 + :param cat: 逗号分隔的站点分类 ID,未指定时沿用媒体类型筛选 + :return: M-Team 搜索请求体 """ - if not mtype: + if cat: + categories = [category.strip() for category in cat.split(",")] + elif not mtype: categories = [] elif mtype == MediaType.TV: categories = self._tv_category @@ -226,7 +235,7 @@ class MTorrentSpider: return True, [] def search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, - search_type: Optional[str] = None) -> Tuple[bool, List[dict[str, Any]]]: + search_type: Optional[str] = None, cat: Optional[str] = None) -> Tuple[bool, List[dict[str, Any]]]: """ 搜索种子或字幕。 @@ -234,6 +243,7 @@ class MTorrentSpider: :param mtype: 目标媒体类型,未指定时不额外限制媒体类型 :param page: 从 0 开始的搜索页码 :param search_type: 资源类型;subtitles 表示字幕,其余值搜索种子 + :param cat: 逗号分隔的站点分类 ID,优先于媒体类型,仅对种子搜索生效 :return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源 """ if search_type == "subtitles": @@ -241,7 +251,7 @@ class MTorrentSpider: if not self._apikey: return True, [] - params = self.__get_params(keyword, mtype, page) + params = self.__get_params(keyword, mtype, page, cat) res = RequestUtils( headers={ @@ -256,7 +266,7 @@ class MTorrentSpider: return self.__process_response(res) async def async_search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, - search_type: Optional[str] = None) -> Tuple[bool, List[dict[str, Any]]]: + search_type: Optional[str] = None, cat: Optional[str] = None) -> Tuple[bool, List[dict[str, Any]]]: """ 异步搜索种子或字幕。 @@ -264,6 +274,7 @@ class MTorrentSpider: :param mtype: 目标媒体类型,未指定时不额外限制媒体类型 :param page: 从 0 开始的搜索页码 :param search_type: 资源类型;subtitles 表示字幕,其余值搜索种子 + :param cat: 逗号分隔的站点分类 ID,优先于媒体类型,仅对种子搜索生效 :return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源 """ if search_type == "subtitles": @@ -271,7 +282,7 @@ class MTorrentSpider: if not self._apikey: return True, [] - params = self.__get_params(keyword, mtype, page) + params = self.__get_params(keyword, mtype, page, cat) res = await AsyncRequestUtils( headers={ diff --git a/app/modules/indexer/spider/yema.py b/app/modules/indexer/spider/yema.py index be2dbc755..baacc9b2c 100644 --- a/app/modules/indexer/spider/yema.py +++ b/app/modules/indexer/spider/yema.py @@ -225,16 +225,27 @@ class YemaSpider: keyword: Optional[str], mtype: Optional[MediaType], page: Optional[int], + cat: Optional[str] = None, ) -> Optional[List[dict[str, Any]]]: """ 统一执行认证准入并生成各分类的搜索请求体。 + + :param keyword: 搜索关键词 + :param mtype: 未指定站点分类时使用的媒体类型 + :param page: 从 0 开始的搜索页码 + :param cat: 逗号分隔的站点分类 ID,优先于媒体类型并逐分类查询 + :return: 各分类的请求体;缺少认证信息时返回 None """ if not self._api_key: logger.warning(f"{self._name} 未配置 API AuthKey") return None + if cat: + category_ids: List[Optional[int]] = [int(category) for category in cat.split(",")] + else: + category_ids = self._search_category_ids(mtype) return [ self._build_params(keyword, page, category_id) - for category_id in self._search_category_ids(mtype) + for category_id in category_ids ] def _finalize_search_responses( @@ -258,6 +269,7 @@ class YemaSpider: keyword: Optional[str], mtype: Optional[MediaType] = None, page: Optional[int] = 0, + cat: Optional[str] = None, ) -> Tuple[bool, List[dict]]: """ 同步搜索 YemaPT 公开种子。 @@ -265,12 +277,14 @@ class YemaSpider: :param keyword: 搜索关键字 :param mtype: MoviePilot 媒体类型,指定后按真实站点分类分别查询 :param page: MoviePilot 从 0 开始的页码 + :param cat: 逗号分隔的站点分类 ID,优先于媒体类型并逐分类查询 :return: 是否失败及标准种子列表 """ payloads = self._prepare_search_requests( keyword=keyword, mtype=mtype, page=page, + cat=cat, ) if payloads is None: return True, [] @@ -293,6 +307,7 @@ class YemaSpider: keyword: Optional[str], mtype: Optional[MediaType] = None, page: Optional[int] = 0, + cat: Optional[str] = None, ) -> Tuple[bool, List[dict]]: """ 异步搜索 YemaPT 公开种子。 @@ -300,12 +315,14 @@ class YemaSpider: :param keyword: 搜索关键字 :param mtype: MoviePilot 媒体类型,指定后按真实站点分类分别查询 :param page: MoviePilot 从 0 开始的页码 + :param cat: 逗号分隔的站点分类 ID,优先于媒体类型并逐分类查询 :return: 是否失败及标准种子列表 """ payloads = self._prepare_search_requests( keyword=keyword, mtype=mtype, page=page, + cat=cat, ) if payloads is None: return True, [] diff --git a/tests/test_indexer_category.py b/tests/test_indexer_category.py new file mode 100644 index 000000000..2c52b1753 --- /dev/null +++ b/tests/test_indexer_category.py @@ -0,0 +1,60 @@ +""" +验证 API 站点搜索的多选分类优先级及同步、异步请求体。 +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.modules.indexer.spider import hddolby, mtorrent, yema +from app.schemas.types import MediaType + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("selection", [None, "", "single", "multiple"]) +@pytest.mark.parametrize( + ("module", "spider_class", "selected_ids", "default_ids"), + [ + (mtorrent, mtorrent.MTorrentSpider, ["402", "403"], mtorrent.MTorrentSpider._movie_category), + (hddolby, hddolby.HddolbySpider, [402, 403], hddolby.HddolbySpider._movie_category), + (yema, yema.YemaSpider, [5, 6], yema.YemaSpider._movie_category), + ], +) +def test_search_requests_use_explicit_categories_before_media_type( + monkeypatch, asynchronous, selection, module, spider_class, selected_ids, default_ids, +): + """ + 单选和多选都优先于电影预置分类,空分类保持旧行为及各站分页单位。 + """ + if module is not yema: + monkeypatch.setattr(module, "get_configured_system_config", lambda: None) + spider = spider_class({ + "id": 1, "name": "测试站点", "domain": "https://site.example/", + "apikey": "offline-key", "ua": "MoviePilot-Test", "proxy": False, + }) + ids = selected_ids[:1] if selection == "single" else selected_ids + cat = ",".join(str(category) for category in ids) if selection else selection + expected_ids = ids if selection else default_ids + payload = {"success": True, "data": []} if module is not mtorrent else {"data": {"data": []}} + response = SimpleNamespace(status_code=200, json=lambda: payload) + request = AsyncMock(return_value=response) if asynchronous else Mock(return_value=response) + transport = module.AsyncRequestUtils if asynchronous else module.RequestUtils + monkeypatch.setattr(transport, "post_res", request) + + arguments = {"keyword": "关键词", "mtype": MediaType.MOVIE, "page": 2, "cat": cat} + result = asyncio.run(spider.async_search(**arguments)) if asynchronous else spider.search(**arguments) + + assert result == (False, []) + requests = [call.kwargs["json"] for call in request.call_args_list] + if module is yema: + assert [body["categoryId"] for body in requests] == expected_ids + assert all(body["pageParam"] == {"current": 3, "pageSize": 40} for body in requests) + assert all(body["keyword"] == "关键词" for body in requests) + else: + assert len(requests) == 1 + assert requests[0]["categories"] == expected_ids + page_key = "pageNumber" if module is mtorrent else "page_number" + assert requests[0][page_key] == (3 if module is mtorrent else 2) + assert requests[0]["keyword"] == "关键词" diff --git a/tests/test_indexer_spider_response_parity.py b/tests/test_indexer_spider_response_parity.py index 40cf1cf51..16d876a45 100644 --- a/tests/test_indexer_spider_response_parity.py +++ b/tests/test_indexer_spider_response_parity.py @@ -399,7 +399,7 @@ def test_yema_sync_async_entries_share_request_plan_and_result_finalizer( spider.async_search(keyword="Movie", mtype=MediaType.MUSIC, page=2) ) - expected_plan = {"keyword": "Movie", "mtype": MediaType.MUSIC, "page": 2} + expected_plan = {"keyword": "Movie", "mtype": MediaType.MUSIC, "page": 2, "cat": None} assert sync_result == async_result == (False, [{"title": "projected"}]) assert prepare == [expected_plan, expected_plan] assert finalized == [ diff --git a/tests/test_indexer_sync_async_parity.py b/tests/test_indexer_sync_async_parity.py index c7828455f..c07e058e6 100644 --- a/tests/test_indexer_sync_async_parity.py +++ b/tests/test_indexer_sync_async_parity.py @@ -80,7 +80,7 @@ def _install_search_observers(monkeypatch, events: list[tuple]) -> None: ), ( "mTorrent", - {"keyword": "clean keyword", "mtype": MediaType.MOVIE, "page": 3}, + {"keyword": "clean keyword", "mtype": MediaType.MOVIE, "cat": "movie", "page": 3}, ), ( "SunnyPT", @@ -93,7 +93,7 @@ def _install_search_observers(monkeypatch, events: list[tuple]) -> None: ), ( "Yema", - {"keyword": "clean keyword", "mtype": MediaType.MOVIE, "page": 3}, + {"keyword": "clean keyword", "mtype": MediaType.MOVIE, "cat": "movie", "page": 3}, ), ( "Milkie", @@ -105,7 +105,7 @@ def _install_search_observers(monkeypatch, events: list[tuple]) -> None: ), ( "HDDolby", - {"keyword": "clean keyword", "mtype": MediaType.MOVIE, "page": 3}, + {"keyword": "clean keyword", "mtype": MediaType.MOVIE, "cat": "movie", "page": 3}, ), ( "RousiPro", @@ -136,6 +136,9 @@ def test_torrent_search_uses_identical_parser_selection_and_arguments( """ def __init__(self, site): + """ + 保存待验证的站点配置。 + """ self.site = site def search(self, **kwargs): diff --git a/tests/test_site_resource.py b/tests/test_site_resource.py new file mode 100644 index 000000000..b1137c785 --- /dev/null +++ b/tests/test_site_resource.py @@ -0,0 +1,32 @@ +""" +验证站点浏览接口保留零起始页码和多选分类。 +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.api.endpoints import site as site_endpoint + + +@pytest.mark.parametrize("page", [0, 1, 2]) +def test_site_resource_preserves_zero_based_pages_and_categories(monkeypatch, page): + """ + 第一页与后续页必须保持独立偏移,多选分类原样交给浏览链路。 + """ + query = SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace(domain="https://site.example/"))) + torrent = SimpleNamespace(to_dict=lambda: {"title": "命中资源"}) + browse = AsyncMock(return_value=[torrent]) + monkeypatch.setattr(site_endpoint, "TorrentsChain", lambda: SimpleNamespace(async_browse=browse)) + + result = asyncio.run(site_endpoint.site_resource( + site_id=1, keyword="关键词", cat="401,402", page=page, query=query, + )) + + assert result == [{"title": "命中资源"}] + query.get.assert_awaited_once_with(1) + browse.assert_awaited_once_with( + domain="https://site.example/", keyword="关键词", cat="401,402", page=page, mtype=None, + )