mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
Merge pull request #6596 from thsrite/feat/mediavault-mediaserver
This commit is contained in:
@@ -413,7 +413,7 @@ class MediaServerOperationTool(_ServiceOperationTool):
|
||||
name: str = "mediaserver_operation"
|
||||
description: str = (
|
||||
"Operate a configured Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia, "
|
||||
"or Navidrome server. The input schema contains one exact branch per action, "
|
||||
"Navidrome, or MediaVault server. The input schema contains one exact branch per action, "
|
||||
"including providers, effects, required fields, types, defaults, enums, nested "
|
||||
"item fields, and cross-field constraints."
|
||||
)
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
"JellyfinModule": {
|
||||
"name": "Jellyfin"
|
||||
},
|
||||
"MediaVaultModule": {
|
||||
"name": "MediaVault"
|
||||
},
|
||||
"PlexModule": {
|
||||
"name": "Plex"
|
||||
},
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
"JellyfinModule": {
|
||||
"name": "Jellyfin"
|
||||
},
|
||||
"MediaVaultModule": {
|
||||
"name": "MediaVault"
|
||||
},
|
||||
"PlexModule": {
|
||||
"name": "Plex"
|
||||
},
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
"JellyfinModule": {
|
||||
"name": "Jellyfin"
|
||||
},
|
||||
"MediaVaultModule": {
|
||||
"name": "MediaVault"
|
||||
},
|
||||
"PlexModule": {
|
||||
"name": "Plex"
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||||
"""
|
||||
模块开关设置,返回开关名和开关值,开关值为True时代表有值即打开,不实现该方法或返回None代表不使用开关
|
||||
部分模块支持同时开启多个,此时设置项以,分隔,开关值使用in判断
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""MediaVault 宿主模块的惰性兼容入口。"""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
_EXPORTS = {
|
||||
"MediaVaultModule": ("app.modules.mediavault.module", "MediaVaultModule"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""按需解析历史包级导出,并保持模块类的原始反射路径。"""
|
||||
contract = _EXPORTS.get(name)
|
||||
if contract is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module_name, symbol_name = contract
|
||||
value = getattr(import_module(module_name), symbol_name)
|
||||
if name == "MediaVaultModule":
|
||||
value.__module__ = __name__
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""向交互式工具公开兼容符号而不提前加载实现。"""
|
||||
return sorted({*globals(), *_EXPORTS})
|
||||
|
||||
|
||||
__all__ = ["MediaVaultModule"]
|
||||
@@ -0,0 +1,124 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urlencode
|
||||
|
||||
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 时得到媒体库封面。"""
|
||||
if not self.configured or not item_id:
|
||||
return ""
|
||||
base = (UrlUtils.standardize_base_url(host).rstrip("/") if host else self._host)
|
||||
query = urlencode({"api_key": self._apikey})
|
||||
return f"{base}{self.LIBRARY_PATH}/items/{item_id}/image/{image_type}?{query}"
|
||||
|
||||
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]
|
||||
@@ -0,0 +1,22 @@
|
||||
schema_version = 1
|
||||
id = "MediaVaultModule"
|
||||
kind = "host_module"
|
||||
entrypoint = "app.modules.mediavault:MediaVaultModule"
|
||||
depends_on = []
|
||||
|
||||
[metadata]
|
||||
name = "MediaVault"
|
||||
type = "mediaserver"
|
||||
subtype = "MediaVault"
|
||||
priority = 7
|
||||
|
||||
[activation]
|
||||
policy = "when_configured"
|
||||
watch = ["MediaServers"]
|
||||
|
||||
[activation.selector]
|
||||
kind = "system_config_item"
|
||||
key = "MediaServers"
|
||||
match_field = "type"
|
||||
match_value = "mediavault"
|
||||
enabled_field = "enabled"
|
||||
@@ -0,0 +1,589 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
||||
|
||||
from app.application.mediaserver import MediaServerIdentityHelper
|
||||
from app.foundation.url import UrlUtils
|
||||
from app.modules.mediavault.api import Api
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.dashboard import Statistic as _SchemaStatistic
|
||||
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
|
||||
from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState
|
||||
from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary
|
||||
from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem
|
||||
from app.schemas.mediaserver import RefreshMediaItem as _SchemaRefreshMediaItem
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
class MediaVault:
|
||||
"""MediaVault 自建媒体库客户端。
|
||||
|
||||
只使用 MediaVault 管理端口的原生接口,凭据是管理面板的长效 API Key;
|
||||
自建媒体库没有音乐库,也不主动外发 Webhook,相关能力在模块层显式留空。
|
||||
"""
|
||||
|
||||
# MediaVault 单次列表请求的最大条数,与服务端 page_size 上限一致
|
||||
PAGE_LIMIT = 100
|
||||
# 媒体库类型到 MoviePilot 媒体类型的映射
|
||||
LIBRARY_TYPES = {"movies": MediaType.MOVIE.value, "tvshows": MediaType.TV.value}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: Optional[str] = None,
|
||||
apikey: Optional[str] = None,
|
||||
play_host: Optional[str] = None,
|
||||
sync_libraries: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._host = UrlUtils.standardize_base_url(host).rstrip("/") if host else None
|
||||
self._playhost = (
|
||||
UrlUtils.standardize_base_url(play_host).rstrip("/") if play_host else None
|
||||
)
|
||||
self._apikey = apikey
|
||||
self._sync_libraries = sync_libraries or []
|
||||
self._api = Api(host=host, apikey=apikey)
|
||||
self._active = False
|
||||
if not self.is_configured():
|
||||
logger.error("MediaVault 配置不完整!")
|
||||
return
|
||||
self.reconnect()
|
||||
|
||||
# ── 连接状态 ────────────────────────────────────────────────
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""配置是否完整到可以发起请求。"""
|
||||
return bool(self._host and self._apikey)
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""当前 API Key 是否已探测通过。"""
|
||||
return self._active
|
||||
|
||||
def is_inactive(self) -> bool:
|
||||
"""是否需要重连。"""
|
||||
if not self.is_configured():
|
||||
return False
|
||||
return not self._active
|
||||
|
||||
def reconnect(self) -> bool:
|
||||
"""用媒体库列表接口探测连通性与凭据有效性。"""
|
||||
if not self.is_configured():
|
||||
return False
|
||||
result = self._api.request("/libraries", suppress_log=True)
|
||||
self._active = bool(result and result.success)
|
||||
return self._active
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""释放底层会话。"""
|
||||
self._active = False
|
||||
self._api.close()
|
||||
|
||||
def authenticate(self, username: str, password: str) -> Optional[str]:
|
||||
"""用 MediaVault 账号完成用户认证,返回访问令牌。"""
|
||||
if not self.is_configured() or not username or not password:
|
||||
return None
|
||||
result = self._api.request(
|
||||
"/login",
|
||||
method="post",
|
||||
data={"username": username, "password": password},
|
||||
base_path="/api/v1/user-auth",
|
||||
suppress_log=True,
|
||||
)
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None
|
||||
token = result.data.get("access_token") or result.data.get("token")
|
||||
return str(token) if token else None
|
||||
|
||||
# ── 媒体库 ──────────────────────────────────────────────────
|
||||
|
||||
def get_librarys(
|
||||
self, hidden: Optional[bool] = False
|
||||
) -> Optional[List[_SchemaMediaServerLibrary]]:
|
||||
"""获取媒体库列表。"""
|
||||
rows = self.__library_rows()
|
||||
if rows is None:
|
||||
return None
|
||||
libraries = []
|
||||
for row in rows:
|
||||
library_id = str(row.get("id") or "")
|
||||
if not library_id:
|
||||
continue
|
||||
if (
|
||||
hidden
|
||||
and self._sync_libraries
|
||||
and "all" not in self._sync_libraries
|
||||
and library_id not in self._sync_libraries
|
||||
):
|
||||
continue
|
||||
library_type = self.LIBRARY_TYPES.get(
|
||||
str(row.get("library_type") or ""), MediaType.UNKNOWN.value
|
||||
)
|
||||
libraries.append(
|
||||
_SchemaMediaServerLibrary(
|
||||
server="mediavault",
|
||||
id=library_id,
|
||||
item_id=library_id,
|
||||
name=row.get("name"),
|
||||
path=row.get("root_paths") or row.get("root_path"),
|
||||
type=library_type,
|
||||
item_count=self.get_items_count(library_id),
|
||||
image=self._api.image_url(library_id, "primary"),
|
||||
link=f"{self._playhost or self._host}/library",
|
||||
server_type="mediavault",
|
||||
)
|
||||
)
|
||||
return libraries
|
||||
|
||||
def __library_rows(self) -> Optional[List[Dict[str, Any]]]:
|
||||
"""媒体库原始记录,连接失败返回 None。"""
|
||||
result = self._api.request("/libraries")
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None
|
||||
items = result.data.get("items")
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
def get_items_count(self, parent: Union[str, int]) -> Optional[int]:
|
||||
"""获取指定媒体库可同步的媒体条目总数。"""
|
||||
if not parent:
|
||||
return None
|
||||
result = self.__query_items(
|
||||
library_id=str(parent), kinds="Movie,Series", page=1, page_size=1
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
total = result.get("total")
|
||||
return int(total) if total is not None else None
|
||||
|
||||
def get_items(
|
||||
self,
|
||||
parent: Union[str, int],
|
||||
start_index: Optional[int] = 0,
|
||||
limit: Optional[int] = -1,
|
||||
) -> Generator[Optional[_SchemaMediaServerItem], Any, None]:
|
||||
"""遍历媒体库中的电影与剧集条目,limit 为 None 或 -1 时取全部。"""
|
||||
if not parent or not self.is_configured():
|
||||
return
|
||||
# 页大小固定,页码才能稳定映射到偏移量;条数上限在产出侧裁剪
|
||||
skip = max(0, start_index or 0)
|
||||
remaining = None if limit is None or limit == -1 else max(0, limit)
|
||||
page = skip // self.PAGE_LIMIT + 1
|
||||
drop = skip % self.PAGE_LIMIT
|
||||
while remaining is None or remaining > 0:
|
||||
result = self.__query_items(
|
||||
library_id=str(parent),
|
||||
kinds="Movie,Series",
|
||||
page=page,
|
||||
page_size=self.PAGE_LIMIT,
|
||||
)
|
||||
if result is None:
|
||||
return
|
||||
rows = result.get("items") or []
|
||||
for row in rows[drop:]:
|
||||
if remaining is not None and remaining <= 0:
|
||||
return
|
||||
item = self.__format_item_info(row)
|
||||
if item:
|
||||
yield item
|
||||
if remaining is not None:
|
||||
remaining -= 1
|
||||
if len(rows) < self.PAGE_LIMIT:
|
||||
return
|
||||
drop = 0
|
||||
page += 1
|
||||
|
||||
def __query_items(
|
||||
self,
|
||||
library_id: str = "",
|
||||
parent_id: Optional[str] = None,
|
||||
keyword: str = "",
|
||||
kinds: str = "",
|
||||
page: int = 1,
|
||||
page_size: int = 40,
|
||||
filter_by: str = "",
|
||||
sort_by: str = "",
|
||||
sort_order: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""调用条目列表接口;MediaVault 只接受页码,偏移量由调用方换算。"""
|
||||
params: Dict[str, Any] = {"page": max(1, page), "page_size": page_size}
|
||||
if library_id:
|
||||
params["library_id"] = library_id
|
||||
if parent_id is not None:
|
||||
params["parent_id"] = parent_id
|
||||
if keyword:
|
||||
params["keyword"] = keyword
|
||||
if kinds:
|
||||
params["kinds"] = kinds
|
||||
if filter_by:
|
||||
params["filter_by"] = filter_by
|
||||
if sort_by:
|
||||
params["sort_by"] = sort_by
|
||||
if sort_order:
|
||||
params["sort_order"] = sort_order
|
||||
result = self._api.request("/items", params=params)
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None
|
||||
return result.data
|
||||
|
||||
# ── 条目 ────────────────────────────────────────────────────
|
||||
|
||||
def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]:
|
||||
"""获取单个条目详情。"""
|
||||
if not itemid or not self.is_configured():
|
||||
return None
|
||||
result = self._api.request(f"/items/{itemid}", suppress_log=True)
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None
|
||||
return self.__format_item_info(result.data)
|
||||
|
||||
def get_movies(
|
||||
self,
|
||||
title: str,
|
||||
year: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
) -> Optional[List[_SchemaMediaServerItem]]:
|
||||
"""按标题和年份检查电影是否存在。"""
|
||||
if not title or not self.is_configured():
|
||||
return None
|
||||
result = self.__query_items(keyword=title, kinds="Movie", page_size=self.PAGE_LIMIT)
|
||||
if result is None:
|
||||
return None
|
||||
movies = []
|
||||
for row in result.get("items") or []:
|
||||
item = self.__format_item_info(row)
|
||||
if not item or item.title != title:
|
||||
continue
|
||||
if year and str(item.year) != str(year):
|
||||
continue
|
||||
if not MediaServerIdentityHelper.is_compatible(item, media_source, media_id):
|
||||
continue
|
||||
movies.append(item)
|
||||
return movies
|
||||
|
||||
def get_tv_episodes(
|
||||
self,
|
||||
item_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
|
||||
"""返回剧集在媒体库中每季已有的集号。"""
|
||||
if not self.is_configured():
|
||||
return None, None
|
||||
series_id = item_id
|
||||
if series_id:
|
||||
info = self.get_iteminfo(series_id)
|
||||
if not info or not MediaServerIdentityHelper.is_compatible(
|
||||
info, media_source, media_id
|
||||
):
|
||||
# 缓存的条目 ID 失效或指向了别的剧,退回按标题重新定位
|
||||
series_id = None
|
||||
if not series_id:
|
||||
if not title:
|
||||
return None, {}
|
||||
series_id = self.__find_series_id(title, year, media_source, media_id)
|
||||
if series_id is None:
|
||||
return None, None
|
||||
if not series_id:
|
||||
return None, {}
|
||||
result = self._api.request(f"/items/{series_id}/episodes")
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None, None
|
||||
seasons: Dict[int, List[int]] = {}
|
||||
for raw_season, episodes in (result.data.get("seasons") or {}).items():
|
||||
try:
|
||||
season_index = int(raw_season)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if season is not None and season_index != season:
|
||||
continue
|
||||
seasons[season_index] = sorted(
|
||||
{int(episode) for episode in episodes if episode is not None}
|
||||
)
|
||||
return series_id, seasons
|
||||
|
||||
def __find_series_id(
|
||||
self,
|
||||
title: str,
|
||||
year: Optional[str],
|
||||
media_source: Optional[MediaSource],
|
||||
media_id: Optional[str],
|
||||
) -> Optional[str]:
|
||||
"""按标题定位剧集条目 ID;连接失败返回 None,未找到返回空串。"""
|
||||
result = self.__query_items(keyword=title, kinds="Series", page_size=self.PAGE_LIMIT)
|
||||
if result is None:
|
||||
return None
|
||||
for row in result.get("items") or []:
|
||||
item = self.__format_item_info(row)
|
||||
if not item or item.title != title:
|
||||
continue
|
||||
if year and str(item.year) != str(year):
|
||||
continue
|
||||
if not MediaServerIdentityHelper.is_compatible(item, media_source, media_id):
|
||||
continue
|
||||
return str(item.item_id)
|
||||
return ""
|
||||
|
||||
def get_season_episode_ids(self, item_id: str, season: int) -> Dict[int, str]:
|
||||
"""获取指定季的集号到条目 ID 映射。"""
|
||||
if not item_id or not self.is_configured():
|
||||
return {}
|
||||
season_id = self.__find_season_id(item_id, season)
|
||||
if not season_id:
|
||||
return {}
|
||||
episode_ids: Dict[int, str] = {}
|
||||
page = 1
|
||||
while True:
|
||||
result = self.__query_items(
|
||||
parent_id=season_id, kinds="Episode", page=page, page_size=self.PAGE_LIMIT
|
||||
)
|
||||
if result is None:
|
||||
return episode_ids
|
||||
rows = result.get("items") or []
|
||||
for row in rows:
|
||||
episode = row.get("episode")
|
||||
row_id = row.get("id")
|
||||
if episode is None or not row_id:
|
||||
continue
|
||||
episode_ids[int(episode)] = str(row_id)
|
||||
if len(rows) < self.PAGE_LIMIT:
|
||||
return episode_ids
|
||||
page += 1
|
||||
|
||||
def __find_season_id(self, series_id: str, season: int) -> str:
|
||||
"""在剧集下定位指定季的条目 ID。"""
|
||||
page = 1
|
||||
while True:
|
||||
result = self.__query_items(
|
||||
parent_id=series_id, kinds="Season", page=page, page_size=self.PAGE_LIMIT
|
||||
)
|
||||
if result is None:
|
||||
return ""
|
||||
rows = result.get("items") or []
|
||||
for row in rows:
|
||||
if row.get("season") == season and row.get("id"):
|
||||
return str(row["id"])
|
||||
if len(rows) < self.PAGE_LIMIT:
|
||||
return ""
|
||||
page += 1
|
||||
|
||||
def __format_item_info(self, row: Dict[str, Any]) -> Optional[_SchemaMediaServerItem]:
|
||||
"""把 MediaVault 条目转换为统一媒体服务器模型。"""
|
||||
try:
|
||||
metadata = row.get("metadata_info") or {}
|
||||
provider_ids: Dict[str, Any] = {}
|
||||
if row.get("tmdb_id"):
|
||||
provider_ids["Tmdb"] = str(row["tmdb_id"])
|
||||
for source, target in (("imdb_id", "Imdb"), ("tvdb_id", "Tvdb")):
|
||||
value = (metadata.get("external_ids") or {}).get(source)
|
||||
if value:
|
||||
provider_ids[target] = str(value)
|
||||
media_source, media_id = MediaServerIdentityHelper.from_provider_ids(provider_ids)
|
||||
user_data = row.get("user_data") or {}
|
||||
position = user_data.get("position_ticks") or 0
|
||||
return _SchemaMediaServerItem(
|
||||
server="mediavault",
|
||||
library=row.get("library_id"),
|
||||
item_id=str(row.get("id") or ""),
|
||||
item_type=row.get("kind"),
|
||||
title=row.get("title"),
|
||||
original_title=metadata.get("original_title"),
|
||||
year=row.get("year") or None,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
path=self.__item_path(row),
|
||||
user_state=_SchemaMediaServerItemUserState(
|
||||
played=user_data.get("played"),
|
||||
resume=position > 0,
|
||||
last_played_date=self.__local_time(user_data.get("last_played_at")),
|
||||
play_count=int(bool(user_data.get("played"))),
|
||||
) if user_data else None,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"解析 MediaVault 条目失败:{err}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __item_path(row: Dict[str, Any]) -> Optional[str]:
|
||||
"""条目详情带媒体源时取第一个文件路径,列表接口没有路径字段。"""
|
||||
sources = row.get("sources")
|
||||
if isinstance(sources, list):
|
||||
for source in sources:
|
||||
if isinstance(source, dict) and source.get("path"):
|
||||
return str(source["path"])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __local_time(value: Optional[str]) -> Optional[str]:
|
||||
"""把 ISO 时间截断为统一模型使用的秒级本地时间文本。"""
|
||||
if not value:
|
||||
return None
|
||||
return str(value).split(".")[0].replace("T", " ")
|
||||
|
||||
# ── 统计与展示 ──────────────────────────────────────────────
|
||||
|
||||
def get_medias_count(self) -> Optional[_SchemaStatistic]:
|
||||
"""媒体数量统计。"""
|
||||
result = self._api.request("/statistics")
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None
|
||||
return _SchemaStatistic(
|
||||
movie_count=result.data.get("movie_count") or 0,
|
||||
tv_count=result.data.get("series_count") or 0,
|
||||
episode_count=result.data.get("episode_count") or 0,
|
||||
)
|
||||
|
||||
def get_user_count(self) -> int:
|
||||
"""媒体库可见用户数。"""
|
||||
result = self._api.request("/users", suppress_log=True)
|
||||
if not result or not result.success or not isinstance(result.data, list):
|
||||
return 0
|
||||
return len(result.data)
|
||||
|
||||
def get_resume(self, num: Optional[int] = 12) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||||
"""继续观看列表。"""
|
||||
return self.__play_items(filter_by="resume", num=num, resume=True)
|
||||
|
||||
def get_latest(self, num: Optional[int] = 20) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||||
"""最新入库列表。"""
|
||||
return self.__play_items(sort_by="added", sort_order="desc", num=num, resume=False)
|
||||
|
||||
def __play_items(
|
||||
self,
|
||||
num: Optional[int],
|
||||
resume: bool,
|
||||
filter_by: str = "",
|
||||
sort_by: str = "",
|
||||
sort_order: str = "",
|
||||
) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||||
"""把条目列表转换为可播放展示项。"""
|
||||
if not self.is_configured():
|
||||
return None
|
||||
count = max(1, min(self.PAGE_LIMIT, num or 20))
|
||||
result = self.__query_items(
|
||||
kinds="Movie,Episode" if resume else "Movie,Series",
|
||||
filter_by=filter_by,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
page_size=count,
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
items = []
|
||||
for row in (result.get("items") or [])[:count]:
|
||||
row_id = str(row.get("id") or "")
|
||||
if not row_id:
|
||||
continue
|
||||
is_episode = row.get("kind") == "Episode"
|
||||
title: Optional[str] = row.get("title")
|
||||
subtitle: Optional[str] = None
|
||||
if is_episode:
|
||||
title = row.get("series_name") or row.get("title")
|
||||
subtitle = f'S{row.get("season")}:{row.get("episode")} - {row.get("title")}'
|
||||
elif row.get("year"):
|
||||
subtitle = str(row["year"])
|
||||
image_id = row.get("series_id") if is_episode else row_id
|
||||
percent = None
|
||||
duration = row.get("duration_ticks") or 0
|
||||
position = (row.get("user_data") or {}).get("position_ticks") or 0
|
||||
if duration > 0 and position > 0:
|
||||
percent = round(position / duration * 100, 2)
|
||||
items.append(
|
||||
_SchemaMediaServerPlayItem(
|
||||
id=row_id,
|
||||
item_id=row_id,
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
type=MediaType.TV.value if is_episode else MediaType.MOVIE.value,
|
||||
image=self._api.image_url(str(image_id or row_id), "primary"),
|
||||
link=self.get_play_url(row_id),
|
||||
percent=percent,
|
||||
server_type="mediavault",
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
def get_latest_backdrops(
|
||||
self, num: Optional[int] = 20, remote: Optional[bool] = False
|
||||
) -> Optional[List[str]]:
|
||||
"""最新入库条目的背景图地址。"""
|
||||
if not self.is_configured():
|
||||
return None
|
||||
count = max(1, min(self.PAGE_LIMIT, num or 20))
|
||||
# 没有背景图的条目会白占名额,多取一批再按实际有图的截断
|
||||
result = self.__query_items(
|
||||
kinds="Movie,Series", sort_by="added", sort_order="desc", page_size=self.PAGE_LIMIT
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
host = (self._playhost or self._host) if remote else self._host
|
||||
images = []
|
||||
for row in result.get("items") or []:
|
||||
if not row.get("has_backdrop") or not row.get("id"):
|
||||
continue
|
||||
images.append(self._api.image_url(str(row["id"]), "backdrop", host=host))
|
||||
if len(images) == count:
|
||||
break
|
||||
return images
|
||||
|
||||
def get_play_url(self, item_id: str) -> Optional[str]:
|
||||
"""媒体库网页播放地址。"""
|
||||
if not item_id or not self.is_configured():
|
||||
return None
|
||||
return f"{self._playhost or self._host}/library/item/{item_id}"
|
||||
|
||||
# ── 入库刷新 ────────────────────────────────────────────────
|
||||
|
||||
def refresh_root_library(self) -> Optional[bool]:
|
||||
"""触发全部媒体库扫描。"""
|
||||
rows = self.__library_rows()
|
||||
if rows is None:
|
||||
return None
|
||||
results = [self.__queue_scan(str(row.get("id"))) for row in rows if row.get("id")]
|
||||
return all(results) if results else False
|
||||
|
||||
def refresh_library_by_items(
|
||||
self, items: List[_SchemaRefreshMediaItem]
|
||||
) -> Optional[bool]:
|
||||
"""按入库路径定位媒体库并触发扫描;定位不到时退回全库扫描。"""
|
||||
if not items:
|
||||
return False
|
||||
rows = self.__library_rows()
|
||||
if rows is None:
|
||||
return None
|
||||
matched = set()
|
||||
unmatched = False
|
||||
for item in items:
|
||||
library_id = self.__match_library_by_path(rows, item.target_path)
|
||||
if library_id:
|
||||
matched.add(library_id)
|
||||
else:
|
||||
unmatched = True
|
||||
logger.info(f"MediaVault 中未找到 {item.title} 对应的媒体库,将扫描全部媒体库")
|
||||
if unmatched:
|
||||
return self.refresh_root_library()
|
||||
return all(self.__queue_scan(library_id) for library_id in matched)
|
||||
|
||||
@staticmethod
|
||||
def __match_library_by_path(
|
||||
rows: List[Dict[str, Any]], target_path: Optional[Path]
|
||||
) -> str:
|
||||
"""按目录归属把入库路径映射到媒体库。"""
|
||||
if not target_path:
|
||||
return ""
|
||||
for row in rows:
|
||||
roots = row.get("root_paths") or ([row["root_path"]] if row.get("root_path") else [])
|
||||
for root in roots:
|
||||
try:
|
||||
if target_path == Path(root) or Path(root) in target_path.parents:
|
||||
return str(row.get("id") or "")
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return ""
|
||||
|
||||
def __queue_scan(self, library_id: str) -> bool:
|
||||
"""把媒体库扫描排进 MediaVault 的后台队列,不阻塞入库流程。"""
|
||||
if not library_id:
|
||||
return False
|
||||
result = self._api.request(f"/libraries/{library_id}/scan-task", method="post")
|
||||
return bool(result and result.success)
|
||||
@@ -0,0 +1,231 @@
|
||||
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
||||
|
||||
from app.modules._base.mediaserver import _MediaServerModuleBase
|
||||
from app.modules.mediavault.mediavault import MediaVault
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.dashboard import Statistic as _SchemaStatistic
|
||||
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
|
||||
from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary
|
||||
from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem
|
||||
from app.schemas.mediaserver import MediaServerSeasonInfo as _SchemaMediaServerSeasonInfo
|
||||
from app.schemas.types import MediaServerType, ModuleType
|
||||
|
||||
|
||||
class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""MediaVault 自建媒体库模块。"""
|
||||
|
||||
# 媒体库标识(ExistMediaInfo.server_type)
|
||||
_server_type_value = "mediavault"
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""
|
||||
初始化模块
|
||||
"""
|
||||
super().init_service(
|
||||
service_name=MediaVault.__name__.lower(),
|
||||
service_type=lambda conf: MediaVault(
|
||||
**conf.config, sync_libraries=conf.sync_libraries
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "MediaVault"
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""
|
||||
获取模块类型
|
||||
"""
|
||||
return ModuleType.MediaServer
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaServerType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MediaServerType.MediaVault
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效
|
||||
"""
|
||||
return 7
|
||||
|
||||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||||
"""本模块不使用开关设置。"""
|
||||
return None
|
||||
|
||||
def _is_inactive(self, server: MediaVault) -> bool:
|
||||
"""未配置的实例不参与定时重连。"""
|
||||
return server.is_configured() and server.is_inactive()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for server in self.get_instances().values():
|
||||
try:
|
||||
server.disconnect()
|
||||
except Exception as err:
|
||||
logger.error(f"停止 MediaVault 模块实例失败:{err}")
|
||||
|
||||
def _test_server(self, server: MediaVault, name: str) -> Optional[str]:
|
||||
"""用配置完整性与 API Key 探测结果判断连接状态。"""
|
||||
if not server.is_configured():
|
||||
return f"{self.get_name()}配置不完整:{name}"
|
||||
if server.is_inactive() and not server.reconnect():
|
||||
return f"无法连接{self.get_name()}:{name}"
|
||||
return None
|
||||
|
||||
def media_statistic(
|
||||
self, server: Optional[str] = None
|
||||
) -> Optional[List[_SchemaStatistic]]:
|
||||
"""
|
||||
媒体数量统计
|
||||
"""
|
||||
if server:
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
servers = [server_obj] if server_obj else []
|
||||
else:
|
||||
servers = list(self.get_instances().values())
|
||||
statistics = []
|
||||
for s in servers:
|
||||
statistic = s.get_medias_count()
|
||||
if not statistic:
|
||||
continue
|
||||
statistic.user_count = s.get_user_count()
|
||||
statistics.append(statistic)
|
||||
return statistics
|
||||
|
||||
def mediaserver_librarys(
|
||||
self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs: Any
|
||||
) -> Optional[List[_SchemaMediaServerLibrary]]:
|
||||
"""
|
||||
媒体库列表
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_librarys(hidden=hidden)
|
||||
return None
|
||||
|
||||
def mediaserver_items(
|
||||
self,
|
||||
server: str,
|
||||
library_id: Union[str, int],
|
||||
start_index: Optional[int] = 0,
|
||||
limit: Optional[int] = -1,
|
||||
) -> Optional[Generator[Optional[_SchemaMediaServerItem], Any, None]]:
|
||||
"""
|
||||
获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param library_id: 媒体库ID
|
||||
:param start_index: 起始索引
|
||||
:param limit: 每次请求的最大项目数,None 或 -1 表示一次性获取所有数据
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_items(library_id, start_index, limit)
|
||||
return None
|
||||
|
||||
def mediaserver_items_count(
|
||||
self, server: str, library_id: Union[str, int]
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库可同步的媒体条目总数
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_items_count(library_id)
|
||||
return None
|
||||
|
||||
def mediaserver_iteminfo(
|
||||
self, server: str, item_id: str
|
||||
) -> Optional[_SchemaMediaServerItem]:
|
||||
"""
|
||||
媒体库项目详情
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_iteminfo(str(item_id))
|
||||
return None
|
||||
|
||||
def mediaserver_tv_episodes(
|
||||
self, server: str, item_id: Union[str, int]
|
||||
) -> Optional[List[_SchemaMediaServerSeasonInfo]]:
|
||||
"""
|
||||
获取剧集信息
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
_, seasoninfo = server_obj.get_tv_episodes(item_id=str(item_id))
|
||||
if not seasoninfo:
|
||||
return []
|
||||
return [
|
||||
_SchemaMediaServerSeasonInfo(season=season, episodes=episodes)
|
||||
for season, episodes in seasoninfo.items()
|
||||
]
|
||||
|
||||
def mediaserver_season_episode_ids(
|
||||
self, server: str, item_id: Union[str, int], season: int
|
||||
) -> Optional[Dict[int, str]]:
|
||||
"""
|
||||
获取指定季的集号到条目 ID 映射
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_season_episode_ids(str(item_id), season)
|
||||
|
||||
def mediaserver_playing(
|
||||
self, server: str, count: Optional[int] = 20, **kwargs: Any
|
||||
) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||||
"""
|
||||
获取媒体服务器正在播放信息
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_resume(num=count)
|
||||
|
||||
def mediaserver_play_url(
|
||||
self, server: str, item_id: Union[str, int]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
获取媒体库播放地址
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_play_url(str(item_id))
|
||||
|
||||
def mediaserver_latest(
|
||||
self, server: Optional[str] = None, count: Optional[int] = 20, **kwargs: Any
|
||||
) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_latest(num=count)
|
||||
|
||||
def mediaserver_latest_images(
|
||||
self,
|
||||
server: Optional[str] = None,
|
||||
count: Optional[int] = 20,
|
||||
remote: Optional[bool] = False,
|
||||
**kwargs: Any,
|
||||
) -> List[str]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目的图片
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param count: 获取数量
|
||||
:param remote: True为外网链接,False为内网链接
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return []
|
||||
return server_obj.get_latest_backdrops(num=count, remote=remote) or []
|
||||
@@ -67,6 +67,7 @@ BASELINE_ASSESSED_MODULES = frozenset(
|
||||
"jellyfin",
|
||||
"listenbrainz",
|
||||
"lrclib",
|
||||
"mediavault",
|
||||
"musicbrainz",
|
||||
"musixmatch",
|
||||
"navidrome",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
from app.runtime.events import eventmanager, Event
|
||||
from app.runtime.log import logger
|
||||
@@ -17,7 +18,7 @@ class ConfigReloadMixin:
|
||||
# 统一生命周期管理器可以继承此 Mixin 的重载方法,但由外部唯一负责事件绑定。
|
||||
CONFIG_RELOAD_MANAGED_EXTERNALLY: bool = False
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""为声明了 CONFIG_WATCH 的子类生成配置变更处理器。"""
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
|
||||
@@ -594,6 +594,8 @@ class MediaServerType(Enum):
|
||||
Ugreen = "Ugreen"
|
||||
# Navidrome 音乐服务器
|
||||
Navidrome = "Navidrome"
|
||||
# MediaVault 自建媒体库
|
||||
MediaVault = "MediaVault"
|
||||
|
||||
|
||||
# 识别器类型
|
||||
|
||||
@@ -754,8 +754,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 976 |
|
||||
| 内部导入边 | 8,270 |
|
||||
| Python 模块 | 980 |
|
||||
| 内部导入边 | 8,302 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||
|
||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 976 / 8,270 | `dependency-baseline.json` 当前快照 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 980 / 8,302 | `dependency-baseline.json` 当前快照 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
@@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 |
|
||||
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
|
||||
| 全量 mypy 历史债务 | 9,494 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| 全量 mypy 历史债务 | 9,441 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| Ruff 历史诊断 | 538 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。
|
||||
| :--- | :--- | :--- |
|
||||
| `moviepilot_api` | MoviePilot 产品业务 API:媒体、搜索、订阅、下载、整理、站点、存储、调度、工作流、插件、过滤规则和系统配置 | `skills/moviepilot-api/SKILL.md`;运行时 schema 为 `app/agent/policy/resources/api_mcp_schema.json` |
|
||||
| `downloader_operation` | qBittorrent、Transmission、rTorrent 原生任务、队列、文件、限速、标签和会话操作 | `skills/downloader-operation/SKILL.md` 与 `skills/downloader-operation/scripts/mp-downloader.py` 的 `ACTIONS` |
|
||||
| `mediaserver_operation` | Emby、Jellyfin、Plex、ZSpace、UGREEN、TrimeMedia、Navidrome 原生媒体库、搜索、播放、扫描和刷新操作 | `skills/mediaserver-operation/SKILL.md` 与 `skills/mediaserver-operation/scripts/mp-mediaserver.py` 的 `ACTIONS` |
|
||||
| `mediaserver_operation` | Emby、Jellyfin、Plex、ZSpace、UGREEN、TrimeMedia、Navidrome、MediaVault 原生媒体库、搜索、播放、扫描和刷新操作 | `skills/mediaserver-operation/SKILL.md` 与 `skills/mediaserver-operation/scripts/mp-mediaserver.py` 的 `ACTIONS` |
|
||||
| `database_operation` | MoviePilot 配置数据库表清单、实时 schema、只读 SQL 和明确授权写入 | `skills/database-operation/SKILL.md` 与 `skills/database-operation/scripts/mp-db.py` 的 `ACTIONS` |
|
||||
|
||||
这四个工具都要求管理员级 MCP 集成身份;`tools/list` 的可见性不等于绕过业务权限或写操作确认。下载器和媒体服务器工具会在一次调用内自动选择默认/唯一实例;实例不明确时,错误结果会列出可复用的精确实例名。数据库工具不接受任意连接串或凭据,脚本从 MoviePilot 运行时配置读取数据库连接。
|
||||
|
||||
@@ -139,46 +139,46 @@ A field name ending in `*` is required. Put every action parameter in the `argum
|
||||
| `server.users.count` | Read provider user count.; no arguments |
|
||||
|
||||
### `activity.backdrops`
|
||||
Read recent provider backdrop images. Effect: `safe_read`. Providers: `ugreen, trimemedia`.
|
||||
Read recent provider backdrop images. Effect: `safe_read`. Providers: `ugreen, trimemedia, mediavault`.
|
||||
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
|
||||
- `remote` (boolean; default `False`): Return provider URLs that are remotely accessible.
|
||||
|
||||
### `activity.latest`
|
||||
Read recently added provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Read recently added provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
|
||||
- `username` (string): Read for this username; supported by Emby, Jellyfin, and ZSpace.
|
||||
|
||||
### `activity.resume`
|
||||
Read in-progress/resumable provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Read in-progress/resumable provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
|
||||
- `username` (string): Read for this username; supported by Emby, Jellyfin, and ZSpace.
|
||||
|
||||
### `capabilities.list`
|
||||
List supported media-server actions and their complete argument contracts. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
List supported media-server actions and their complete argument contracts. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `action_name` (string): Optional exact action name used to return one capability contract.
|
||||
|
||||
### `instances.list`
|
||||
List configured media-server instances without connection secrets. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
List configured media-server instances without connection secrets. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `arguments`: `{}`
|
||||
|
||||
### `items.count`
|
||||
Count items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Count items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `parent` (string|integer): Library or parent item ID; Navidrome may omit it and use music.
|
||||
- Rule: parent is required except for Navidrome, which defaults to music.
|
||||
|
||||
### `items.detail`
|
||||
Read one provider item by native ID. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Read one provider item by native ID. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `item_id*` (string): Provider-native item ID returned by the selected media server.
|
||||
|
||||
### `items.list`
|
||||
Page items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Page items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `parent` (string|integer): Library or parent item ID; Navidrome may omit it and use music.
|
||||
- `offset` (integer; default `0`): Zero-based list offset.
|
||||
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
|
||||
- Rule: parent is required except for Navidrome, which ignores it.
|
||||
|
||||
### `items.movies.search`
|
||||
Search provider-native movie items by title and optional year. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia`.
|
||||
Search provider-native movie items by title and optional year. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, mediavault`.
|
||||
- `title*` (string): Movie title.
|
||||
- `year` (string|integer): Optional release year.
|
||||
|
||||
@@ -190,7 +190,7 @@ Search provider-native music by title, artist, or album. Effect: `safe_read`. Pr
|
||||
- Rule: Provide at least one of title, artist, and album.
|
||||
|
||||
### `items.season_episodes`
|
||||
Read native episode coverage for one series and optional season. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia`.
|
||||
Read native episode coverage for one series and optional season. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, mediavault`.
|
||||
- `item_id` (string): Provider-native item ID returned by the selected media server.
|
||||
- `title` (string): Series title; provide it or item_id.
|
||||
- `year` (string|integer): Optional premiere year.
|
||||
@@ -198,12 +198,12 @@ Read native episode coverage for one series and optional season. Effect: `safe_r
|
||||
- Rule: Provide at least one of item_id and title.
|
||||
|
||||
### `libraries.list`
|
||||
List visible provider libraries. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
List visible provider libraries. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `hidden` (boolean; default `False`): Return only libraries configured for synchronization.
|
||||
- `username` (string): Read libraries visible to this username; supported by Emby, Jellyfin, and ZSpace.
|
||||
|
||||
### `library.scan`
|
||||
Trigger a provider library scan. Effect: `external_side_effect`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Trigger a provider library scan. Effect: `external_side_effect`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `scan_mode` (string|integer): UGREEN-native scan mode; omit it for every other provider.
|
||||
|
||||
### `metadata.refresh`
|
||||
@@ -215,11 +215,11 @@ Read active playback sessions. Effect: `safe_read`. Providers: `emby, jellyfin,
|
||||
- `arguments`: `{}`
|
||||
|
||||
### `playback.url`
|
||||
Build the provider play URL for one item. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Build the provider play URL for one item. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `item_id*` (string): Provider-native item ID returned by the selected media server.
|
||||
|
||||
### `server.statistics`
|
||||
Read media counts and provider statistics. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Read media counts and provider statistics. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `arguments`: `{}`
|
||||
|
||||
### `server.user.library_folders`
|
||||
@@ -227,7 +227,7 @@ Read the current user's visible library folders. Effect: `safe_read`. Providers:
|
||||
- `arguments`: `{}`
|
||||
|
||||
### `server.users.count`
|
||||
Read provider user count. Effect: `safe_read`. Providers: `emby, jellyfin, zspace, ugreen, trimemedia, navidrome`.
|
||||
Read provider user count. Effect: `safe_read`. Providers: `emby, jellyfin, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `arguments`: `{}`
|
||||
|
||||
## Safety And Verification
|
||||
|
||||
@@ -25,6 +25,7 @@ ALL_PROVIDERS = (
|
||||
"ugreen",
|
||||
"trimemedia",
|
||||
"navidrome",
|
||||
"mediavault",
|
||||
)
|
||||
PROVIDER_CLASSES = {
|
||||
"emby": "app.modules.emby.emby:Emby",
|
||||
@@ -34,6 +35,7 @@ PROVIDER_CLASSES = {
|
||||
"ugreen": "app.modules.ugreen.ugreen:Ugreen",
|
||||
"trimemedia": "app.modules.trimemedia.trimemedia:TrimeMedia",
|
||||
"navidrome": "app.modules.navidrome.navidrome:Navidrome",
|
||||
"mediavault": "app.modules.mediavault.mediavault:MediaVault",
|
||||
}
|
||||
_UNSET = object()
|
||||
|
||||
@@ -122,7 +124,7 @@ ACTIONS: dict[str, ActionSpec] = {
|
||||
"server.users.count": ActionSpec(
|
||||
"Read provider user count.",
|
||||
"safe_read",
|
||||
("emby", "jellyfin", "zspace", "ugreen", "trimemedia", "navidrome"),
|
||||
("emby", "jellyfin", "zspace", "ugreen", "trimemedia", "navidrome", "mediavault"),
|
||||
),
|
||||
"server.user.library_folders": ActionSpec(
|
||||
"Read the current user's visible library folders.",
|
||||
@@ -157,7 +159,7 @@ ACTIONS: dict[str, ActionSpec] = {
|
||||
"items.movies.search": ActionSpec(
|
||||
"Search provider-native movie items by title and optional year.",
|
||||
"safe_read",
|
||||
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
|
||||
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia", "mediavault"),
|
||||
(
|
||||
ArgumentSpec("title", "string", "Movie title.", required=True),
|
||||
ArgumentSpec("year", "string|integer", "Optional release year."),
|
||||
@@ -177,7 +179,7 @@ ACTIONS: dict[str, ActionSpec] = {
|
||||
"items.season_episodes": ActionSpec(
|
||||
"Read native episode coverage for one series and optional season.",
|
||||
"safe_read",
|
||||
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
|
||||
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia", "mediavault"),
|
||||
(
|
||||
ITEM_ID,
|
||||
ArgumentSpec("title", "string", "Series title; provide it or item_id."),
|
||||
@@ -199,7 +201,7 @@ ACTIONS: dict[str, ActionSpec] = {
|
||||
"activity.backdrops": ActionSpec(
|
||||
"Read recent provider backdrop images.",
|
||||
"safe_read",
|
||||
("ugreen", "trimemedia"),
|
||||
("ugreen", "trimemedia", "mediavault"),
|
||||
(
|
||||
LIMIT,
|
||||
ArgumentSpec("remote", "boolean", "Return provider URLs that are remotely accessible.", default=False),
|
||||
|
||||
+39
-3
@@ -1074,8 +1074,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 8270,
|
||||
"edge_sha256": "8f25287ff625b0327fa3ee567ff99ac90818376d0fdc34e91834eba90e133d7c",
|
||||
"edge_count": 8302,
|
||||
"edge_sha256": "49000047cece20aa2bd7f1d06916072d06b832f0f2d929693f995206f9a29b84",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -6898,6 +6898,38 @@
|
||||
"app.modules.lrclib -> app.runtime.settings",
|
||||
"app.modules.lrclib -> app.schemas",
|
||||
"app.modules.lrclib -> app.schemas.types",
|
||||
"app.modules.mediavault.api -> app.adapters",
|
||||
"app.modules.mediavault.api -> app.adapters.network",
|
||||
"app.modules.mediavault.api -> app.adapters.network.http",
|
||||
"app.modules.mediavault.api -> app.foundation",
|
||||
"app.modules.mediavault.api -> app.foundation.url",
|
||||
"app.modules.mediavault.api -> app.runtime",
|
||||
"app.modules.mediavault.api -> app.runtime.log",
|
||||
"app.modules.mediavault.api -> app.runtime.settings",
|
||||
"app.modules.mediavault.mediavault -> app.application",
|
||||
"app.modules.mediavault.mediavault -> app.application.mediaserver",
|
||||
"app.modules.mediavault.mediavault -> app.foundation",
|
||||
"app.modules.mediavault.mediavault -> app.foundation.url",
|
||||
"app.modules.mediavault.mediavault -> app.modules",
|
||||
"app.modules.mediavault.mediavault -> app.modules.mediavault",
|
||||
"app.modules.mediavault.mediavault -> app.modules.mediavault.api",
|
||||
"app.modules.mediavault.mediavault -> app.runtime",
|
||||
"app.modules.mediavault.mediavault -> app.runtime.log",
|
||||
"app.modules.mediavault.mediavault -> app.schemas",
|
||||
"app.modules.mediavault.mediavault -> app.schemas.dashboard",
|
||||
"app.modules.mediavault.mediavault -> app.schemas.mediaserver",
|
||||
"app.modules.mediavault.mediavault -> app.schemas.types",
|
||||
"app.modules.mediavault.module -> app.modules",
|
||||
"app.modules.mediavault.module -> app.modules._base",
|
||||
"app.modules.mediavault.module -> app.modules._base.mediaserver",
|
||||
"app.modules.mediavault.module -> app.modules.mediavault",
|
||||
"app.modules.mediavault.module -> app.modules.mediavault.mediavault",
|
||||
"app.modules.mediavault.module -> app.runtime",
|
||||
"app.modules.mediavault.module -> app.runtime.log",
|
||||
"app.modules.mediavault.module -> app.schemas",
|
||||
"app.modules.mediavault.module -> app.schemas.dashboard",
|
||||
"app.modules.mediavault.module -> app.schemas.mediaserver",
|
||||
"app.modules.mediavault.module -> app.schemas.types",
|
||||
"app.modules.musicbrainz -> app.adapters",
|
||||
"app.modules.musicbrainz -> app.adapters.network",
|
||||
"app.modules.musicbrainz -> app.adapters.network.http",
|
||||
@@ -9348,7 +9380,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 976,
|
||||
"module_count": 980,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -10009,6 +10041,10 @@
|
||||
"app.modules.jellyfin.jellyfin",
|
||||
"app.modules.listenbrainz",
|
||||
"app.modules.lrclib",
|
||||
"app.modules.mediavault",
|
||||
"app.modules.mediavault.api",
|
||||
"app.modules.mediavault.mediavault",
|
||||
"app.modules.mediavault.module",
|
||||
"app.modules.musicbrainz",
|
||||
"app.modules.musicbrainz.cache",
|
||||
"app.modules.musixmatch",
|
||||
|
||||
+21
-60
@@ -1395,39 +1395,34 @@
|
||||
"empty-body": 4,
|
||||
"misc": 3,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 5,
|
||||
"no-untyped-call": 4,
|
||||
"no-untyped-def": 7,
|
||||
"union-attr": 4
|
||||
},
|
||||
"app/modules/_base/downloader.py": {
|
||||
"assignment": 1,
|
||||
"attr-defined": 5,
|
||||
"no-untyped-call": 1
|
||||
"attr-defined": 5
|
||||
},
|
||||
"app/modules/_base/mediaserver.py": {
|
||||
"arg-type": 2,
|
||||
"assignment": 3,
|
||||
"attr-defined": 5,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"var-annotated": 1
|
||||
},
|
||||
"app/modules/_base/notification.py": {
|
||||
"arg-type": 1,
|
||||
"assignment": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 3,
|
||||
"type-arg": 5
|
||||
},
|
||||
"app/modules/acoustid/__init__.py": {
|
||||
"arg-type": 1,
|
||||
"list-item": 1,
|
||||
"no-untyped-call": 1
|
||||
"list-item": 1
|
||||
},
|
||||
"app/modules/anilist/__init__.py": {
|
||||
"assignment": 4,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 5,
|
||||
"return-value": 1,
|
||||
"type-arg": 12
|
||||
@@ -1442,7 +1437,6 @@
|
||||
"app/modules/bangumi/__init__.py": {
|
||||
"arg-type": 2,
|
||||
"assignment": 4,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 5,
|
||||
"return-value": 1,
|
||||
"type-arg": 7
|
||||
@@ -1452,9 +1446,7 @@
|
||||
},
|
||||
"app/modules/dingtalk/__init__.py": {
|
||||
"arg-type": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"override": 1
|
||||
"no-untyped-def": 1
|
||||
},
|
||||
"app/modules/dingtalk/dingtalk.py": {
|
||||
"no-untyped-def": 1
|
||||
@@ -1466,7 +1458,6 @@
|
||||
"empty-body": 1,
|
||||
"misc": 1,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"truthy-function": 1,
|
||||
"type-arg": 8
|
||||
@@ -1487,7 +1478,7 @@
|
||||
"empty-body": 1,
|
||||
"misc": 2,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 4,
|
||||
"no-untyped-call": 3,
|
||||
"no-untyped-def": 7,
|
||||
"return-value": 2,
|
||||
"type-arg": 13,
|
||||
@@ -1512,7 +1503,6 @@
|
||||
"arg-type": 3,
|
||||
"assignment": 13,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"type-arg": 1
|
||||
},
|
||||
@@ -1530,7 +1520,6 @@
|
||||
"app/modules/fanart/__init__.py": {
|
||||
"misc": 2,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"type-arg": 10,
|
||||
"var-annotated": 1
|
||||
@@ -1539,7 +1528,6 @@
|
||||
"arg-type": 4,
|
||||
"assignment": 6,
|
||||
"attr-defined": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"return-value": 1,
|
||||
"type-arg": 5,
|
||||
@@ -1561,7 +1549,7 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 3,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-def": 5,
|
||||
"type-arg": 3,
|
||||
"var-annotated": 3
|
||||
@@ -1668,7 +1656,7 @@
|
||||
"assignment": 4,
|
||||
"empty-body": 1,
|
||||
"index": 1,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 3,
|
||||
"operator": 2,
|
||||
"return": 1,
|
||||
@@ -1678,9 +1666,7 @@
|
||||
"app/modules/imdb/__init__.py": {
|
||||
"assignment": 10,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 3,
|
||||
"override": 1,
|
||||
"type-arg": 5
|
||||
},
|
||||
"app/modules/imdb/api.py": {
|
||||
@@ -1694,7 +1680,7 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 4,
|
||||
"no-untyped-call": 3,
|
||||
"no-untyped-def": 1,
|
||||
"return-value": 5,
|
||||
"type-arg": 19,
|
||||
@@ -1913,7 +1899,6 @@
|
||||
"arg-type": 3,
|
||||
"assignment": 13,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"type-arg": 1
|
||||
},
|
||||
@@ -1932,22 +1917,17 @@
|
||||
},
|
||||
"app/modules/listenbrainz/__init__.py": {
|
||||
"misc": 2,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 1,
|
||||
"override": 1
|
||||
"no-any-return": 2
|
||||
},
|
||||
"app/modules/lrclib/__init__.py": {
|
||||
"misc": 1,
|
||||
"no-untyped-call": 1,
|
||||
"override": 1
|
||||
"misc": 1
|
||||
},
|
||||
"app/modules/musicbrainz/__init__.py": {
|
||||
"arg-type": 1,
|
||||
"assignment": 5,
|
||||
"misc": 4,
|
||||
"no-untyped-call": 4,
|
||||
"no-untyped-call": 3,
|
||||
"no-untyped-def": 2,
|
||||
"override": 1,
|
||||
"type-arg": 2
|
||||
},
|
||||
"app/modules/musicbrainz/cache.py": {
|
||||
@@ -1958,13 +1938,10 @@
|
||||
"union-attr": 1
|
||||
},
|
||||
"app/modules/musixmatch/__init__.py": {
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1
|
||||
"no-any-return": 1
|
||||
},
|
||||
"app/modules/navidrome/__init__.py": {
|
||||
"arg-type": 9,
|
||||
"no-untyped-call": 1,
|
||||
"override": 1,
|
||||
"type-arg": 1,
|
||||
"union-attr": 1
|
||||
},
|
||||
@@ -1976,7 +1953,7 @@
|
||||
"arg-type": 7,
|
||||
"assignment": 16,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 5,
|
||||
"type-arg": 1
|
||||
},
|
||||
@@ -1994,7 +1971,6 @@
|
||||
},
|
||||
"app/modules/postgresql/__init__.py": {
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1
|
||||
},
|
||||
"app/modules/qbittorrent/__init__.py": {
|
||||
@@ -2002,7 +1978,6 @@
|
||||
"assignment": 16,
|
||||
"empty-body": 1,
|
||||
"no-redef": 2,
|
||||
"no-untyped-call": 1,
|
||||
"operator": 1,
|
||||
"str-bytes-safe": 2,
|
||||
"type-arg": 8,
|
||||
@@ -2029,7 +2004,6 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"type-arg": 4,
|
||||
"var-annotated": 1
|
||||
@@ -2042,7 +2016,7 @@
|
||||
},
|
||||
"app/modules/redis/__init__.py": {
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1
|
||||
},
|
||||
"app/modules/rtorrent/__init__.py": {
|
||||
@@ -2050,7 +2024,6 @@
|
||||
"assignment": 16,
|
||||
"empty-body": 1,
|
||||
"no-redef": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"operator": 1,
|
||||
"str-bytes-safe": 2,
|
||||
@@ -2072,7 +2045,6 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"index": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"type-arg": 7
|
||||
},
|
||||
@@ -2089,7 +2061,6 @@
|
||||
"app/modules/subtitle/__init__.py": {
|
||||
"arg-type": 1,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"var-annotated": 1
|
||||
},
|
||||
@@ -2099,7 +2070,6 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"type-arg": 4,
|
||||
"var-annotated": 1
|
||||
@@ -2115,7 +2085,6 @@
|
||||
"assignment": 9,
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"type-arg": 11,
|
||||
"union-attr": 1
|
||||
@@ -2135,9 +2104,7 @@
|
||||
"app/modules/theaudiodb/__init__.py": {
|
||||
"assignment": 4,
|
||||
"misc": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"override": 1
|
||||
"no-untyped-def": 2
|
||||
},
|
||||
"app/modules/themoviedb/__init__.py": {
|
||||
"arg-type": 5,
|
||||
@@ -2145,7 +2112,7 @@
|
||||
"empty-body": 1,
|
||||
"index": 2,
|
||||
"no-any-return": 4,
|
||||
"no-untyped-call": 6,
|
||||
"no-untyped-call": 5,
|
||||
"no-untyped-def": 3,
|
||||
"return-value": 2,
|
||||
"type-arg": 19,
|
||||
@@ -2291,7 +2258,7 @@
|
||||
"app/modules/thetvdb/__init__.py": {
|
||||
"empty-body": 1,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 4,
|
||||
"type-arg": 2
|
||||
},
|
||||
@@ -2311,7 +2278,7 @@
|
||||
"empty-body": 1,
|
||||
"no-any-return": 1,
|
||||
"no-redef": 2,
|
||||
"no-untyped-call": 10,
|
||||
"no-untyped-call": 9,
|
||||
"no-untyped-def": 7,
|
||||
"str-bytes-safe": 1,
|
||||
"type-arg": 6
|
||||
@@ -2333,7 +2300,7 @@
|
||||
"assignment": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 3,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 7,
|
||||
"return": 1,
|
||||
"type-arg": 2
|
||||
@@ -2363,7 +2330,6 @@
|
||||
"assignment": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 3,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 7,
|
||||
"type-arg": 2
|
||||
},
|
||||
@@ -2383,7 +2349,6 @@
|
||||
"assignment": 6,
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 3,
|
||||
"type-arg": 4,
|
||||
"union-attr": 1
|
||||
@@ -2398,7 +2363,6 @@
|
||||
"arg-type": 1,
|
||||
"assignment": 1,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"type-arg": 1,
|
||||
"union-attr": 1
|
||||
@@ -2409,7 +2373,7 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 3,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-def": 5,
|
||||
"type-arg": 4,
|
||||
"union-attr": 4
|
||||
@@ -2435,7 +2399,6 @@
|
||||
"assignment": 3,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 4,
|
||||
"type-arg": 2,
|
||||
"union-attr": 1
|
||||
@@ -2453,7 +2416,6 @@
|
||||
"arg-type": 3,
|
||||
"assignment": 12,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"type-arg": 1
|
||||
},
|
||||
@@ -2653,7 +2615,7 @@
|
||||
"app/runtime/reload.py": {
|
||||
"misc": 1,
|
||||
"no-untyped-call": 5,
|
||||
"no-untyped-def": 6
|
||||
"no-untyped-def": 5
|
||||
},
|
||||
"app/runtime/scheduling.py": {
|
||||
"import-untyped": 1,
|
||||
@@ -2668,7 +2630,6 @@
|
||||
"app/runtime/state.py": {
|
||||
"attr-defined": 2,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 3,
|
||||
"type-arg": 1
|
||||
},
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
"""MediaVault 自建媒体库客户端的行为契约,全部走假 API 不发真实请求。"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.mediavault.api import Result
|
||||
from app.modules.mediavault.mediavault import MediaVault
|
||||
from app.schemas.mediaserver import RefreshMediaItem
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
class _FakeApi:
|
||||
"""按路径返回预置数据,并记录调用参数。"""
|
||||
|
||||
def __init__(self, routes: dict, host: str = "http://mv.local"):
|
||||
self.routes = routes
|
||||
self.calls = []
|
||||
self.closed = False
|
||||
self._host = host
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def image_url(self, item_id: str, image_type: str, host: Optional[str] = None) -> str:
|
||||
return f"{host or self._host}/api/v1/media-library/items/{item_id}/image/{image_type}?api_key=k"
|
||||
|
||||
def request(self, api, method=None, params=None, data=None, base_path=None, suppress_log=False):
|
||||
self.calls.append({"api": api, "method": method, "params": params or {}, "data": data,
|
||||
"base_path": base_path})
|
||||
handler = self.routes.get(api)
|
||||
if handler is None:
|
||||
return Result(False, None, "not found", 404)
|
||||
return handler(params or {}, data) if callable(handler) else handler
|
||||
|
||||
|
||||
def _client(routes: dict, **kwargs) -> MediaVault:
|
||||
"""构造一个绕过网络探测的客户端。"""
|
||||
client = MediaVault.__new__(MediaVault)
|
||||
client._host = "http://mv.local"
|
||||
client._playhost = kwargs.get("play_host")
|
||||
client._apikey = "k"
|
||||
client._sync_libraries = kwargs.get("sync_libraries") or []
|
||||
client._api = _FakeApi(routes)
|
||||
client._active = True
|
||||
return client
|
||||
|
||||
|
||||
def _item_row(index: int, kind: str = "Movie", **extra) -> dict:
|
||||
row = {"id": f"id-{index}", "library_id": "lib-1", "parent_id": "", "kind": kind,
|
||||
"title": f"影片{index}", "year": 2020, "tmdb_id": 1000 + index, "overview": "",
|
||||
"genres": [], "has_poster": True, "has_backdrop": False, "season": 0, "episode": 0,
|
||||
"duration_ticks": 0, "is_missing": False, "metadata_info": {}, "user_data": {}}
|
||||
row.update(extra)
|
||||
return row
|
||||
|
||||
|
||||
def _paged_items(total_rows: list):
|
||||
"""按 page/page_size 切分预置条目,模拟 MediaVault 的分页语义。"""
|
||||
|
||||
def handler(params, _data):
|
||||
page = int(params.get("page", 1))
|
||||
size = int(params.get("page_size", 40))
|
||||
start = (page - 1) * size
|
||||
return Result(True, {"items": total_rows[start:start + size], "total": len(total_rows)})
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
# ── 分页 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [1, 30, 100, 130, 250, 356, 400])
|
||||
def test_get_items_limit_matches_full_scan_prefix(limit):
|
||||
"""限量遍历的结果必须是全量遍历的前缀,不重复也不跳条。"""
|
||||
rows = [_item_row(i) for i in range(356)]
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
full = [item.item_id for item in client.get_items("lib-1")]
|
||||
assert len(full) == len(set(full)) == 356
|
||||
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
got = [item.item_id for item in client.get_items("lib-1", limit=limit)]
|
||||
assert got == full[:limit]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start_index", [0, 30, 100, 130, 250])
|
||||
def test_get_items_start_index_matches_full_scan_slice(start_index):
|
||||
"""起始偏移不是页大小整数倍时,也必须精确对齐到全量切片。"""
|
||||
rows = [_item_row(i) for i in range(356)]
|
||||
full = [item.item_id for item in _client({"/items": _paged_items(rows)}).get_items("lib-1")]
|
||||
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
got = [item.item_id for item in client.get_items("lib-1", start_index=start_index, limit=20)]
|
||||
assert got == full[start_index:start_index + 20]
|
||||
|
||||
|
||||
def test_get_items_always_requests_full_pages():
|
||||
"""页大小恒为上限,页码才能稳定换算成偏移量。"""
|
||||
rows = [_item_row(i) for i in range(250)]
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
list(client.get_items("lib-1", limit=130))
|
||||
sizes = {call["params"]["page_size"] for call in client._api.calls}
|
||||
pages = [call["params"]["page"] for call in client._api.calls]
|
||||
assert sizes == {MediaVault.PAGE_LIMIT}
|
||||
assert pages == [1, 2]
|
||||
|
||||
|
||||
def test_get_items_stops_when_request_fails():
|
||||
"""中途请求失败时停止产出,不把失败当成遍历结束的空库。"""
|
||||
rows = [_item_row(i) for i in range(150)]
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky(params, _data):
|
||||
calls["n"] += 1
|
||||
if calls["n"] > 1:
|
||||
return None
|
||||
return _paged_items(rows)(params, None)
|
||||
|
||||
client = _client({"/items": flaky})
|
||||
assert len([*client.get_items("lib-1")]) == 100
|
||||
|
||||
|
||||
# ── 媒体库与统计 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_librarys_maps_type_and_builds_image_url():
|
||||
"""媒体库类型按 MediaVault 的 library_type 映射,封面走带鉴权的图片直链。"""
|
||||
routes = {
|
||||
"/libraries": Result(True, {"items": [
|
||||
{"id": "lib-1", "name": "电影库", "library_type": "movies", "root_paths": ["/mnt/movies"]},
|
||||
{"id": "lib-2", "name": "剧集库", "library_type": "tvshows", "root_path": "/mnt/tv"},
|
||||
{"id": "lib-3", "name": "未知库", "library_type": "other", "root_paths": []},
|
||||
]}),
|
||||
"/items": _paged_items([_item_row(0)]),
|
||||
}
|
||||
libraries = _client(routes).get_librarys()
|
||||
assert [lib.type for lib in libraries] == [
|
||||
MediaType.MOVIE.value, MediaType.TV.value, MediaType.UNKNOWN.value
|
||||
]
|
||||
assert libraries[0].path == ["/mnt/movies"]
|
||||
assert libraries[1].path == "/mnt/tv"
|
||||
assert libraries[0].image.endswith("/items/lib-1/image/primary?api_key=k")
|
||||
assert libraries[0].server_type == "mediavault"
|
||||
|
||||
|
||||
def test_get_librarys_hidden_respects_sync_selection():
|
||||
"""开启过滤时只保留已勾选同步的媒体库。"""
|
||||
routes = {
|
||||
"/libraries": Result(True, {"items": [
|
||||
{"id": "lib-1", "name": "A", "library_type": "movies"},
|
||||
{"id": "lib-2", "name": "B", "library_type": "movies"},
|
||||
]}),
|
||||
"/items": _paged_items([]),
|
||||
}
|
||||
client = _client(routes, sync_libraries=["lib-2"])
|
||||
assert [lib.id for lib in client.get_librarys(hidden=True)] == ["lib-2"]
|
||||
assert [lib.id for lib in client.get_librarys(hidden=False)] == ["lib-1", "lib-2"]
|
||||
|
||||
|
||||
def test_get_librarys_returns_none_when_unreachable():
|
||||
"""连接失败返回 None,与"媒体库为空"区分开。"""
|
||||
assert _client({"/libraries": None}).get_librarys() is None
|
||||
|
||||
|
||||
def test_get_medias_count_maps_statistics_fields():
|
||||
"""统计接口字段映射到 MoviePilot 的统计模型。"""
|
||||
routes = {"/statistics": Result(True, {"movie_count": 3030, "series_count": 1501,
|
||||
"episode_count": 69597, "item_count": 74128})}
|
||||
statistic = _client(routes).get_medias_count()
|
||||
assert (statistic.movie_count, statistic.tv_count, statistic.episode_count) == (3030, 1501, 69597)
|
||||
|
||||
|
||||
def test_get_items_count_reads_total_not_page_length():
|
||||
"""条目总数取分页返回的 total,不受页大小影响。"""
|
||||
rows = [_item_row(i) for i in range(356)]
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
assert client.get_items_count("lib-1") == 356
|
||||
|
||||
|
||||
# ── 存在性判断 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_movies_filters_by_title_year_and_identity():
|
||||
"""电影匹配要求标题全等、年份一致且媒体身份不冲突。"""
|
||||
rows = [
|
||||
_item_row(1, title="沙丘", year=2021, tmdb_id=438631),
|
||||
_item_row(2, title="沙丘", year=2024, tmdb_id=693134),
|
||||
_item_row(3, title="沙丘前传", year=2021, tmdb_id=111),
|
||||
]
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
assert [m.item_id for m in client.get_movies(title="沙丘")] == ["id-1", "id-2"]
|
||||
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
assert [m.item_id for m in client.get_movies(title="沙丘", year="2021")] == ["id-1"]
|
||||
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
matched = client.get_movies(title="沙丘", media_source=MediaSource.TMDB, media_id="693134")
|
||||
assert [m.item_id for m in matched] == ["id-2"]
|
||||
|
||||
|
||||
def test_get_movies_requests_movie_kind_only():
|
||||
"""存在性查询按类型收窄,避免剧集与分集混进电影结果。"""
|
||||
client = _client({"/items": _paged_items([])})
|
||||
client.get_movies(title="沙丘")
|
||||
assert client._api.calls[0]["params"]["kinds"] == "Movie"
|
||||
assert client._api.calls[0]["params"]["keyword"] == "沙丘"
|
||||
|
||||
|
||||
def test_get_tv_episodes_by_item_id_returns_season_map():
|
||||
"""按条目 ID 查询直接返回季集映射。"""
|
||||
routes = {
|
||||
"/items/series-1": Result(True, _item_row(1, kind="Series", title="剧A", tmdb_id=99)),
|
||||
"/items/series-1/episodes": Result(True, {"seasons": {"1": [1, 2, 3], "2": [1]}}),
|
||||
}
|
||||
item_id, seasons = _client(routes).get_tv_episodes(item_id="series-1")
|
||||
assert item_id == "series-1"
|
||||
assert seasons == {1: [1, 2, 3], 2: [1]}
|
||||
|
||||
|
||||
def test_get_tv_episodes_filters_requested_season():
|
||||
"""指定季号时只返回该季。"""
|
||||
routes = {
|
||||
"/items/series-1": Result(True, _item_row(1, kind="Series")),
|
||||
"/items/series-1/episodes": Result(True, {"seasons": {"1": [1, 2], "2": [1]}}),
|
||||
}
|
||||
_, seasons = _client(routes).get_tv_episodes(item_id="series-1", season=2)
|
||||
assert seasons == {2: [1]}
|
||||
|
||||
|
||||
def test_get_tv_episodes_falls_back_to_title_when_cached_id_is_stale():
|
||||
"""缓存的条目 ID 失效时退回按标题重新定位,不误判整部剧缺失。"""
|
||||
routes = {
|
||||
"/items/stale-id": Result(False, None, "not found", 404),
|
||||
"/items": _paged_items([_item_row(7, kind="Series", title="剧A", year=2020)]),
|
||||
"/items/id-7/episodes": Result(True, {"seasons": {"1": [1, 2]}}),
|
||||
}
|
||||
item_id, seasons = _client(routes).get_tv_episodes(item_id="stale-id", title="剧A", year="2020")
|
||||
assert item_id == "id-7"
|
||||
assert seasons == {1: [1, 2]}
|
||||
|
||||
|
||||
def test_get_tv_episodes_returns_empty_when_series_absent():
|
||||
"""剧集不在库中返回空季集:与 Emby 一致用 (None, {}) 表示"查得到但没有"。"""
|
||||
routes = {"/items": _paged_items([])}
|
||||
assert _client(routes).get_tv_episodes(title="不存在的剧") == (None, {})
|
||||
|
||||
|
||||
def test_get_tv_episodes_returns_none_when_unreachable():
|
||||
"""服务不可达时返回 None,避免被当成"这部剧一集都没有"。"""
|
||||
routes = {"/items": lambda params, data: None}
|
||||
assert _client(routes).get_tv_episodes(title="剧A") == (None, None)
|
||||
|
||||
|
||||
def test_get_season_episode_ids_maps_episode_number_to_item_id():
|
||||
"""季集条目 ID 映射先定位季,再遍历该季的分集。"""
|
||||
|
||||
def items(params, _data):
|
||||
if params.get("kinds") == "Season":
|
||||
return Result(True, {"items": [_item_row(1, kind="Season", season=1),
|
||||
_item_row(2, kind="Season", season=2)], "total": 2})
|
||||
if params.get("parent_id") == "id-2":
|
||||
return Result(True, {"items": [_item_row(10, kind="Episode", season=2, episode=1),
|
||||
_item_row(11, kind="Episode", season=2, episode=2)],
|
||||
"total": 2})
|
||||
return Result(True, {"items": [], "total": 0})
|
||||
|
||||
assert _client({"/items": items}).get_season_episode_ids("series-1", 2) == {
|
||||
1: "id-10", 2: "id-11",
|
||||
}
|
||||
|
||||
|
||||
def test_get_season_episode_ids_returns_empty_for_missing_season():
|
||||
"""季不存在时返回空映射。"""
|
||||
client = _client({"/items": _paged_items([_item_row(1, kind="Season", season=1)])})
|
||||
assert client.get_season_episode_ids("series-1", 9) == {}
|
||||
|
||||
|
||||
# ── 条目转换 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_iteminfo_extracts_identity_and_path_from_sources():
|
||||
"""条目详情带媒体源时取文件路径,身份按 ProviderIds 优先级解析。"""
|
||||
row = _item_row(1, tmdb_id=0, metadata_info={"original_title": "Dune",
|
||||
"external_ids": {"imdb_id": "tt1160419"}},
|
||||
sources=[{"id": "s1", "path": "/mnt/movies/Dune/Dune.mkv"}])
|
||||
client = _client({"/items/id-1": Result(True, row)})
|
||||
item = client.get_iteminfo("id-1")
|
||||
assert item.media_source == MediaSource.IMDb
|
||||
assert item.media_id == "tt1160419"
|
||||
assert item.original_title == "Dune"
|
||||
assert item.path == "/mnt/movies/Dune/Dune.mkv"
|
||||
|
||||
|
||||
def test_iteminfo_prefers_tmdb_over_other_providers():
|
||||
"""同时有 TMDB 与 IMDb 时按统一优先级取 TMDB。"""
|
||||
row = _item_row(1, tmdb_id=438631, metadata_info={"external_ids": {"imdb_id": "tt1160419"}})
|
||||
item = _client({"/items/id-1": Result(True, row)}).get_iteminfo("id-1")
|
||||
assert (item.media_source, item.media_id) == (MediaSource.TMDB, "438631")
|
||||
|
||||
|
||||
def test_iteminfo_returns_none_for_missing_item():
|
||||
"""条目不存在返回 None。"""
|
||||
assert _client({}).get_iteminfo("nope") is None
|
||||
|
||||
|
||||
# ── 展示与图片 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_resume_builds_episode_subtitle_and_percent():
|
||||
"""继续观看的分集用剧名做标题,进度按播放位置换算。"""
|
||||
row = _item_row(1, kind="Episode", season=2, episode=5, title="第五集",
|
||||
series_id="series-1", series_name="剧A",
|
||||
duration_ticks=1000, user_data={"position_ticks": 250})
|
||||
client = _client({"/items": _paged_items([row])})
|
||||
played = client.get_resume(num=5)[0]
|
||||
assert played.title == "剧A"
|
||||
assert played.subtitle == "S2:5 - 第五集"
|
||||
assert played.type == MediaType.TV.value
|
||||
assert played.percent == 25.0
|
||||
# 分集海报取所属剧集,避免每集一张缩略图
|
||||
assert "/items/series-1/image/primary" in played.image
|
||||
|
||||
|
||||
def test_get_latest_backdrops_fills_up_to_requested_count():
|
||||
"""没有背景图的条目不占名额,取满请求数量为止。"""
|
||||
rows = [_item_row(i, has_backdrop=(i % 3 == 0)) for i in range(60)]
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
assert len(client.get_latest_backdrops(num=10)) == 10
|
||||
|
||||
|
||||
def test_get_latest_backdrops_uses_play_host_when_remote():
|
||||
"""外网场景改用播放地址拼图片链接。"""
|
||||
rows = [_item_row(0, has_backdrop=True)]
|
||||
client = _client({"/items": _paged_items(rows)}, play_host="https://mv.example.com")
|
||||
assert client.get_latest_backdrops(num=1, remote=True)[0].startswith("https://mv.example.com")
|
||||
assert client.get_latest_backdrops(num=1, remote=False)[0].startswith("http://mv.local")
|
||||
|
||||
|
||||
# ── 入库刷新 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_refresh_library_by_items_scans_only_matched_libraries():
|
||||
"""入库路径命中哪个媒体库就只扫哪个,同一媒体库不重复排队。"""
|
||||
routes = {
|
||||
"/libraries": Result(True, {"items": [
|
||||
{"id": "lib-1", "name": "电影", "root_paths": ["/mnt/movies"]},
|
||||
{"id": "lib-2", "name": "剧集", "root_paths": ["/mnt/tv"]},
|
||||
]}),
|
||||
"/libraries/lib-1/scan-task": Result(True, {}),
|
||||
"/libraries/lib-2/scan-task": Result(True, {}),
|
||||
}
|
||||
client = _client(routes)
|
||||
assert client.refresh_library_by_items([
|
||||
RefreshMediaItem(title="A", target_path=Path("/mnt/movies/A (2020)")),
|
||||
RefreshMediaItem(title="B", target_path=Path("/mnt/movies/B (2021)")),
|
||||
]) is True
|
||||
scanned = [call["api"] for call in client._api.calls if call["api"].endswith("scan-task")]
|
||||
assert scanned == ["/libraries/lib-1/scan-task"]
|
||||
|
||||
|
||||
def test_refresh_library_by_items_falls_back_to_full_scan_when_unmatched():
|
||||
"""路径落在所有媒体库之外时退回全库扫描,避免新片一直不入库。"""
|
||||
routes = {
|
||||
"/libraries": Result(True, {"items": [{"id": "lib-1", "root_paths": ["/mnt/movies"]}]}),
|
||||
"/libraries/lib-1/scan-task": Result(True, {}),
|
||||
}
|
||||
client = _client(routes)
|
||||
assert client.refresh_library_by_items([
|
||||
RefreshMediaItem(title="C", target_path=Path("/data/other/C")),
|
||||
]) is True
|
||||
assert [call["api"] for call in client._api.calls if "scan-task" in call["api"]] == [
|
||||
"/libraries/lib-1/scan-task"
|
||||
]
|
||||
|
||||
|
||||
def test_refresh_library_by_items_returns_none_when_unreachable():
|
||||
"""媒体库列表拿不到时返回 None,交由上层判定为服务不可用。"""
|
||||
client = _client({"/libraries": None})
|
||||
assert client.refresh_library_by_items([RefreshMediaItem(title="A", target_path=Path("/x"))]) is None
|
||||
|
||||
|
||||
def test_refresh_root_library_queues_every_library():
|
||||
"""全库刷新对每个媒体库各排一次后台扫描。"""
|
||||
routes = {
|
||||
"/libraries": Result(True, {"items": [{"id": "lib-1"}, {"id": "lib-2"}]}),
|
||||
"/libraries/lib-1/scan-task": Result(True, {}),
|
||||
"/libraries/lib-2/scan-task": Result(True, {}),
|
||||
}
|
||||
client = _client(routes)
|
||||
assert client.refresh_root_library() is True
|
||||
assert [call["api"] for call in client._api.calls if "scan-task" in call["api"]] == [
|
||||
"/libraries/lib-1/scan-task", "/libraries/lib-2/scan-task",
|
||||
]
|
||||
assert all(call["method"] == "post" for call in client._api.calls if "scan-task" in call["api"])
|
||||
|
||||
|
||||
# ── 连接与认证 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reconnect_marks_inactive_when_credentials_rejected():
|
||||
"""凭据被拒时标记为失活,交给定时重连重试。"""
|
||||
client = _client({"/libraries": Result(False, None, "unauthorized", 401)})
|
||||
assert client.reconnect() is False
|
||||
assert client.is_inactive() is True
|
||||
assert client.is_authenticated() is False
|
||||
|
||||
|
||||
def test_unconfigured_client_never_reports_inactive():
|
||||
"""配置不完整的实例不参与重连,避免定时任务空转。"""
|
||||
client = _client({})
|
||||
client._apikey = None
|
||||
assert client.is_configured() is False
|
||||
assert client.is_inactive() is False
|
||||
assert client.reconnect() is False
|
||||
|
||||
|
||||
def test_authenticate_posts_to_user_auth_and_returns_token():
|
||||
"""用户认证走 MediaVault 账号体系,返回访问令牌。"""
|
||||
routes = {"/login": Result(True, {"access_token": "jwt-token", "token": "legacy"})}
|
||||
client = _client(routes)
|
||||
assert client.authenticate("someone", "secret") == "jwt-token"
|
||||
call = client._api.calls[0]
|
||||
assert (call["base_path"], call["method"]) == ("/api/v1/user-auth", "post")
|
||||
assert call["data"] == {"username": "someone", "password": "secret"}
|
||||
|
||||
|
||||
def test_authenticate_returns_none_on_rejection():
|
||||
"""认证失败返回 None,不把错误响应当成令牌。"""
|
||||
assert _client({"/login": Result(False, None, "bad credentials", 401)}).authenticate("a", "b") is None
|
||||
assert _client({}).authenticate("", "") is None
|
||||
|
||||
|
||||
def test_disconnect_closes_session():
|
||||
"""断开时释放底层会话。"""
|
||||
client = _client({})
|
||||
client.disconnect()
|
||||
assert client._api.closed is True
|
||||
assert client.is_authenticated() is False
|
||||
@@ -583,7 +583,7 @@ from app.runtime.extensions.module.adapter import (
|
||||
|
||||
registry = build_host_module_registry()
|
||||
specs = registry.list_specs()
|
||||
assert len(specs) == 40
|
||||
assert len(specs) == 41
|
||||
|
||||
adapter = HostModuleAdapter()
|
||||
lifecycle_events = []
|
||||
@@ -630,7 +630,7 @@ from app.schemas.types import EventType
|
||||
|
||||
registry = build_host_module_registry()
|
||||
specs = registry.list_specs()
|
||||
assert len(specs) == 40
|
||||
assert len(specs) == 41
|
||||
spec_by_id = {spec.id: spec for spec in specs}
|
||||
|
||||
events = {spec.id: [] for spec in specs}
|
||||
@@ -797,7 +797,7 @@ from app.runtime.extensions.module.adapter import (
|
||||
|
||||
registry = build_host_module_registry()
|
||||
specs = registry.list_specs()
|
||||
assert len(specs) == 40
|
||||
assert len(specs) == 41
|
||||
configured_specs = tuple(
|
||||
spec for spec in specs
|
||||
if spec.activation is ActivationPolicy.WHEN_CONFIGURED
|
||||
@@ -893,12 +893,12 @@ from app.application.module import configure_module_runtime
|
||||
configure_module_runtime(lambda: ModuleManager())
|
||||
|
||||
manager = ModuleManager()
|
||||
assert len(manager.list_specs()) == 40
|
||||
assert len(manager.list_specs()) == 41
|
||||
assert manager.get_specs() == manager.list_specs()
|
||||
|
||||
from app.api.endpoints.system import modulelist
|
||||
response = modulelist(None)
|
||||
assert len(response.data["modules"]) == 40
|
||||
assert len(response.data["modules"]) == 41
|
||||
|
||||
heavy_prefixes = (
|
||||
"lark_oapi",
|
||||
@@ -1007,7 +1007,7 @@ from app.runtime.extensions.module.manager import ModuleManager
|
||||
|
||||
manager = ModuleManager()
|
||||
modules = manager.get_modules()
|
||||
assert len(modules) == len(manager.list_specs()) == 40
|
||||
assert len(modules) == len(manager.list_specs()) == 41
|
||||
for spec in manager.list_specs():
|
||||
implementation = modules[spec.id]
|
||||
assert implementation.get_name() == spec.metadata["name"]
|
||||
|
||||
Reference in New Issue
Block a user