Merge pull request #6596 from thsrite/feat/mediavault-mediaserver

This commit is contained in:
jxxghp
2026-09-07 14:58:06 +08:00
committed by GitHub
22 changed files with 1546 additions and 96 deletions
+1 -1
View File
@@ -413,7 +413,7 @@ class MediaServerOperationTool(_ServiceOperationTool):
name: str = "mediaserver_operation" name: str = "mediaserver_operation"
description: str = ( description: str = (
"Operate a configured Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia, " "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 " "including providers, effects, required fields, types, defaults, enums, nested "
"item fields, and cross-field constraints." "item fields, and cross-field constraints."
) )
+3
View File
@@ -34,6 +34,9 @@
"JellyfinModule": { "JellyfinModule": {
"name": "Jellyfin" "name": "Jellyfin"
}, },
"MediaVaultModule": {
"name": "MediaVault"
},
"PlexModule": { "PlexModule": {
"name": "Plex" "name": "Plex"
}, },
+3
View File
@@ -34,6 +34,9 @@
"JellyfinModule": { "JellyfinModule": {
"name": "Jellyfin" "name": "Jellyfin"
}, },
"MediaVaultModule": {
"name": "MediaVault"
},
"PlexModule": { "PlexModule": {
"name": "Plex" "name": "Plex"
}, },
+3
View File
@@ -34,6 +34,9 @@
"JellyfinModule": { "JellyfinModule": {
"name": "Jellyfin" "name": "Jellyfin"
}, },
"MediaVaultModule": {
"name": "MediaVault"
},
"PlexModule": { "PlexModule": {
"name": "Plex" "name": "Plex"
}, },
+1 -1
View File
@@ -50,7 +50,7 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
pass pass
@abstractmethod @abstractmethod
def init_setting(self) -> Tuple[str, Union[str, bool]]: def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
""" """
模块开关设置,返回开关名和开关值,开关值为True时代表有值即打开,不实现该方法或返回None代表不使用开关 模块开关设置,返回开关名和开关值,开关值为True时代表有值即打开,不实现该方法或返回None代表不使用开关
部分模块支持同时开启多个,此时设置项以,分隔,开关值使用in判断 部分模块支持同时开启多个,此时设置项以,分隔,开关值使用in判断
+29
View File
@@ -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"]
+124
View File
@@ -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]
+22
View File
@@ -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"
+589
View File
@@ -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)
+231
View File
@@ -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 []
+1
View File
@@ -67,6 +67,7 @@ BASELINE_ASSESSED_MODULES = frozenset(
"jellyfin", "jellyfin",
"listenbrainz", "listenbrainz",
"lrclib", "lrclib",
"mediavault",
"musicbrainz", "musicbrainz",
"musixmatch", "musixmatch",
"navidrome", "navidrome",
+2 -1
View File
@@ -1,4 +1,5 @@
import inspect import inspect
from typing import Any
from app.runtime.events import eventmanager, Event from app.runtime.events import eventmanager, Event
from app.runtime.log import logger from app.runtime.log import logger
@@ -17,7 +18,7 @@ class ConfigReloadMixin:
# 统一生命周期管理器可以继承此 Mixin 的重载方法,但由外部唯一负责事件绑定。 # 统一生命周期管理器可以继承此 Mixin 的重载方法,但由外部唯一负责事件绑定。
CONFIG_RELOAD_MANAGED_EXTERNALLY: bool = False CONFIG_RELOAD_MANAGED_EXTERNALLY: bool = False
def __init_subclass__(cls, **kwargs): def __init_subclass__(cls, **kwargs: Any) -> None:
"""为声明了 CONFIG_WATCH 的子类生成配置变更处理器。""" """为声明了 CONFIG_WATCH 的子类生成配置变更处理器。"""
super().__init_subclass__(**kwargs) super().__init_subclass__(**kwargs)
+2
View File
@@ -594,6 +594,8 @@ class MediaServerType(Enum):
Ugreen = "Ugreen" Ugreen = "Ugreen"
# Navidrome 音乐服务器 # Navidrome 音乐服务器
Navidrome = "Navidrome" Navidrome = "Navidrome"
# MediaVault 自建媒体库
MediaVault = "MediaVault"
# 识别器类型 # 识别器类型
+2 -2
View File
@@ -754,8 +754,8 @@ flowchart LR
| 指标 | 当前值 | | 指标 | 当前值 |
|---|---:| |---|---:|
| Python 模块 | 976 | | Python 模块 | 980 |
| 内部导入边 | 8,270 | | 内部导入边 | 8,302 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 | | Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 53(债务已清零,53 条精确 containment | | Direct egress | 53(债务已清零,53 条精确 containment |
+2 -2
View File
@@ -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 移植包环 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
@@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`61 个文件超过 1,000 行,11 个超过 2,000 行 | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`61 个文件超过 1,000 行,11 个超过 2,000 行 |
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | | 长方法 | 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` | | Ruff 历史诊断 | 538 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率固定基线 | Application 80.00%Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | | 覆盖率固定基线 | Application 80.00%Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
+1 -1
View File
@@ -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` | | `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` | | `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` | | `database_operation` | MoviePilot 配置数据库表清单、实时 schema、只读 SQL 和明确授权写入 | `skills/database-operation/SKILL.md``skills/database-operation/scripts/mp-db.py``ACTIONS` |
这四个工具都要求管理员级 MCP 集成身份;`tools/list` 的可见性不等于绕过业务权限或写操作确认。下载器和媒体服务器工具会在一次调用内自动选择默认/唯一实例;实例不明确时,错误结果会列出可复用的精确实例名。数据库工具不接受任意连接串或凭据,脚本从 MoviePilot 运行时配置读取数据库连接。 这四个工具都要求管理员级 MCP 集成身份;`tools/list` 的可见性不等于绕过业务权限或写操作确认。下载器和媒体服务器工具会在一次调用内自动选择默认/唯一实例;实例不明确时,错误结果会列出可复用的精确实例名。数据库工具不接受任意连接串或凭据,脚本从 MoviePilot 运行时配置读取数据库连接。
+15 -15
View File
@@ -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 | | `server.users.count` | Read provider user count.; no arguments |
### `activity.backdrops` ### `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. - `limit` (integer; default `50`): Number of items to return, from 1 to 200.
- `remote` (boolean; default `False`): Return provider URLs that are remotely accessible. - `remote` (boolean; default `False`): Return provider URLs that are remotely accessible.
### `activity.latest` ### `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. - `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. - `username` (string): Read for this username; supported by Emby, Jellyfin, and ZSpace.
### `activity.resume` ### `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. - `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. - `username` (string): Read for this username; supported by Emby, Jellyfin, and ZSpace.
### `capabilities.list` ### `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. - `action_name` (string): Optional exact action name used to return one capability contract.
### `instances.list` ### `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`: `{}` - `arguments`: `{}`
### `items.count` ### `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. - `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. - Rule: parent is required except for Navidrome, which defaults to music.
### `items.detail` ### `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. - `item_id*` (string): Provider-native item ID returned by the selected media server.
### `items.list` ### `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. - `parent` (string|integer): Library or parent item ID; Navidrome may omit it and use music.
- `offset` (integer; default `0`): Zero-based list offset. - `offset` (integer; default `0`): Zero-based list offset.
- `limit` (integer; default `50`): Number of items to return, from 1 to 200. - `limit` (integer; default `50`): Number of items to return, from 1 to 200.
- Rule: parent is required except for Navidrome, which ignores it. - Rule: parent is required except for Navidrome, which ignores it.
### `items.movies.search` ### `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. - `title*` (string): Movie title.
- `year` (string|integer): Optional release year. - `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. - Rule: Provide at least one of title, artist, and album.
### `items.season_episodes` ### `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. - `item_id` (string): Provider-native item ID returned by the selected media server.
- `title` (string): Series title; provide it or item_id. - `title` (string): Series title; provide it or item_id.
- `year` (string|integer): Optional premiere year. - `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. - Rule: Provide at least one of item_id and title.
### `libraries.list` ### `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. - `hidden` (boolean; default `False`): Return only libraries configured for synchronization.
- `username` (string): Read libraries visible to this username; supported by Emby, Jellyfin, and ZSpace. - `username` (string): Read libraries visible to this username; supported by Emby, Jellyfin, and ZSpace.
### `library.scan` ### `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. - `scan_mode` (string|integer): UGREEN-native scan mode; omit it for every other provider.
### `metadata.refresh` ### `metadata.refresh`
@@ -215,11 +215,11 @@ Read active playback sessions. Effect: `safe_read`. Providers: `emby, jellyfin,
- `arguments`: `{}` - `arguments`: `{}`
### `playback.url` ### `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. - `item_id*` (string): Provider-native item ID returned by the selected media server.
### `server.statistics` ### `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`: `{}` - `arguments`: `{}`
### `server.user.library_folders` ### `server.user.library_folders`
@@ -227,7 +227,7 @@ Read the current user's visible library folders. Effect: `safe_read`. Providers:
- `arguments`: `{}` - `arguments`: `{}`
### `server.users.count` ### `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`: `{}` - `arguments`: `{}`
## Safety And Verification ## Safety And Verification
@@ -25,6 +25,7 @@ ALL_PROVIDERS = (
"ugreen", "ugreen",
"trimemedia", "trimemedia",
"navidrome", "navidrome",
"mediavault",
) )
PROVIDER_CLASSES = { PROVIDER_CLASSES = {
"emby": "app.modules.emby.emby:Emby", "emby": "app.modules.emby.emby:Emby",
@@ -34,6 +35,7 @@ PROVIDER_CLASSES = {
"ugreen": "app.modules.ugreen.ugreen:Ugreen", "ugreen": "app.modules.ugreen.ugreen:Ugreen",
"trimemedia": "app.modules.trimemedia.trimemedia:TrimeMedia", "trimemedia": "app.modules.trimemedia.trimemedia:TrimeMedia",
"navidrome": "app.modules.navidrome.navidrome:Navidrome", "navidrome": "app.modules.navidrome.navidrome:Navidrome",
"mediavault": "app.modules.mediavault.mediavault:MediaVault",
} }
_UNSET = object() _UNSET = object()
@@ -122,7 +124,7 @@ ACTIONS: dict[str, ActionSpec] = {
"server.users.count": ActionSpec( "server.users.count": ActionSpec(
"Read provider user count.", "Read provider user count.",
"safe_read", "safe_read",
("emby", "jellyfin", "zspace", "ugreen", "trimemedia", "navidrome"), ("emby", "jellyfin", "zspace", "ugreen", "trimemedia", "navidrome", "mediavault"),
), ),
"server.user.library_folders": ActionSpec( "server.user.library_folders": ActionSpec(
"Read the current user's visible library folders.", "Read the current user's visible library folders.",
@@ -157,7 +159,7 @@ ACTIONS: dict[str, ActionSpec] = {
"items.movies.search": ActionSpec( "items.movies.search": ActionSpec(
"Search provider-native movie items by title and optional year.", "Search provider-native movie items by title and optional year.",
"safe_read", "safe_read",
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"), ("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia", "mediavault"),
( (
ArgumentSpec("title", "string", "Movie title.", required=True), ArgumentSpec("title", "string", "Movie title.", required=True),
ArgumentSpec("year", "string|integer", "Optional release year."), ArgumentSpec("year", "string|integer", "Optional release year."),
@@ -177,7 +179,7 @@ ACTIONS: dict[str, ActionSpec] = {
"items.season_episodes": ActionSpec( "items.season_episodes": ActionSpec(
"Read native episode coverage for one series and optional season.", "Read native episode coverage for one series and optional season.",
"safe_read", "safe_read",
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"), ("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia", "mediavault"),
( (
ITEM_ID, ITEM_ID,
ArgumentSpec("title", "string", "Series title; provide it or item_id."), ArgumentSpec("title", "string", "Series title; provide it or item_id."),
@@ -199,7 +201,7 @@ ACTIONS: dict[str, ActionSpec] = {
"activity.backdrops": ActionSpec( "activity.backdrops": ActionSpec(
"Read recent provider backdrop images.", "Read recent provider backdrop images.",
"safe_read", "safe_read",
("ugreen", "trimemedia"), ("ugreen", "trimemedia", "mediavault"),
( (
LIMIT, LIMIT,
ArgumentSpec("remote", "boolean", "Return provider URLs that are remotely accessible.", default=False), ArgumentSpec("remote", "boolean", "Return provider URLs that are remotely accessible.", default=False),
+39 -3
View File
@@ -1074,8 +1074,8 @@
"runtime_only": true "runtime_only": true
} }
}, },
"edge_count": 8270, "edge_count": 8302,
"edge_sha256": "8f25287ff625b0327fa3ee567ff99ac90818376d0fdc34e91834eba90e133d7c", "edge_sha256": "49000047cece20aa2bd7f1d06916072d06b832f0f2d929693f995206f9a29b84",
"edges": [ "edges": [
"app -> app.foundation", "app -> app.foundation",
"app -> app.foundation.environment", "app -> app.foundation.environment",
@@ -6898,6 +6898,38 @@
"app.modules.lrclib -> app.runtime.settings", "app.modules.lrclib -> app.runtime.settings",
"app.modules.lrclib -> app.schemas", "app.modules.lrclib -> app.schemas",
"app.modules.lrclib -> app.schemas.types", "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",
"app.modules.musicbrainz -> app.adapters.network", "app.modules.musicbrainz -> app.adapters.network",
"app.modules.musicbrainz -> app.adapters.network.http", "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",
"app.workflow.actions.transfer_file -> app.workflow.actions" "app.workflow.actions.transfer_file -> app.workflow.actions"
], ],
"module_count": 976, "module_count": 980,
"modules": [ "modules": [
"app", "app",
"app.adapters", "app.adapters",
@@ -10009,6 +10041,10 @@
"app.modules.jellyfin.jellyfin", "app.modules.jellyfin.jellyfin",
"app.modules.listenbrainz", "app.modules.listenbrainz",
"app.modules.lrclib", "app.modules.lrclib",
"app.modules.mediavault",
"app.modules.mediavault.api",
"app.modules.mediavault.mediavault",
"app.modules.mediavault.module",
"app.modules.musicbrainz", "app.modules.musicbrainz",
"app.modules.musicbrainz.cache", "app.modules.musicbrainz.cache",
"app.modules.musixmatch", "app.modules.musixmatch",
+21 -60
View File
@@ -1395,39 +1395,34 @@
"empty-body": 4, "empty-body": 4,
"misc": 3, "misc": 3,
"no-any-return": 1, "no-any-return": 1,
"no-untyped-call": 5, "no-untyped-call": 4,
"no-untyped-def": 7, "no-untyped-def": 7,
"union-attr": 4 "union-attr": 4
}, },
"app/modules/_base/downloader.py": { "app/modules/_base/downloader.py": {
"assignment": 1, "assignment": 1,
"attr-defined": 5, "attr-defined": 5
"no-untyped-call": 1
}, },
"app/modules/_base/mediaserver.py": { "app/modules/_base/mediaserver.py": {
"arg-type": 2, "arg-type": 2,
"assignment": 3, "assignment": 3,
"attr-defined": 5, "attr-defined": 5,
"no-any-return": 1, "no-any-return": 1,
"no-untyped-call": 1,
"no-untyped-def": 2, "no-untyped-def": 2,
"var-annotated": 1 "var-annotated": 1
}, },
"app/modules/_base/notification.py": { "app/modules/_base/notification.py": {
"arg-type": 1, "arg-type": 1,
"assignment": 1, "assignment": 1,
"no-untyped-call": 1,
"no-untyped-def": 3, "no-untyped-def": 3,
"type-arg": 5 "type-arg": 5
}, },
"app/modules/acoustid/__init__.py": { "app/modules/acoustid/__init__.py": {
"arg-type": 1, "arg-type": 1,
"list-item": 1, "list-item": 1
"no-untyped-call": 1
}, },
"app/modules/anilist/__init__.py": { "app/modules/anilist/__init__.py": {
"assignment": 4, "assignment": 4,
"no-untyped-call": 1,
"no-untyped-def": 5, "no-untyped-def": 5,
"return-value": 1, "return-value": 1,
"type-arg": 12 "type-arg": 12
@@ -1442,7 +1437,6 @@
"app/modules/bangumi/__init__.py": { "app/modules/bangumi/__init__.py": {
"arg-type": 2, "arg-type": 2,
"assignment": 4, "assignment": 4,
"no-untyped-call": 1,
"no-untyped-def": 5, "no-untyped-def": 5,
"return-value": 1, "return-value": 1,
"type-arg": 7 "type-arg": 7
@@ -1452,9 +1446,7 @@
}, },
"app/modules/dingtalk/__init__.py": { "app/modules/dingtalk/__init__.py": {
"arg-type": 2, "arg-type": 2,
"no-untyped-call": 1, "no-untyped-def": 1
"no-untyped-def": 1,
"override": 1
}, },
"app/modules/dingtalk/dingtalk.py": { "app/modules/dingtalk/dingtalk.py": {
"no-untyped-def": 1 "no-untyped-def": 1
@@ -1466,7 +1458,6 @@
"empty-body": 1, "empty-body": 1,
"misc": 1, "misc": 1,
"no-any-return": 1, "no-any-return": 1,
"no-untyped-call": 1,
"no-untyped-def": 1, "no-untyped-def": 1,
"truthy-function": 1, "truthy-function": 1,
"type-arg": 8 "type-arg": 8
@@ -1487,7 +1478,7 @@
"empty-body": 1, "empty-body": 1,
"misc": 2, "misc": 2,
"no-any-return": 1, "no-any-return": 1,
"no-untyped-call": 4, "no-untyped-call": 3,
"no-untyped-def": 7, "no-untyped-def": 7,
"return-value": 2, "return-value": 2,
"type-arg": 13, "type-arg": 13,
@@ -1512,7 +1503,6 @@
"arg-type": 3, "arg-type": 3,
"assignment": 13, "assignment": 13,
"empty-body": 1, "empty-body": 1,
"no-untyped-call": 1,
"no-untyped-def": 1, "no-untyped-def": 1,
"type-arg": 1 "type-arg": 1
}, },
@@ -1530,7 +1520,6 @@
"app/modules/fanart/__init__.py": { "app/modules/fanart/__init__.py": {
"misc": 2, "misc": 2,
"no-any-return": 1, "no-any-return": 1,
"no-untyped-call": 1,
"no-untyped-def": 2, "no-untyped-def": 2,
"type-arg": 10, "type-arg": 10,
"var-annotated": 1 "var-annotated": 1
@@ -1539,7 +1528,6 @@
"arg-type": 4, "arg-type": 4,
"assignment": 6, "assignment": 6,
"attr-defined": 1, "attr-defined": 1,
"no-untyped-call": 1,
"no-untyped-def": 1, "no-untyped-def": 1,
"return-value": 1, "return-value": 1,
"type-arg": 5, "type-arg": 5,
@@ -1561,7 +1549,7 @@
"attr-defined": 1, "attr-defined": 1,
"empty-body": 1, "empty-body": 1,
"no-any-return": 2, "no-any-return": 2,
"no-untyped-call": 3, "no-untyped-call": 2,
"no-untyped-def": 5, "no-untyped-def": 5,
"type-arg": 3, "type-arg": 3,
"var-annotated": 3 "var-annotated": 3
@@ -1668,7 +1656,7 @@
"assignment": 4, "assignment": 4,
"empty-body": 1, "empty-body": 1,
"index": 1, "index": 1,
"no-untyped-call": 2, "no-untyped-call": 1,
"no-untyped-def": 3, "no-untyped-def": 3,
"operator": 2, "operator": 2,
"return": 1, "return": 1,
@@ -1678,9 +1666,7 @@
"app/modules/imdb/__init__.py": { "app/modules/imdb/__init__.py": {
"assignment": 10, "assignment": 10,
"no-any-return": 1, "no-any-return": 1,
"no-untyped-call": 1,
"no-untyped-def": 3, "no-untyped-def": 3,
"override": 1,
"type-arg": 5 "type-arg": 5
}, },
"app/modules/imdb/api.py": { "app/modules/imdb/api.py": {
@@ -1694,7 +1680,7 @@
"attr-defined": 1, "attr-defined": 1,
"empty-body": 1, "empty-body": 1,
"no-any-return": 2, "no-any-return": 2,
"no-untyped-call": 4, "no-untyped-call": 3,
"no-untyped-def": 1, "no-untyped-def": 1,
"return-value": 5, "return-value": 5,
"type-arg": 19, "type-arg": 19,
@@ -1913,7 +1899,6 @@
"arg-type": 3, "arg-type": 3,
"assignment": 13, "assignment": 13,
"empty-body": 1, "empty-body": 1,
"no-untyped-call": 1,
"no-untyped-def": 1, "no-untyped-def": 1,
"type-arg": 1 "type-arg": 1
}, },
@@ -1932,22 +1917,17 @@
}, },
"app/modules/listenbrainz/__init__.py": { "app/modules/listenbrainz/__init__.py": {
"misc": 2, "misc": 2,
"no-any-return": 2, "no-any-return": 2
"no-untyped-call": 1,
"override": 1
}, },
"app/modules/lrclib/__init__.py": { "app/modules/lrclib/__init__.py": {
"misc": 1, "misc": 1
"no-untyped-call": 1,
"override": 1
}, },
"app/modules/musicbrainz/__init__.py": { "app/modules/musicbrainz/__init__.py": {
"arg-type": 1, "arg-type": 1,
"assignment": 5, "assignment": 5,
"misc": 4, "misc": 4,
"no-untyped-call": 4, "no-untyped-call": 3,
"no-untyped-def": 2, "no-untyped-def": 2,
"override": 1,
"type-arg": 2 "type-arg": 2
}, },
"app/modules/musicbrainz/cache.py": { "app/modules/musicbrainz/cache.py": {
@@ -1958,13 +1938,10 @@
"union-attr": 1 "union-attr": 1
}, },
"app/modules/musixmatch/__init__.py": { "app/modules/musixmatch/__init__.py": {
"no-any-return": 1, "no-any-return": 1
"no-untyped-call": 1
}, },
"app/modules/navidrome/__init__.py": { "app/modules/navidrome/__init__.py": {
"arg-type": 9, "arg-type": 9,
"no-untyped-call": 1,
"override": 1,
"type-arg": 1, "type-arg": 1,
"union-attr": 1 "union-attr": 1
}, },
@@ -1976,7 +1953,7 @@
"arg-type": 7, "arg-type": 7,
"assignment": 16, "assignment": 16,
"empty-body": 1, "empty-body": 1,
"no-untyped-call": 2, "no-untyped-call": 1,
"no-untyped-def": 5, "no-untyped-def": 5,
"type-arg": 1 "type-arg": 1
}, },
@@ -1994,7 +1971,6 @@
}, },
"app/modules/postgresql/__init__.py": { "app/modules/postgresql/__init__.py": {
"empty-body": 1, "empty-body": 1,
"no-untyped-call": 1,
"no-untyped-def": 1 "no-untyped-def": 1
}, },
"app/modules/qbittorrent/__init__.py": { "app/modules/qbittorrent/__init__.py": {
@@ -2002,7 +1978,6 @@
"assignment": 16, "assignment": 16,
"empty-body": 1, "empty-body": 1,
"no-redef": 2, "no-redef": 2,
"no-untyped-call": 1,
"operator": 1, "operator": 1,
"str-bytes-safe": 2, "str-bytes-safe": 2,
"type-arg": 8, "type-arg": 8,
@@ -2029,7 +2004,6 @@
"attr-defined": 1, "attr-defined": 1,
"empty-body": 1, "empty-body": 1,
"no-any-return": 1, "no-any-return": 1,
"no-untyped-call": 1,
"no-untyped-def": 1, "no-untyped-def": 1,
"type-arg": 4, "type-arg": 4,
"var-annotated": 1 "var-annotated": 1
@@ -2042,7 +2016,7 @@
}, },
"app/modules/redis/__init__.py": { "app/modules/redis/__init__.py": {
"empty-body": 1, "empty-body": 1,
"no-untyped-call": 2, "no-untyped-call": 1,
"no-untyped-def": 1 "no-untyped-def": 1
}, },
"app/modules/rtorrent/__init__.py": { "app/modules/rtorrent/__init__.py": {
@@ -2050,7 +2024,6 @@
"assignment": 16, "assignment": 16,
"empty-body": 1, "empty-body": 1,
"no-redef": 1, "no-redef": 1,
"no-untyped-call": 1,
"no-untyped-def": 1, "no-untyped-def": 1,
"operator": 1, "operator": 1,
"str-bytes-safe": 2, "str-bytes-safe": 2,
@@ -2072,7 +2045,6 @@
"attr-defined": 1, "attr-defined": 1,
"empty-body": 1, "empty-body": 1,
"index": 1, "index": 1,
"no-untyped-call": 1,
"no-untyped-def": 1, "no-untyped-def": 1,
"type-arg": 7 "type-arg": 7
}, },
@@ -2089,7 +2061,6 @@
"app/modules/subtitle/__init__.py": { "app/modules/subtitle/__init__.py": {
"arg-type": 1, "arg-type": 1,
"empty-body": 1, "empty-body": 1,
"no-untyped-call": 1,
"no-untyped-def": 2, "no-untyped-def": 2,
"var-annotated": 1 "var-annotated": 1
}, },
@@ -2099,7 +2070,6 @@
"attr-defined": 1, "attr-defined": 1,
"empty-body": 1, "empty-body": 1,
"no-any-return": 1, "no-any-return": 1,
"no-untyped-call": 1,
"no-untyped-def": 2, "no-untyped-def": 2,
"type-arg": 4, "type-arg": 4,
"var-annotated": 1 "var-annotated": 1
@@ -2115,7 +2085,6 @@
"assignment": 9, "assignment": 9,
"attr-defined": 1, "attr-defined": 1,
"empty-body": 1, "empty-body": 1,
"no-untyped-call": 1,
"no-untyped-def": 1, "no-untyped-def": 1,
"type-arg": 11, "type-arg": 11,
"union-attr": 1 "union-attr": 1
@@ -2135,9 +2104,7 @@
"app/modules/theaudiodb/__init__.py": { "app/modules/theaudiodb/__init__.py": {
"assignment": 4, "assignment": 4,
"misc": 2, "misc": 2,
"no-untyped-call": 1, "no-untyped-def": 2
"no-untyped-def": 2,
"override": 1
}, },
"app/modules/themoviedb/__init__.py": { "app/modules/themoviedb/__init__.py": {
"arg-type": 5, "arg-type": 5,
@@ -2145,7 +2112,7 @@
"empty-body": 1, "empty-body": 1,
"index": 2, "index": 2,
"no-any-return": 4, "no-any-return": 4,
"no-untyped-call": 6, "no-untyped-call": 5,
"no-untyped-def": 3, "no-untyped-def": 3,
"return-value": 2, "return-value": 2,
"type-arg": 19, "type-arg": 19,
@@ -2291,7 +2258,7 @@
"app/modules/thetvdb/__init__.py": { "app/modules/thetvdb/__init__.py": {
"empty-body": 1, "empty-body": 1,
"no-any-return": 2, "no-any-return": 2,
"no-untyped-call": 2, "no-untyped-call": 1,
"no-untyped-def": 4, "no-untyped-def": 4,
"type-arg": 2 "type-arg": 2
}, },
@@ -2311,7 +2278,7 @@
"empty-body": 1, "empty-body": 1,
"no-any-return": 1, "no-any-return": 1,
"no-redef": 2, "no-redef": 2,
"no-untyped-call": 10, "no-untyped-call": 9,
"no-untyped-def": 7, "no-untyped-def": 7,
"str-bytes-safe": 1, "str-bytes-safe": 1,
"type-arg": 6 "type-arg": 6
@@ -2333,7 +2300,7 @@
"assignment": 1, "assignment": 1,
"empty-body": 1, "empty-body": 1,
"no-any-return": 3, "no-any-return": 3,
"no-untyped-call": 2, "no-untyped-call": 1,
"no-untyped-def": 7, "no-untyped-def": 7,
"return": 1, "return": 1,
"type-arg": 2 "type-arg": 2
@@ -2363,7 +2330,6 @@
"assignment": 1, "assignment": 1,
"empty-body": 1, "empty-body": 1,
"no-any-return": 3, "no-any-return": 3,
"no-untyped-call": 1,
"no-untyped-def": 7, "no-untyped-def": 7,
"type-arg": 2 "type-arg": 2
}, },
@@ -2383,7 +2349,6 @@
"assignment": 6, "assignment": 6,
"attr-defined": 1, "attr-defined": 1,
"empty-body": 1, "empty-body": 1,
"no-untyped-call": 1,
"no-untyped-def": 3, "no-untyped-def": 3,
"type-arg": 4, "type-arg": 4,
"union-attr": 1 "union-attr": 1
@@ -2398,7 +2363,6 @@
"arg-type": 1, "arg-type": 1,
"assignment": 1, "assignment": 1,
"empty-body": 1, "empty-body": 1,
"no-untyped-call": 1,
"no-untyped-def": 2, "no-untyped-def": 2,
"type-arg": 1, "type-arg": 1,
"union-attr": 1 "union-attr": 1
@@ -2409,7 +2373,7 @@
"attr-defined": 1, "attr-defined": 1,
"empty-body": 1, "empty-body": 1,
"no-any-return": 2, "no-any-return": 2,
"no-untyped-call": 3, "no-untyped-call": 2,
"no-untyped-def": 5, "no-untyped-def": 5,
"type-arg": 4, "type-arg": 4,
"union-attr": 4 "union-attr": 4
@@ -2435,7 +2399,6 @@
"assignment": 3, "assignment": 3,
"empty-body": 1, "empty-body": 1,
"no-any-return": 1, "no-any-return": 1,
"no-untyped-call": 1,
"no-untyped-def": 4, "no-untyped-def": 4,
"type-arg": 2, "type-arg": 2,
"union-attr": 1 "union-attr": 1
@@ -2453,7 +2416,6 @@
"arg-type": 3, "arg-type": 3,
"assignment": 12, "assignment": 12,
"empty-body": 1, "empty-body": 1,
"no-untyped-call": 1,
"no-untyped-def": 2, "no-untyped-def": 2,
"type-arg": 1 "type-arg": 1
}, },
@@ -2653,7 +2615,7 @@
"app/runtime/reload.py": { "app/runtime/reload.py": {
"misc": 1, "misc": 1,
"no-untyped-call": 5, "no-untyped-call": 5,
"no-untyped-def": 6 "no-untyped-def": 5
}, },
"app/runtime/scheduling.py": { "app/runtime/scheduling.py": {
"import-untyped": 1, "import-untyped": 1,
@@ -2668,7 +2630,6 @@
"app/runtime/state.py": { "app/runtime/state.py": {
"attr-defined": 2, "attr-defined": 2,
"no-any-return": 2, "no-any-return": 2,
"no-untyped-call": 1,
"no-untyped-def": 3, "no-untyped-def": 3,
"type-arg": 1 "type-arg": 1
}, },
+443
View File
@@ -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() registry = build_host_module_registry()
specs = registry.list_specs() specs = registry.list_specs()
assert len(specs) == 40 assert len(specs) == 41
adapter = HostModuleAdapter() adapter = HostModuleAdapter()
lifecycle_events = [] lifecycle_events = []
@@ -630,7 +630,7 @@ from app.schemas.types import EventType
registry = build_host_module_registry() registry = build_host_module_registry()
specs = registry.list_specs() specs = registry.list_specs()
assert len(specs) == 40 assert len(specs) == 41
spec_by_id = {spec.id: spec for spec in specs} spec_by_id = {spec.id: spec for spec in specs}
events = {spec.id: [] 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() registry = build_host_module_registry()
specs = registry.list_specs() specs = registry.list_specs()
assert len(specs) == 40 assert len(specs) == 41
configured_specs = tuple( configured_specs = tuple(
spec for spec in specs spec for spec in specs
if spec.activation is ActivationPolicy.WHEN_CONFIGURED if spec.activation is ActivationPolicy.WHEN_CONFIGURED
@@ -893,12 +893,12 @@ from app.application.module import configure_module_runtime
configure_module_runtime(lambda: ModuleManager()) configure_module_runtime(lambda: ModuleManager())
manager = ModuleManager() manager = ModuleManager()
assert len(manager.list_specs()) == 40 assert len(manager.list_specs()) == 41
assert manager.get_specs() == manager.list_specs() assert manager.get_specs() == manager.list_specs()
from app.api.endpoints.system import modulelist from app.api.endpoints.system import modulelist
response = modulelist(None) response = modulelist(None)
assert len(response.data["modules"]) == 40 assert len(response.data["modules"]) == 41
heavy_prefixes = ( heavy_prefixes = (
"lark_oapi", "lark_oapi",
@@ -1007,7 +1007,7 @@ from app.runtime.extensions.module.manager import ModuleManager
manager = ModuleManager() manager = ModuleManager()
modules = manager.get_modules() 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(): for spec in manager.list_specs():
implementation = modules[spec.id] implementation = modules[spec.id]
assert implementation.get_name() == spec.metadata["name"] assert implementation.get_name() == spec.metadata["name"]