diff --git a/app/chain/site.py b/app/chain/site.py index 1ce38b71f..6cf2b46bc 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -659,12 +659,16 @@ class SiteChain(InteractionChainMixin, ChainBase): def __hddolby_test(self, site: SiteSnapshot) -> Tuple[bool, str]: """ - 通过 HDDolby 用户 API 验证 API Key,与资料解析使用相同 API 主机。 + 通过 HDDolby 用户 API 验证 API Key,与搜索和资料解析使用相同资源 API 基础地址。 + + 未配置 api_url 时沿用旧站的 api 子域,兼容存量资源和自定义索引。 :param site: HDDolby 协议站点配置 :return: 是否可用及认证或连接失败原因 """ - url = f"https://api.{site_rules.extract_domain(site.url)}/api/v1/user/data" + domain = site.domain or site_rules.extract_domain(site.url) + indexer = SitesHelper().get_indexer(domain) or {} + api_url = str(indexer.get("api_url") or f"https://api.{domain}/api/v1").rstrip("/") headers = { "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", @@ -672,7 +676,7 @@ class SiteChain(InteractionChainMixin, ChainBase): } with _open_site_response( method="GET", - url=url, + url=f"{api_url}/user/data", headers=headers, proxies=(self.runtime_config.proxy or {}) if site.proxy else {}, timeout=site.timeout or 15, diff --git a/app/modules/indexer/parser/hddolby.py b/app/modules/indexer/parser/hddolby.py index af1b899c3..2fe23fc16 100644 --- a/app/modules/indexer/parser/hddolby.py +++ b/app/modules/indexer/parser/hddolby.py @@ -48,13 +48,17 @@ class HDDolbySiteUserInfo(SiteParserBase): def _parse_site_page(self, html_text: SitePage) -> None: """ - 获取站点页面地址。 + 使用资源声明的 API 基础地址配置用户资料和做种接口。 + + 未配置 api_url 时保留旧站的 api 子域,前端地址继续用于读取站点消息。 + + :param html_text: API 模式不使用首页内容 """ - # 更换api地址 - self._base_url = f"https://api.{site_rules.extract_domain(self._base_url)}" + api_url = self._api_url or f"https://api.{site_rules.extract_domain(self._base_url)}/api/v1" + self._base_url = f"{api_url.rstrip('/')}/" self._user_traffic_page = None self._user_detail_page = None - self._user_basic_page = "api/v1/user/data" + self._user_basic_page = "user/data" self._user_basic_params = {} self._user_basic_headers = { "Content-Type": "application/json", @@ -63,7 +67,7 @@ class HDDolbySiteUserInfo(SiteParserBase): self._sys_mail_unread_page = None self._user_mail_unread_page = None self._mail_unread_params = {} - self._torrent_seeding_page = "api/v1/user/peers" + self._torrent_seeding_page = "user/peers" self._torrent_seeding_params = {} self._torrent_seeding_headers = { "Content-Type": "application/json", diff --git a/app/modules/indexer/spider/hddolby.py b/app/modules/indexer/spider/hddolby.py index 00d09adac..d9b189a58 100644 --- a/app/modules/indexer/spider/hddolby.py +++ b/app/modules/indexer/spider/hddolby.py @@ -73,9 +73,12 @@ class HddolbySpider: """ return cls._size - def __init__(self, indexer: dict[str, Any]): + def __init__(self, indexer: dict[str, Any]) -> None: """ 使用站点配置初始化 HDDolby API 请求上下文。 + + :param indexer: 合并认证信息的索引配置;api_url 可覆盖包含版本路径的 API 基础地址, + 未配置时沿用旧站的 api 子域 """ self.systemconfig = get_configured_system_config() if indexer: @@ -89,7 +92,10 @@ class HddolbySpider: self._ua = indexer.get('ua') self._apikey = indexer.get('apikey') self._timeout = indexer.get('timeout') or 15 - self._searchurl = f"https://api.{self._domain_host}/api/v1/torrent/search" + api_url = str( + indexer.get('api_url') or f"https://api.{self._domain_host}/api/v1" + ).rstrip('/') + self._searchurl = f"{api_url}/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, diff --git a/tests/test_hddolby_parser.py b/tests/test_hddolby_parser.py index 84fafcbd8..d98d7fc9d 100644 --- a/tests/test_hddolby_parser.py +++ b/tests/test_hddolby_parser.py @@ -3,6 +3,10 @@ 验证 HDDolby 用户信息、未读消息与无有效用户标识时的请求边界。 """ +from unittest.mock import Mock + +import pytest + from app.modules.indexer.parser.hddolby import HDDolbySiteUserInfo from app.modules.indexer.parser.nexus_php import NexusPhpSiteUserInfo @@ -23,16 +27,56 @@ USER_DATA_JSON = """ """ -def _build_parser(site_cookie: str = "") -> HDDolbySiteUserInfo: +def _build_parser(site_cookie: str = "", **kwargs) -> HDDolbySiteUserInfo: + """ + 构造隔离 HTTP 会话、允许覆盖 API 地址的杜比用户数据解析器。 + """ return HDDolbySiteUserInfo( site_name="高清杜比", url="https://www.hddolby.com/", site_cookie=site_cookie, apikey="test-api-key", token=None, + request_utils=Mock(), + **kwargs, ) +@pytest.mark.parametrize( + ("api_url", "expected_base"), + [ + (None, "https://api.hddolby.com/api/v1/"), + ("", "https://api.hddolby.com/api/v1/"), + ("https://www.uhddolby.com/api/v1", "https://www.uhddolby.com/api/v1/"), + ("https://www.uhddolby.com/api/v1/", "https://www.uhddolby.com/api/v1/"), + ("https://custom-api.test:8443/dolby/v1/", "https://custom-api.test:8443/dolby/v1/"), + ], +) +def test_userdata_uses_resource_api_url_for_profile_and_seeding(monkeypatch, api_url, expected_base): + """ + 用户资料和做种统计使用同一资源 API 地址,保留 API Key 并关闭隔离会话。 + """ + parser = _build_parser(api_url=api_url) + get_page = Mock(side_effect=[ + USER_DATA_JSON, + '{"status": 0, "data": [{"size": 2048, "seeders": 3}]}', + ]) + monkeypatch.setattr(parser, "_get_page_content", get_page) + + parser.parse() + + assert [call.kwargs["url"] for call in get_page.call_args_list] == [ + f"{expected_base}user/data", f"{expected_base}user/peers", + ] + assert parser._addition_headers == {"x-api-key": "test-api-key"} + assert parser.userid == "41088" + assert parser.upload == 1000 + assert parser.download == 500 + assert parser.seeding == 1 + assert parser.seeding_size == 2048 + parser._request_utils.close.assert_called_once_with() + + def test_hddolby_parse_user_base_info_reads_unread_count(): """ 用户基础信息解析保留用户标识、名称和未读消息数量。 diff --git a/tests/test_hddolby_spider.py b/tests/test_hddolby_spider.py index 6eace465d..9f654e457 100644 --- a/tests/test_hddolby_spider.py +++ b/tests/test_hddolby_spider.py @@ -6,6 +6,9 @@ # MediaType 使用历史惰性导出入口,Pylint 无法静态解析。 # pylint: disable=no-name-in-module +import asyncio +from unittest.mock import AsyncMock, Mock + import pytest from app.modules.indexer.spider import hddolby as hddolby_module @@ -71,3 +74,53 @@ def test_parse_result_marks_music_torrents(hddolby_spider): MediaType.MOVIE.value, MediaType.TV.value, ] + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize( + ("domain", "api_url", "expected_url"), + [ + ("https://www.hddolby.com/", None, "https://api.hddolby.com/api/v1/torrent/search"), + ("https://www.hddolby.com/", "", "https://api.hddolby.com/api/v1/torrent/search"), + ("https://www.uhddolby.com/", "https://www.uhddolby.com/api/v1", + "https://www.uhddolby.com/api/v1/torrent/search"), + ("https://www.uhddolby.com/", "https://www.uhddolby.com/api/v1/", + "https://www.uhddolby.com/api/v1/torrent/search"), + ("https://www.uhddolby.com/", "https://custom-api.test:8443/dolby/v1/", + "https://custom-api.test:8443/dolby/v1/torrent/search"), + ], +) +def test_search_honors_resource_api_url( + monkeypatch, asynchronous, domain, api_url, expected_url, +): + """ + 同步和异步搜索使用资源 API 地址及 API Key,详情和下载链接仍属于站点前端。 + """ + monkeypatch.setattr(hddolby_module, "get_configured_system_config", lambda: None) + indexer = {**_build_indexer(), "domain": domain, "api_url": api_url} + spider = HddolbySpider(indexer) + response = Mock(status_code=200) + response.json.return_value = { + "status": 0, + "data": [{"id": 42, "name": "Example.Movie", "category": 401, "downhash": "test-hash"}], + } + request = Mock() + transport = Mock(return_value=response) + if asynchronous: + transport = AsyncMock(return_value=response) + request.return_value.post_res = transport + monkeypatch.setattr(hddolby_module, "AsyncRequestUtils" if asynchronous else "RequestUtils", request) + + if asynchronous: + error, torrents = asyncio.run(spider.async_search("Movie", MediaType.MOVIE, page=2)) + else: + error, torrents = spider.search("Movie", MediaType.MOVIE, page=2) + + assert error is False + transport.assert_called_once_with(url=expected_url, json={ + "keyword": "Movie", "page_number": 2, "page_size": 100, + "categories": HddolbySpider._movie_category, "visible": 1, + }) + assert request.call_args.kwargs["headers"]["x-api-key"] == "dolby-secret" + assert torrents[0]["page_url"] == f"{domain}details.php?id=42&hit=1" + assert torrents[0]["enclosure"] == f"{domain}download.php?id=42&downhash=test-hash" diff --git a/tests/test_site_connectivity.py b/tests/test_site_connectivity.py index 993c36981..5459ab4f6 100644 --- a/tests/test_site_connectivity.py +++ b/tests/test_site_connectivity.py @@ -212,11 +212,12 @@ def test_api_protocols_use_their_own_authentication( ("MTorrent", "api_domain", "https://custom-api.test:8443/api/member/profile"), ("MTorrent", "api_host", "https://custom-api.test:8443/api/member/profile"), ("SunnyPT", "api_url", "https://custom-api.test:8443/api/profile"), + ("HDDolby", "api_url", "https://custom-api.test:8443/api/user/data"), ], ) def test_api_probe_honors_resource_endpoint(monkeypatch, protocol, host_key, expected_url): """ - MTorrent 和 SunnyPT 探测与搜索使用同一资源 API 地址配置。 + API 协议探测与搜索使用同一资源 API 地址配置。 """ chain = SiteChain() site = SiteSnapshot(id=1, name="独立 API", domain="custom.test", url="https://www.custom.test/", apikey="key") @@ -225,7 +226,9 @@ def test_api_probe_honors_resource_endpoint(monkeypatch, protocol, host_key, exp indexer = Mock(return_value={"schema": protocol, host_key: "https://custom-api.test:8443/api"}) monkeypatch.setattr("app.chain.site.SitesHelper", Mock(return_value=Mock(get_indexer=indexer))) context = MagicMock() - context.__enter__.return_value = Mock(status_code=200, json=Mock(return_value={"code": "0", "data": {"id": "1"}})) + context.__enter__.return_value = Mock( + status_code=200, json=Mock(return_value={"code": "0", "status": 0, "data": {"id": "1"}}), + ) open_response = Mock(return_value=context) monkeypatch.setattr("app.chain.site._open_site_response", open_response) @@ -234,6 +237,38 @@ def test_api_probe_honors_resource_endpoint(monkeypatch, protocol, host_key, exp assert open_response.call_args.kwargs["url"] == expected_url +@pytest.mark.parametrize("domain", ["hddolby.com", "uhddolby.com"]) +@pytest.mark.parametrize("api_url", ["https://www.uhddolby.com/api/v1", "https://www.uhddolby.com/api/v1/"]) +def test_hddolby_probe_uses_migrated_resource_for_both_domains(monkeypatch, domain, api_url): + """ + 旧域专用路由和新域协议路由均按资源配置探测主站 API,并保留认证与连接参数。 + """ + chain = SiteChain() + site = SiteSnapshot( + id=1, name="高清杜比", domain=domain, url=f"https://www.{domain}/", + apikey="test-key", proxy=1, timeout=25, + ) + chain.site_repository = Mock() + chain.site_repository.get_by_domain.return_value = site + indexer = Mock(return_value={"schema": "HDDolby", "api_url": api_url}) + monkeypatch.setattr("app.chain.site.SitesHelper", Mock(return_value=Mock(get_indexer=indexer))) + context = MagicMock() + context.__enter__.return_value = Mock(status_code=200, json=Mock(return_value={"status": 0})) + open_response = Mock(return_value=context) + monkeypatch.setattr("app.chain.site._open_site_response", open_response) + + assert chain.test(site.url) == (True, "连接成功") + + indexer.assert_called_with(domain) + request = open_response.call_args.kwargs + assert request["method"] == "GET" + assert request["url"] == "https://www.uhddolby.com/api/v1/user/data" + assert request["headers"]["x-api-key"] == "test-key" + assert request["proxies"] == (chain.runtime_config.proxy or {}) + assert request["timeout"] == 25 + context.__exit__.assert_called_once_with(None, None, None) + + @pytest.mark.parametrize("apikey", [None, "test-auth-key"]) def test_yema_registered_domain_prefers_authkey_and_preserves_legacy_cookie(monkeypatch, apikey): """