diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index d2afe98e3..4e7c2e9b0 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -4,6 +4,7 @@ import json import zipfile from datetime import datetime from typing import Annotated, Any, Optional, Union +from urllib.parse import urlsplit import anyio import pillow_avif # noqa: F401 # pylint: disable=unused-import # AVIF 注册副作用 @@ -88,6 +89,23 @@ _PUBLIC_SYSTEM_CONFIG_KEYS = { _PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"} +def _get_image_proxy_allowed_domains() -> set[str]: + """返回图片代理允许的域名,并纳入已启用的 Bangumi 图片代理主机。""" + runtime_settings = get_runtime_settings() + allowed_domains = set(runtime_settings.get("SECURITY_IMAGE_DOMAINS", [])) + if not runtime_settings.get("BANGUMI_PROXY_ENABLE"): + return allowed_domains + + image_proxy = str(runtime_settings.get("BANGUMI_IMAGE_DOMAIN") or "").strip() + if not image_proxy: + return allowed_domains + candidate = image_proxy if "://" in image_proxy else f"https://{image_proxy}" + parsed = urlsplit(candidate) + if parsed.scheme in {"http", "https"} and parsed.netloc: + allowed_domains.add(parsed.netloc) + return allowed_domains + + def _database_backup_artifact_data(artifact: Any) -> _SchemaDatabaseBackupArtifactData: """将内部备份制品映射为不含宿主路径的 Web DTO。""" return _SchemaDatabaseBackupArtifactData( @@ -157,7 +175,7 @@ async def fetch_image( return None if allowed_domains is None: - allowed_domains = set(get_runtime_settings().get("SECURITY_IMAGE_DOMAINS", [])) + allowed_domains = _get_image_proxy_allowed_domains() fetch_url = SecurityUtils.strip_url_signature(url) # 验证URL安全性 @@ -228,7 +246,7 @@ async def proxy_img( """ 图片代理,可选是否使用代理服务器,支持 HTTP 缓存 """ - allowed_domains = set(get_runtime_settings().get("SECURITY_IMAGE_DOMAINS", [])) + allowed_domains = _get_image_proxy_allowed_domains() cookies = MediaServerChain().get_image_cookies(server=None, image_url=imgurl) if use_cookies else None return await fetch_image( url=imgurl, @@ -290,6 +308,8 @@ def get_global_setting(token: str): runtime_settings = get_runtime_settings() info = runtime_settings.snapshot( include={ + "BANGUMI_PROXY_ENABLE", + "BANGUMI_IMAGE_DOMAIN", "TMDB_IMAGE_DOMAIN", "GLOBAL_IMAGE_CACHE", "WALLPAPER_ROTATION_INTERVAL", @@ -327,6 +347,8 @@ async def get_user_global_setting( include={ "AI_AGENT_ENABLE", "AI_AGENT_HIDE_ENTRY", + "BANGUMI_PROXY_ENABLE", + "BANGUMI_IMAGE_DOMAIN", "LLM_SUPPORT_AUDIO_INPUT", "LLM_SUPPORT_AUDIO_OUTPUT", "RECOGNIZE_SOURCE", diff --git a/app/modules/bangumi/__init__.py b/app/modules/bangumi/__init__.py index beae34967..9a7d87b0c 100644 --- a/app/modules/bangumi/__init__.py +++ b/app/modules/bangumi/__init__.py @@ -27,6 +27,8 @@ class BangumiConfigSnapshot: """Bangumi 模块一次配置 generation 使用的稳定网络快照。""" proxy: Any + proxy_enabled: bool = False + api_domain: str = "" @dataclass(frozen=True, slots=True) @@ -57,7 +59,11 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): Bangumi媒体信息匹配 """ auxiliary_media_source = MediaSource.Bangumi - CONFIG_WATCH = {"PROXY_HOST"} + CONFIG_WATCH = { + "PROXY_HOST", + "BANGUMI_PROXY_ENABLE", + "BANGUMI_API_DOMAIN", + } bangumiapi: BangumiApi = None scraper: MediaScraperHelper = None @@ -67,8 +73,18 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ 初始化Bangumi客户端 """ - self._config = BangumiConfigSnapshot(proxy=get_runtime_setting('PROXY')) - self.bangumiapi = BangumiApi() + self._config = BangumiConfigSnapshot( + proxy=get_runtime_setting('PROXY'), + proxy_enabled=bool( + get_runtime_setting('BANGUMI_PROXY_ENABLE', False) + ), + api_domain=str( + get_runtime_setting('BANGUMI_API_DOMAIN', '') or '' + ), + ) + self.bangumiapi = BangumiApi( + base_url=self._config.api_domain if self._config.proxy_enabled else '', + ) self.scraper = MediaScraperHelper() def stop(self) -> None: @@ -76,13 +92,17 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): 关闭Bangumi客户端 """ if self.bangumiapi: - self.bangumiapi.close() + try: + self.bangumiapi.clear_cache() + finally: + self.bangumiapi.close() def test(self) -> Tuple[bool, str]: """ 测试模块连接性 """ - ret = RequestUtils(proxies=self._config.proxy).get_res("https://api.bgm.tv/") + api_url = self.bangumiapi.base_url if self.bangumiapi else "https://api.bgm.tv/" + ret = RequestUtils(proxies=self._config.proxy).get_res(api_url) if ret and ret.status_code == 200: return True, "" elif ret: diff --git a/app/modules/bangumi/bangumi.py b/app/modules/bangumi/bangumi.py index 6a714e918..6db47af4f 100644 --- a/app/modules/bangumi/bangumi.py +++ b/app/modules/bangumi/bangumi.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any, Optional, cast +from urllib.parse import urlsplit from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.runtime.cache import cached @@ -33,8 +34,23 @@ class BangumiApi: } _base_url = "https://api.bgm.tv/" - def __init__(self) -> None: - """初始化同步与异步 Bangumi 请求客户端。""" + def __init__( + self, + base_url: Optional[str] = None, + ) -> None: + """初始化同步与异步 Bangumi 请求客户端及其代理地址快照。""" + configured_base_url = ( + base_url if base_url is not None else ( + get_runtime_setting("BANGUMI_API_DOMAIN", "") + if get_runtime_setting("BANGUMI_PROXY_ENABLE", False) + else "" + ) + ) + self._base_url = self._normalize_base_url( + configured_base_url, + default=self.__class__._base_url, + ) + self._cache_token = self._base_url self._req = RequestUtils( ua=get_runtime_setting('NORMAL_USER_AGENT'), proxies=get_runtime_setting('PROXY'), @@ -45,16 +61,44 @@ class BangumiApi: proxies=get_runtime_setting('PROXY'), ) - @classmethod + @property + def base_url(self) -> str: + """返回当前客户端使用的 Bangumi 数据请求地址。""" + return self._base_url + + @staticmethod + def _normalize_base_url(value: Any, default: str) -> str: + """归一化 Bangumi 数据地址,兼容域名、完整 URL 和代理路径。""" + raw_value = str(value or "").strip() + if not raw_value: + return default + candidate = ( + raw_value + if "://" in raw_value + else f"https://{raw_value}" + ) + try: + parsed = urlsplit(candidate) + except ValueError: + return default + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.query + or parsed.fragment + ): + return default + return f"{candidate.rstrip('/')}/" + def _request_plan( - cls, + self, path: str, key: Optional[str] = None, **params: Any, ) -> _BangumiRequestPlan: """构造同步与异步请求共同使用的不可变调用计划。""" return _BangumiRequestPlan( - url=f"{cls._base_url}{path}", + url=f"{self._base_url}{path}", params=dict(params), key=key, ) @@ -72,8 +116,7 @@ class BangumiApi: return payload.get(key) if isinstance(payload, dict) else None return payload - @classmethod - def _decode_response(cls, response: Any, key: Optional[str]) -> Any: + def _decode_response(self, response: Any, key: Optional[str]) -> Any: """解析 HTTP 响应,并把格式错误统一映射为空结果。""" if response is None: return None @@ -82,30 +125,60 @@ class BangumiApi: except (TypeError, ValueError) as err: logger.warning(f"Bangumi 响应解析失败:{str(err)}") return None - return cls._project_response(response.status_code, payload, key) + return self._project_response(response.status_code, payload, key) @cached( maxsize=get_runtime_setting('CONF').bangumi, ttl=get_runtime_setting('CONF').meta, shared_key="get", ) - def __invoke(self, url, key=None, **kwargs): - """执行同步 HTTP 请求,业务计划与响应规则由共享 helper 决定。""" + def _cached_invoke( + self, + url, + key=None, + _cache_token: str = "", + **kwargs, + ): + """执行带配置身份的同步 HTTP 请求,并复用统一响应解码。""" plan = self._request_plan(url, key=key, **kwargs) response = self._req.get_res(url=plan.url, params=plan.params) return self._decode_response(response, plan.key) + def __invoke(self, url, key=None, **kwargs): + """执行同步 Bangumi 请求,并把当前代理配置纳入缓存键。""" + return self._cached_invoke( + url, + key=key, + _cache_token=self._cache_token, + **kwargs, + ) + @cached( maxsize=get_runtime_setting('CONF').bangumi, ttl=get_runtime_setting('CONF').meta, shared_key="get", ) - async def __async_invoke(self, url, key=None, **kwargs): - """执行异步 HTTP 请求,业务计划与响应规则由共享 helper 决定。""" + async def _cached_async_invoke( + self, + url, + key=None, + _cache_token: str = "", + **kwargs, + ): + """执行带配置身份的异步 HTTP 请求,并复用统一响应解码。""" plan = self._request_plan(url, key=key, **kwargs) response = await self._async_req.get_res(url=plan.url, params=plan.params) return self._decode_response(response, plan.key) + async def __async_invoke(self, url, key=None, **kwargs): + """执行异步 Bangumi 请求,并把当前代理配置纳入缓存键。""" + return await self._cached_async_invoke( + url, + key=key, + _cache_token=self._cache_token, + **kwargs, + ) + @staticmethod def _dated_params(**params: Any) -> dict[str, Any]: """为缓存键稳定附加当天日期,并保留调用方筛选参数。""" @@ -273,7 +346,7 @@ class BangumiApi: def clear_cache(self) -> None: """清除 Bangumi 请求缓存。""" - self.__invoke.cache_clear() + self._cached_invoke.cache_clear() def close(self) -> None: """关闭 Bangumi 同步会话。""" diff --git a/app/modules/bangumi/capability.toml b/app/modules/bangumi/capability.toml index ad0668eab..c8da3f6ff 100644 --- a/app/modules/bangumi/capability.toml +++ b/app/modules/bangumi/capability.toml @@ -12,4 +12,4 @@ priority = 3 [activation] policy = "bootstrap" -watch = ["PROXY_HOST"] +watch = ["PROXY_HOST", "BANGUMI_PROXY_ENABLE", "BANGUMI_API_DOMAIN"] diff --git a/app/runtime/config.py b/app/runtime/config.py index f69419631..fc28b7793 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -391,6 +391,14 @@ class ConfigModel(BaseModel): # TMDB API Key TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381" + # ==================== Bangumi配置 ==================== + # 是否启用 Bangumi 数据与图片代理 + BANGUMI_PROXY_ENABLE: bool = False + # Bangumi API代理地址,留空使用官方地址 + BANGUMI_API_DOMAIN: str = "" + # Bangumi图片代理地址,留空时由后端代理端点直接拉取原始图片 + BANGUMI_IMAGE_DOMAIN: str = "" + # ==================== 音乐配置 ==================== # 音乐封面代理地址(用于解决 coverartarchive.org 无法访问导致的封面不显示问题,留空则使用官方地址) MUSIC_COVER_PROXY: str = "" @@ -750,6 +758,9 @@ class ConfigModel(BaseModel): "bing.com", "doubanio.com", "lain.bgm.tv", + "bgm.tv", + "bangumi.tv", + "bangumi.lol", "raw.githubusercontent.com", "github.com", "thetvdb.com", diff --git a/tests/test_bangumi_api.py b/tests/test_bangumi_api.py new file mode 100644 index 000000000..b43fdf1c6 --- /dev/null +++ b/tests/test_bangumi_api.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock + +from app.modules.bangumi.bangumi import BangumiApi + + +def test_bangumi_api_uses_data_proxy_without_rewriting_payload() -> None: + """Bangumi 数据代理只改变请求地址,不依赖或修改响应报文结构。""" + original_image = "https://lain.bgm.tv/pic/cover.jpg" + response = MagicMock(status_code=200) + response.json.return_value = { + "images": {"large": original_image}, + "unknown_image_field": original_image, + } + api = BangumiApi(base_url="api-proxy.example/bangumi") + api._req.get_res = MagicMock(return_value=response) + + try: + result = api.detail(123) + finally: + api.clear_cache() + api.close() + + assert api._req.get_res.call_args.kwargs["url"] == ( + "https://api-proxy.example/bangumi/v0/subjects/123" + ) + assert result == response.json.return_value + + +def test_bangumi_api_empty_data_proxy_uses_official_endpoint() -> None: + """空数据代理地址应回退官方 Bangumi API。""" + api = BangumiApi(base_url="") + + assert api.base_url == "https://api.bgm.tv/" + api.close() diff --git a/tests/test_bangumi_module.py b/tests/test_bangumi_module.py index 859600e36..068086bb8 100644 --- a/tests/test_bangumi_module.py +++ b/tests/test_bangumi_module.py @@ -76,16 +76,26 @@ def test_bangumi_test_uses_generation_snapshot_until_reload(monkeypatch): """长生命周期模块应在 init/reload 时换快照,而不是每次调用读取全局配置。""" module = BangumiModule() monkeypatch.setattr(settings, "PROXY_HOST", "http://old-proxy") + monkeypatch.setattr(settings, "BANGUMI_PROXY_ENABLE", True) + monkeypatch.setattr(settings, "BANGUMI_API_DOMAIN", "https://old-bangumi.example/api") module.init_module() old_proxy = settings.PROXY monkeypatch.setattr(settings, "PROXY_HOST", "http://new-proxy") + monkeypatch.setattr(settings, "BANGUMI_PROXY_ENABLE", True) + monkeypatch.setattr(settings, "BANGUMI_API_DOMAIN", "https://new-bangumi.example/api") new_proxy = settings.PROXY with patch("app.modules.bangumi.RequestUtils") as request_utils: request_utils.return_value.get_res.return_value = MagicMock(status_code=200) module.test() assert request_utils.call_args.kwargs["proxies"] == old_proxy + assert request_utils.return_value.get_res.call_args.args[0] == ( + "https://old-bangumi.example/api/" + ) module.on_config_changed() module.test() assert request_utils.call_args.kwargs["proxies"] == new_proxy + assert request_utils.return_value.get_res.call_args.args[0] == ( + "https://new-bangumi.example/api/" + ) diff --git a/tests/test_system_image_proxy.py b/tests/test_system_image_proxy.py index e415cfb69..077a5aa25 100644 --- a/tests/test_system_image_proxy.py +++ b/tests/test_system_image_proxy.py @@ -85,6 +85,23 @@ def _image_bytes(image_format: str, trailing: bytes = b"") -> bytes: return buffer.getvalue() + trailing +def test_bangumi_image_proxy_domain_is_added_to_allowlist() -> None: + """启用 Bangumi 图片代理时,应允许其配置主机通过通用图片端点。""" + with patch.object( + system_endpoint, + "get_runtime_settings", + return_value={ + "BANGUMI_PROXY_ENABLE": True, + "BANGUMI_IMAGE_DOMAIN": "https://image-proxy.example/?url=", + "SECURITY_IMAGE_DOMAINS": ["lain.bgm.tv"], + }, + ): + assert system_endpoint._get_image_proxy_allowed_domains() == { + "lain.bgm.tv", + "image-proxy.example", + } + + @pytest.mark.parametrize( ("image_format", "expected_mime"), [