Files
MoviePilot/app/modules/mediavault/api.py
thsrite 429b0020be fix(mediaserver): 修正 MediaVault 图片凭据、查询截断、剧集类型与刷新短路
代码审查在 v2 移植版上发现四处缺陷,同样存在于本分支已合入的模块:

- 图片地址把管理面板的长效 API Key 拼进查询参数。该地址会随接口响应交给浏览器
  直接加载,等于把管理凭据下发到客户端,可被重放到 MediaVault 的全部管理接口。
  MediaVault 的媒体库图片为免鉴权资源,去掉该参数即可
- get_movies 与按标题定位剧集只读关键字查询的第一页,命中数超过单页上限时排在
  后面的目标会被误判为未入库;改为翻页直到取完
- 最新入库把 Series 条目标成电影,只有 Episode 被识别为电视剧;改为两者都映射为
  电视剧,「季:集 - 分集名」副标题仍只给分集
- refresh_library_by_items 把生成器交给 all(),首个媒体库排队失败就会短路,同批
  命中的其余媒体库收不到扫描任务;改为先全部发出请求再汇总

补 6 条回归测试;同时收紧测试替身:假 Api 改为复用真实的 image_url 实现,避免
自行拼装掩盖「图片地址不得携带凭据」这一约束。
2026-09-10 17:28:58 +08:00

127 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Union
from app.adapters.network.http import RequestUtils
from app.foundation.url import UrlUtils
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
@dataclass
class Result:
"""MediaVault 统一响应包装。"""
success: bool
data: Optional[Union[Dict[str, Any], List[Any], str, int, bool]] = None
message: Optional[str] = None
status_code: Optional[int] = None
class Api:
"""MediaVault 自建媒体库的原生 HTTP 接口。
统一走管理端口的 `/api/v1`,凭据为管理面板的长效 API Key;
自建媒体库的接口都挂在 `/api/v1/media-library` 之下。
"""
LIBRARY_PATH = "/api/v1/media-library"
def __init__(self, host: Optional[str] = None, apikey: Optional[str] = None):
self._host = UrlUtils.standardize_base_url(host).rstrip("/") if host else None
self._apikey = apikey
self._request_utils = RequestUtils(use_session=True, timeout=15)
@property
def host(self) -> Optional[str]:
return self._host
@property
def configured(self) -> bool:
return bool(self._host and self._apikey)
def close(self) -> None:
"""释放底层会话。"""
self._request_utils.close()
def image_url(self, item_id: str, image_type: str, host: Optional[str] = None) -> str:
"""拼装图片直链;item_id 传媒体库 ID 时得到媒体库封面。
MediaVault 的图片是免鉴权资源,这里**不能**带上 API Key:该地址会随接口
响应交给浏览器加载,带 Key 等于把管理凭据下发到客户端。
"""
if not self.configured or not item_id:
return ""
base = (UrlUtils.standardize_base_url(host).rstrip("/") if host else self._host)
return f"{base}{self.LIBRARY_PATH}/items/{item_id}/image/{image_type}"
def request(
self,
api: str,
method: Optional[str] = None,
params: Optional[Dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None,
base_path: Optional[str] = None,
suppress_log: bool = False,
) -> Optional[Result]:
"""请求 MediaVault 接口。
:param api: 接口路径,默认相对自建媒体库前缀
:param base_path: 覆盖接口前缀(如站点级 `/api/v1`)
:param suppress_log: 探测类调用可关闭错误日志
:return: 网络层失败返回 None,业务失败返回 success=False 的 Result
"""
if not self.configured or not api:
return None
host = self._host or ""
prefix = base_path if base_path is not None else self.LIBRARY_PATH
url = host + prefix + (api if api.startswith("/") else f"/{api}")
if method is None:
method = "get" if data is None else "post"
headers = {
"User-Agent": get_runtime_setting("USER_AGENT"),
"Accept": "application/json",
"X-API-Key": self._apikey,
}
try:
res = self._request_utils.request(
method=method, url=url, headers=headers, params=params, json=data
)
except Exception as err:
if not suppress_log:
logger.error(f"请求 MediaVault 接口 {url} 异常:{err}")
return None
if res is None:
if not suppress_log:
logger.error(f"请求 MediaVault 接口 {url} 无响应")
return None
if res.status_code >= 400:
message = self.__error_message(res)
if not suppress_log:
logger.error(f"请求 MediaVault 接口 {url} 失败:{res.status_code} {message}")
return Result(False, None, message, res.status_code)
try:
body = res.json()
except Exception as err:
if not suppress_log:
logger.error(f"解析 MediaVault 接口 {url} 响应失败:{err}")
return None
if isinstance(body, dict) and "success" in body:
if not body.get("success"):
message = str(body.get("message") or body.get("detail") or "")
if not suppress_log:
logger.error(f"请求 MediaVault 接口 {url} 未成功:{message}")
return Result(False, None, message, res.status_code)
return Result(True, body.get("data"), None, res.status_code)
return Result(True, body, None, res.status_code)
@staticmethod
def __error_message(res: Any) -> str:
"""从错误响应中取出可读信息,非 JSON 时退回状态文本。"""
try:
body = res.json()
except Exception:
return (res.text or "")[:200]
if isinstance(body, dict):
return str(body.get("detail") or body.get("message") or "")[:200]
return str(body)[:200]