mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 20:05:42 +08:00
feat: 内置 Bangumi 数据与图片代理
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 同步会话。"""
|
||||
|
||||
@@ -12,4 +12,4 @@ priority = 3
|
||||
|
||||
[activation]
|
||||
policy = "bootstrap"
|
||||
watch = ["PROXY_HOST"]
|
||||
watch = ["PROXY_HOST", "BANGUMI_PROXY_ENABLE", "BANGUMI_API_DOMAIN"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
34
tests/test_bangumi_api.py
Normal file
34
tests/test_bangumi_api.py
Normal file
@@ -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()
|
||||
@@ -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/"
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user