mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 21:07:01 +08:00
feat(mediaserver): 内置媒体服务 MediaVault 更名为 Vyo
主项目更名为 Muvyo,内置媒体服务更名为 Vyo。模块目录、文件、类名、模块 ID、枚举、 名称描述、日志与文档统一改为 Vyo;配置类型值 mediavault 保持不变, 已保存的媒体服务器配置无需迁移。
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, "
|
||||
"Navidrome, or MediaVault server. The input schema contains one exact branch per action, "
|
||||
"Navidrome, or Vyo 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."
|
||||
)
|
||||
|
||||
@@ -57,9 +57,9 @@
|
||||
"name": "Jellyfin",
|
||||
"description": "Media-server adapter that synchronizes Jellyfin libraries and runs refresh or playback actions."
|
||||
},
|
||||
"MediaVaultModule": {
|
||||
"name": "MediaVault",
|
||||
"description": "Media-server adapter that connects to MediaVault and synchronizes its library."
|
||||
"VyoModule": {
|
||||
"name": "Vyo",
|
||||
"description": "Media-server adapter that connects to Vyo (the built-in media service of Muvyo) and synchronizes its library."
|
||||
},
|
||||
"LrclibModule": {
|
||||
"name": "LRCLIB",
|
||||
|
||||
@@ -57,9 +57,9 @@
|
||||
"name": "Jellyfin",
|
||||
"description": "媒体服务器适配器:同步 Jellyfin 媒体库并执行刷新、播放等操作。"
|
||||
},
|
||||
"MediaVaultModule": {
|
||||
"name": "MediaVault",
|
||||
"description": "媒体服务器适配器:连接 MediaVault 并同步媒体库信息。"
|
||||
"VyoModule": {
|
||||
"name": "Vyo",
|
||||
"description": "媒体服务器适配器:连接 Vyo(Muvyo 内置媒体服务)并同步媒体库信息。"
|
||||
},
|
||||
"LrclibModule": {
|
||||
"name": "LRCLIB",
|
||||
|
||||
@@ -57,9 +57,9 @@
|
||||
"name": "Jellyfin",
|
||||
"description": "媒體伺服器適配器:同步 Jellyfin 媒體庫並執行重新整理、播放等操作。"
|
||||
},
|
||||
"MediaVaultModule": {
|
||||
"name": "MediaVault",
|
||||
"description": "媒體伺服器適配器:連接 MediaVault 並同步媒體庫資訊。"
|
||||
"VyoModule": {
|
||||
"name": "Vyo",
|
||||
"description": "媒體伺服器適配器:連接 Vyo(Muvyo 內建媒體服務)並同步媒體庫資訊。"
|
||||
},
|
||||
"LrclibModule": {
|
||||
"name": "LRCLIB",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""MediaVault 宿主模块的惰性兼容入口。"""
|
||||
"""Vyo 宿主模块的惰性兼容入口。"""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
_EXPORTS = {
|
||||
"MediaVaultModule": ("app.modules.mediavault.module", "MediaVaultModule"),
|
||||
"VyoModule": ("app.modules.vyo.module", "VyoModule"),
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ def __getattr__(name: str) -> Any:
|
||||
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":
|
||||
if name == "VyoModule":
|
||||
value.__module__ = __name__
|
||||
globals()[name] = value
|
||||
return value
|
||||
@@ -26,4 +26,4 @@ def __dir__() -> list[str]:
|
||||
return sorted({*globals(), *_EXPORTS})
|
||||
|
||||
|
||||
__all__ = ["MediaVaultModule"]
|
||||
__all__ = ["VyoModule"]
|
||||
@@ -9,7 +9,7 @@ from app.runtime.settings import get_runtime_setting
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
"""MediaVault 统一响应包装。"""
|
||||
"""Vyo 统一响应包装。"""
|
||||
|
||||
success: bool
|
||||
data: Optional[Union[Dict[str, Any], List[Any], str, int, bool]] = None
|
||||
@@ -18,10 +18,10 @@ class Result:
|
||||
|
||||
|
||||
class Api:
|
||||
"""MediaVault 自建媒体库的原生 HTTP 接口。
|
||||
"""Vyo 媒体服务的原生 HTTP 接口。
|
||||
|
||||
统一走管理端口的 `/api/v1`,凭据为管理面板的长效 API Key;
|
||||
自建媒体库的接口都挂在 `/api/v1/media-library` 之下。
|
||||
Vyo 的接口都挂在 `/api/v1/media-library` 之下。
|
||||
"""
|
||||
|
||||
LIBRARY_PATH = "/api/v1/media-library"
|
||||
@@ -46,7 +46,7 @@ class Api:
|
||||
def image_url(self, item_id: str, image_type: str, host: Optional[str] = None) -> str:
|
||||
"""拼装图片直链;item_id 传媒体库 ID 时得到媒体库封面。
|
||||
|
||||
MediaVault 的图片是免鉴权资源,这里**不能**带上 API Key:该地址会随接口
|
||||
Vyo 的图片是免鉴权资源,这里**不能**带上 API Key:该地址会随接口
|
||||
响应交给浏览器加载,带 Key 等于把管理凭据下发到客户端。
|
||||
"""
|
||||
if not self.configured or not item_id:
|
||||
@@ -63,9 +63,9 @@ class Api:
|
||||
base_path: Optional[str] = None,
|
||||
suppress_log: bool = False,
|
||||
) -> Optional[Result]:
|
||||
"""请求 MediaVault 接口。
|
||||
"""请求 Vyo 接口。
|
||||
|
||||
:param api: 接口路径,默认相对自建媒体库前缀
|
||||
:param api: 接口路径,默认相对 Vyo 接口前缀
|
||||
:param base_path: 覆盖接口前缀(如站点级 `/api/v1`)
|
||||
:param suppress_log: 探测类调用可关闭错误日志
|
||||
:return: 网络层失败返回 None,业务失败返回 success=False 的 Result
|
||||
@@ -88,28 +88,28 @@ class Api:
|
||||
)
|
||||
except Exception as err:
|
||||
if not suppress_log:
|
||||
logger.error(f"请求 MediaVault 接口 {url} 异常:{err}")
|
||||
logger.error(f"请求 Vyo 接口 {url} 异常:{err}")
|
||||
return None
|
||||
if res is None:
|
||||
if not suppress_log:
|
||||
logger.error(f"请求 MediaVault 接口 {url} 无响应")
|
||||
logger.error(f"请求 Vyo 接口 {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}")
|
||||
logger.error(f"请求 Vyo 接口 {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}")
|
||||
logger.error(f"解析 Vyo 接口 {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}")
|
||||
logger.error(f"请求 Vyo 接口 {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)
|
||||
@@ -1,13 +1,13 @@
|
||||
schema_version = 1
|
||||
id = "MediaVaultModule"
|
||||
id = "VyoModule"
|
||||
kind = "host_module"
|
||||
entrypoint = "app.modules.mediavault:MediaVaultModule"
|
||||
entrypoint = "app.modules.vyo:VyoModule"
|
||||
depends_on = []
|
||||
|
||||
[metadata]
|
||||
name = "MediaVault"
|
||||
name = "Vyo"
|
||||
type = "mediaserver"
|
||||
subtype = "MediaVault"
|
||||
subtype = "Vyo"
|
||||
priority = 7
|
||||
|
||||
[activation]
|
||||
@@ -1,7 +1,7 @@
|
||||
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.modules.vyo.vyo import Vyo
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.dashboard import Statistic as _SchemaStatistic
|
||||
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
|
||||
@@ -11,10 +11,11 @@ from app.schemas.mediaserver import MediaServerSeasonInfo as _SchemaMediaServerS
|
||||
from app.schemas.types import MediaServerType, ModuleType
|
||||
|
||||
|
||||
class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""MediaVault 自建媒体库模块。"""
|
||||
class VyoModule(_MediaServerModuleBase[Vyo]):
|
||||
"""Vyo 媒体服务模块。"""
|
||||
|
||||
# 媒体库标识(ExistMediaInfo.server_type)
|
||||
# 配置类型值沿用 mediavault,兼容已保存的媒体服务器配置
|
||||
_server_type_value = "mediavault"
|
||||
|
||||
def init_module(self) -> None:
|
||||
@@ -22,15 +23,15 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
初始化模块
|
||||
"""
|
||||
super().init_service(
|
||||
service_name=MediaVault.__name__.lower(),
|
||||
service_type=lambda conf: MediaVault(
|
||||
service_name=Vyo.__name__.lower(),
|
||||
service_type=lambda conf: Vyo(
|
||||
**conf.config, sync_libraries=conf.sync_libraries
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "MediaVault"
|
||||
return "Vyo"
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
@@ -44,7 +45,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MediaServerType.MediaVault
|
||||
return MediaServerType.Vyo
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
@@ -57,7 +58,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""本模块不使用开关设置。"""
|
||||
return None
|
||||
|
||||
def _is_inactive(self, server: MediaVault) -> bool:
|
||||
def _is_inactive(self, server: Vyo) -> bool:
|
||||
"""未配置的实例不参与定时重连。"""
|
||||
return server.is_configured() and server.is_inactive()
|
||||
|
||||
@@ -67,9 +68,9 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
try:
|
||||
server.disconnect()
|
||||
except Exception as err:
|
||||
logger.error(f"停止 MediaVault 模块实例失败:{err}")
|
||||
logger.error(f"停止 Vyo 模块实例失败:{err}")
|
||||
|
||||
def _test_server(self, server: MediaVault, name: str) -> Optional[str]:
|
||||
def _test_server(self, server: Vyo, name: str) -> Optional[str]:
|
||||
"""用配置完整性与 API Key 探测结果判断连接状态。"""
|
||||
if not server.is_configured():
|
||||
return f"{self.get_name()}配置不完整:{name}"
|
||||
@@ -84,7 +85,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
媒体数量统计
|
||||
"""
|
||||
if server:
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
server_obj: Optional[Vyo] = self.get_instance(server)
|
||||
servers = [server_obj] if server_obj else []
|
||||
else:
|
||||
servers = list(self.get_instances().values())
|
||||
@@ -103,7 +104,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""
|
||||
媒体库列表
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
server_obj: Optional[Vyo] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_librarys(hidden=hidden)
|
||||
return None
|
||||
@@ -123,7 +124,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
:param start_index: 起始索引
|
||||
:param limit: 每次请求的最大项目数,None 或 -1 表示一次性获取所有数据
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
server_obj: Optional[Vyo] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_items(library_id, start_index, limit)
|
||||
return None
|
||||
@@ -134,7 +135,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""
|
||||
获取指定媒体库可同步的媒体条目总数
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
server_obj: Optional[Vyo] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_items_count(library_id)
|
||||
return None
|
||||
@@ -145,7 +146,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""
|
||||
媒体库项目详情
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
server_obj: Optional[Vyo] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_iteminfo(str(item_id))
|
||||
return None
|
||||
@@ -156,7 +157,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""
|
||||
获取剧集信息
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
server_obj: Optional[Vyo] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
_, seasoninfo = server_obj.get_tv_episodes(item_id=str(item_id))
|
||||
@@ -173,7 +174,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""
|
||||
获取指定季的集号到条目 ID 映射
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
server_obj: Optional[Vyo] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_season_episode_ids(str(item_id), season)
|
||||
@@ -184,7 +185,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""
|
||||
获取媒体服务器正在播放信息
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
server_obj: Optional[Vyo] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_resume(num=count)
|
||||
@@ -195,7 +196,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""
|
||||
获取媒体库播放地址
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
server_obj: Optional[Vyo] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_play_url(str(item_id))
|
||||
@@ -206,7 +207,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
server_obj: Optional[Vyo] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_latest(num=count)
|
||||
@@ -225,7 +226,7 @@ class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
:param count: 获取数量
|
||||
:param remote: True为外网链接,False为内网链接
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
server_obj: Optional[Vyo] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return []
|
||||
return server_obj.get_latest_backdrops(num=count, remote=remote) or []
|
||||
@@ -3,7 +3,7 @@ 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.modules.vyo.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
|
||||
@@ -22,14 +22,14 @@ class _SearchInterrupted(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class MediaVault:
|
||||
"""MediaVault 自建媒体库客户端。
|
||||
class Vyo:
|
||||
"""Vyo 媒体服务客户端。
|
||||
|
||||
只使用 MediaVault 管理端口的原生接口,凭据是管理面板的长效 API Key;
|
||||
自建媒体库没有音乐库,也不主动外发 Webhook,相关能力在模块层显式留空。
|
||||
只使用 Muvyo 管理端口的原生接口,凭据是 Muvyo 管理面板的长效 API Key;
|
||||
Vyo 没有音乐库,也不主动外发 Webhook,相关能力在模块层显式留空。
|
||||
"""
|
||||
|
||||
# MediaVault 单次列表请求的最大条数,与服务端 page_size 上限一致
|
||||
# Vyo 单次列表请求的最大条数,与服务端 page_size 上限一致
|
||||
PAGE_LIMIT = 100
|
||||
# 媒体库类型到 MoviePilot 媒体类型的映射
|
||||
LIBRARY_TYPES = {"movies": MediaType.MOVIE.value, "tvshows": MediaType.TV.value}
|
||||
@@ -51,7 +51,7 @@ class MediaVault:
|
||||
self._api = Api(host=host, apikey=apikey)
|
||||
self._active = False
|
||||
if not self.is_configured():
|
||||
logger.error("MediaVault 配置不完整!")
|
||||
logger.error("Vyo 配置不完整!")
|
||||
return
|
||||
self.reconnect()
|
||||
|
||||
@@ -85,7 +85,7 @@ class MediaVault:
|
||||
self._api.close()
|
||||
|
||||
def authenticate(self, username: str, password: str) -> Optional[str]:
|
||||
"""用 MediaVault 账号完成用户认证,返回访问令牌。"""
|
||||
"""用 Vyo 账号完成用户认证,返回访问令牌。"""
|
||||
if not self.is_configured() or not username or not password:
|
||||
return None
|
||||
result = self._api.request(
|
||||
@@ -209,7 +209,7 @@ class MediaVault:
|
||||
sort_by: str = "",
|
||||
sort_order: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""调用条目列表接口;MediaVault 只接受页码,偏移量由调用方换算。"""
|
||||
"""调用条目列表接口;Vyo 只接受页码,偏移量由调用方换算。"""
|
||||
params: Dict[str, Any] = {"page": max(1, page), "page_size": page_size}
|
||||
if library_id:
|
||||
params["library_id"] = library_id
|
||||
@@ -233,7 +233,7 @@ class MediaVault:
|
||||
def __search_rows(self, keyword: str, kinds: str) -> Optional[Generator[Dict[str, Any], Any, None]]:
|
||||
"""按关键字翻页产出条目原始记录。
|
||||
|
||||
MediaVault 的关键字查询是模糊匹配,命中数可能超过单页上限;只读第一页会让
|
||||
Vyo 的关键字查询是模糊匹配,命中数可能超过单页上限;只读第一页会让
|
||||
存在性判断把排在后面的目标误判成「未入库」,因此这里翻页直到取完。
|
||||
连接失败返回 None,与「查得到但没有」区分开。
|
||||
"""
|
||||
@@ -422,7 +422,7 @@ class MediaVault:
|
||||
page += 1
|
||||
|
||||
def __format_item_info(self, row: Dict[str, Any]) -> Optional[_SchemaMediaServerItem]:
|
||||
"""把 MediaVault 条目转换为统一媒体服务器模型。"""
|
||||
"""把 Vyo 条目转换为统一媒体服务器模型。"""
|
||||
try:
|
||||
metadata = row.get("metadata_info") or {}
|
||||
provider_ids: Dict[str, Any] = {}
|
||||
@@ -454,7 +454,7 @@ class MediaVault:
|
||||
) if user_data else None,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"解析 MediaVault 条目失败:{err}")
|
||||
logger.error(f"解析 Vyo 条目失败:{err}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -615,7 +615,7 @@ class MediaVault:
|
||||
matched.add(library_id)
|
||||
else:
|
||||
unmatched = True
|
||||
logger.info(f"MediaVault 中未找到 {item.title} 对应的媒体库,将扫描全部媒体库")
|
||||
logger.info(f"Vyo 中未找到 {item.title} 对应的媒体库,将扫描全部媒体库")
|
||||
if unmatched:
|
||||
return self.refresh_root_library()
|
||||
# 先全部发出排队请求再汇总:交给 all() 的生成器会在首个失败处短路,
|
||||
@@ -641,7 +641,7 @@ class MediaVault:
|
||||
return ""
|
||||
|
||||
def __queue_scan(self, library_id: str) -> bool:
|
||||
"""把媒体库扫描排进 MediaVault 的后台队列,不阻塞入库流程。"""
|
||||
"""把媒体库扫描排进 Vyo 的后台队列,不阻塞入库流程。"""
|
||||
if not library_id:
|
||||
return False
|
||||
result = self._api.request(f"/libraries/{library_id}/scan-task", method="post")
|
||||
@@ -68,7 +68,7 @@ BASELINE_ASSESSED_MODULES = frozenset(
|
||||
"jellyfin",
|
||||
"listenbrainz",
|
||||
"lrclib",
|
||||
"mediavault",
|
||||
"vyo",
|
||||
"musicbrainz",
|
||||
"navidrome",
|
||||
"plex",
|
||||
|
||||
@@ -592,8 +592,8 @@ class MediaServerType(Enum):
|
||||
Ugreen = "Ugreen"
|
||||
# Navidrome 音乐服务器
|
||||
Navidrome = "Navidrome"
|
||||
# MediaVault 自建媒体库
|
||||
MediaVault = "MediaVault"
|
||||
# Vyo 媒体服务
|
||||
Vyo = "Vyo"
|
||||
|
||||
|
||||
# 识别器类型
|
||||
|
||||
@@ -140,7 +140,7 @@ MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。
|
||||
| :--- | :--- | :--- |
|
||||
| `moviepilot_api` | MoviePilot 产品业务 API:媒体、搜索、订阅、下载、整理、站点、存储、调度、工作流、插件、过滤规则和系统配置 | `skills/moviepilot-api/SKILL.md` 及其 `api/<category>.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、MediaVault 原生媒体库、搜索、播放、扫描和刷新操作 | `skills/mediaserver-operation/SKILL.md` 与 `skills/mediaserver-operation/scripts/mp-mediaserver.py` 的 `ACTIONS` |
|
||||
| `mediaserver_operation` | Emby、Jellyfin、Plex、ZSpace、UGREEN、TrimeMedia、Navidrome、Vyo 原生媒体库、搜索、播放、扫描和刷新操作 | `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 运行时配置读取数据库连接。
|
||||
|
||||
@@ -35,7 +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",
|
||||
"mediavault": "app.modules.vyo.vyo:Vyo",
|
||||
}
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
@@ -931,7 +931,7 @@
|
||||
}
|
||||
},
|
||||
"edge_count": 9218,
|
||||
"edge_sha256": "50c110ffa461d8c05104774cbd16d7d28ba0ad33fbfe16c847e61dcc4a5ff6bf",
|
||||
"edge_sha256": "6b4e9129b86a6618ca5a42d4c90a8f345af18a115a894deb5ea5c4dc07be3aa3",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -7561,38 +7561,6 @@
|
||||
"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",
|
||||
@@ -8282,6 +8250,38 @@
|
||||
"app.modules.vocechat.vocechat -> app.runtime",
|
||||
"app.modules.vocechat.vocechat -> app.runtime.execution",
|
||||
"app.modules.vocechat.vocechat -> app.runtime.log",
|
||||
"app.modules.vyo.api -> app.adapters",
|
||||
"app.modules.vyo.api -> app.adapters.network",
|
||||
"app.modules.vyo.api -> app.adapters.network.http",
|
||||
"app.modules.vyo.api -> app.foundation",
|
||||
"app.modules.vyo.api -> app.foundation.url",
|
||||
"app.modules.vyo.api -> app.runtime",
|
||||
"app.modules.vyo.api -> app.runtime.log",
|
||||
"app.modules.vyo.api -> app.runtime.settings",
|
||||
"app.modules.vyo.module -> app.modules",
|
||||
"app.modules.vyo.module -> app.modules._base",
|
||||
"app.modules.vyo.module -> app.modules._base.mediaserver",
|
||||
"app.modules.vyo.module -> app.modules.vyo",
|
||||
"app.modules.vyo.module -> app.modules.vyo.vyo",
|
||||
"app.modules.vyo.module -> app.runtime",
|
||||
"app.modules.vyo.module -> app.runtime.log",
|
||||
"app.modules.vyo.module -> app.schemas",
|
||||
"app.modules.vyo.module -> app.schemas.dashboard",
|
||||
"app.modules.vyo.module -> app.schemas.mediaserver",
|
||||
"app.modules.vyo.module -> app.schemas.types",
|
||||
"app.modules.vyo.vyo -> app.application",
|
||||
"app.modules.vyo.vyo -> app.application.mediaserver",
|
||||
"app.modules.vyo.vyo -> app.foundation",
|
||||
"app.modules.vyo.vyo -> app.foundation.url",
|
||||
"app.modules.vyo.vyo -> app.modules",
|
||||
"app.modules.vyo.vyo -> app.modules.vyo",
|
||||
"app.modules.vyo.vyo -> app.modules.vyo.api",
|
||||
"app.modules.vyo.vyo -> app.runtime",
|
||||
"app.modules.vyo.vyo -> app.runtime.log",
|
||||
"app.modules.vyo.vyo -> app.schemas",
|
||||
"app.modules.vyo.vyo -> app.schemas.dashboard",
|
||||
"app.modules.vyo.vyo -> app.schemas.mediaserver",
|
||||
"app.modules.vyo.vyo -> app.schemas.types",
|
||||
"app.modules.webpush -> app.adapters",
|
||||
"app.modules.webpush -> app.adapters.network",
|
||||
"app.modules.webpush -> app.adapters.network.webpush",
|
||||
@@ -10916,10 +10916,6 @@
|
||||
"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.navidrome",
|
||||
@@ -10996,6 +10992,10 @@
|
||||
"app.modules.ugreen.ugreen",
|
||||
"app.modules.vocechat",
|
||||
"app.modules.vocechat.vocechat",
|
||||
"app.modules.vyo",
|
||||
"app.modules.vyo.api",
|
||||
"app.modules.vyo.module",
|
||||
"app.modules.vyo.vyo",
|
||||
"app.modules.webpush",
|
||||
"app.modules.wechat",
|
||||
"app.modules.wechat.wechat",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""MediaVault 自建媒体库客户端的行为契约,全部走假 API 不发真实请求。"""
|
||||
"""Vyo 媒体服务客户端的行为契约,全部走假 API 不发真实请求。"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.mediavault.api import Api, Result
|
||||
from app.modules.mediavault.mediavault import MediaVault
|
||||
from app.modules.vyo.api import Api, Result
|
||||
from app.modules.vyo.vyo import Vyo
|
||||
from app.schemas.mediaserver import RefreshMediaItem
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
@@ -40,9 +40,9 @@ class _FakeApi:
|
||||
return handler(params or {}, data) if callable(handler) else handler
|
||||
|
||||
|
||||
def _client(routes: dict, **kwargs) -> MediaVault:
|
||||
def _client(routes: dict, **kwargs) -> Vyo:
|
||||
"""构造一个绕过网络探测的客户端。"""
|
||||
client = MediaVault.__new__(MediaVault)
|
||||
client = Vyo.__new__(Vyo)
|
||||
client._host = "http://mv.local"
|
||||
client._playhost = kwargs.get("play_host")
|
||||
client._apikey = "k"
|
||||
@@ -62,7 +62,7 @@ def _item_row(index: int, kind: str = "Movie", **extra) -> dict:
|
||||
|
||||
|
||||
def _paged_items(total_rows: list):
|
||||
"""按 page/page_size 切分预置条目,模拟 MediaVault 的分页语义。"""
|
||||
"""按 page/page_size 切分预置条目,模拟 Vyo 的分页语义。"""
|
||||
|
||||
def handler(params, _data):
|
||||
page = int(params.get("page", 1))
|
||||
@@ -107,7 +107,7 @@ def test_get_items_always_requests_full_pages():
|
||||
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 sizes == {Vyo.PAGE_LIMIT}
|
||||
assert pages == [1, 2]
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ def test_get_items_stops_when_request_fails():
|
||||
|
||||
|
||||
def test_get_librarys_maps_type_and_builds_image_url():
|
||||
"""媒体库类型按 MediaVault 的 library_type 映射,封面走带鉴权的图片直链。"""
|
||||
"""媒体库类型按 Vyo 的 library_type 映射,封面走带鉴权的图片直链。"""
|
||||
routes = {
|
||||
"/libraries": Result(True, {"items": [
|
||||
{"id": "lib-1", "name": "电影库", "library_type": "movies", "root_paths": ["/mnt/movies"]},
|
||||
@@ -421,7 +421,7 @@ def test_unconfigured_client_never_reports_inactive():
|
||||
|
||||
|
||||
def test_authenticate_posts_to_user_auth_and_returns_token():
|
||||
"""用户认证走 MediaVault 账号体系,返回访问令牌。"""
|
||||
"""用户认证走 Vyo 账号体系,返回访问令牌。"""
|
||||
routes = {"/login": Result(True, {"access_token": "jwt-token", "token": "legacy"})}
|
||||
client = _client(routes)
|
||||
assert client.authenticate("someone", "secret") == "jwt-token"
|
||||
@@ -449,7 +449,7 @@ def test_disconnect_closes_session():
|
||||
|
||||
def test_get_movies_pages_past_the_first_page():
|
||||
"""关键字命中超过单页上限时,仍要能找到排在后面的目标电影。"""
|
||||
rows = [_item_row(i, title=f"其它{i}") for i in range(MediaVault.PAGE_LIMIT)]
|
||||
rows = [_item_row(i, title=f"其它{i}") for i in range(Vyo.PAGE_LIMIT)]
|
||||
rows.append(_item_row(999, title="沙丘", year=2021, tmdb_id=438631))
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
|
||||
@@ -461,7 +461,7 @@ def test_get_movies_pages_past_the_first_page():
|
||||
|
||||
def test_find_series_pages_past_the_first_page():
|
||||
"""按标题定位剧集时同样要翻页,否则整部剧会被误判成未入库。"""
|
||||
rows = [_item_row(i, kind="Series", title=f"其它{i}") for i in range(MediaVault.PAGE_LIMIT)]
|
||||
rows = [_item_row(i, kind="Series", title=f"其它{i}") for i in range(Vyo.PAGE_LIMIT)]
|
||||
rows.append(_item_row(999, kind="Series", title="剧A", year=2020))
|
||||
routes = {
|
||||
"/items": _paged_items(rows),
|
||||
@@ -550,7 +550,7 @@ def _failing_second_page(total_rows: list):
|
||||
def test_get_movies_reports_unreachable_when_a_later_page_fails():
|
||||
"""翻页中途断连必须返回 None,不能把残缺结果当成「不在库中」。"""
|
||||
# total 必须超过一页,否则第一页就判定取完,构造不出「中途失败」
|
||||
rows = [_item_row(i, title=f"沙丘{i}") for i in range(MediaVault.PAGE_LIMIT + 1)]
|
||||
rows = [_item_row(i, title=f"沙丘{i}") for i in range(Vyo.PAGE_LIMIT + 1)]
|
||||
client = _client({"/items": _failing_second_page(rows)})
|
||||
|
||||
assert client.get_movies(title="沙丘") is None
|
||||
@@ -558,7 +558,7 @@ def test_get_movies_reports_unreachable_when_a_later_page_fails():
|
||||
|
||||
def test_find_series_reports_unreachable_when_a_later_page_fails():
|
||||
"""同上:剧集定位不能把断连误判成整部剧未入库。"""
|
||||
rows = [_item_row(i, kind="Series", title=f"剧{i}") for i in range(MediaVault.PAGE_LIMIT + 1)]
|
||||
rows = [_item_row(i, kind="Series", title=f"剧{i}") for i in range(Vyo.PAGE_LIMIT + 1)]
|
||||
client = _client({"/items": _failing_second_page(rows)})
|
||||
|
||||
assert client.get_tv_episodes(title="剧A") == (None, None)
|
||||
@@ -566,19 +566,19 @@ def test_find_series_reports_unreachable_when_a_later_page_fails():
|
||||
|
||||
def test_search_stops_at_reported_total_without_an_extra_request():
|
||||
"""末页恰好满额时用 total 判定取完,不再多发一次可能失败的请求。"""
|
||||
rows = [_item_row(i, title="沙丘", year=2021) for i in range(MediaVault.PAGE_LIMIT)]
|
||||
rows = [_item_row(i, title="沙丘", year=2021) for i in range(Vyo.PAGE_LIMIT)]
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
|
||||
matched = client.get_movies(title="沙丘")
|
||||
|
||||
assert len(matched) == MediaVault.PAGE_LIMIT
|
||||
assert len(matched) == Vyo.PAGE_LIMIT
|
||||
assert [call["params"]["page"] for call in client._api.calls] == [1]
|
||||
|
||||
|
||||
def test_find_series_returns_first_page_hit_even_if_a_later_page_fails():
|
||||
"""第一页已命中就该直接返回,不因后续页失败被误报成服务不可达。"""
|
||||
rows = [_item_row(0, kind="Series", title="剧A", year=2020)]
|
||||
rows += [_item_row(i, kind="Series", title=f"其它{i}") for i in range(1, MediaVault.PAGE_LIMIT + 1)]
|
||||
rows += [_item_row(i, kind="Series", title=f"其它{i}") for i in range(1, Vyo.PAGE_LIMIT + 1)]
|
||||
routes = {
|
||||
"/items": _failing_second_page(rows),
|
||||
"/items/id-0/episodes": Result(True, {"seasons": {"1": [1]}}),
|
||||
Reference in New Issue
Block a user