feat(mediaserver): 内置媒体服务 MediaVault 更名为 Vyo

主项目更名为 Muvyo,内置媒体服务更名为 Vyo。模块目录、文件、类名、模块 ID、枚举、
名称描述、日志与文档统一改为 Vyo;配置类型值 mediavault 保持不变,
已保存的媒体服务器配置无需迁移。
This commit is contained in:
thsrite
2026-09-30 19:41:57 +08:00
parent dc0ad842fb
commit 4395ddecf2
15 changed files with 123 additions and 122 deletions

View File

@@ -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."
)

View File

@@ -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",

View File

@@ -57,9 +57,9 @@
"name": "Jellyfin",
"description": "媒体服务器适配器:同步 Jellyfin 媒体库并执行刷新、播放等操作。"
},
"MediaVaultModule": {
"name": "MediaVault",
"description": "媒体服务器适配器:连接 MediaVault 并同步媒体库信息。"
"VyoModule": {
"name": "Vyo",
"description": "媒体服务器适配器:连接 Vyo(Muvyo 内置媒体服务)并同步媒体库信息。"
},
"LrclibModule": {
"name": "LRCLIB",

View File

@@ -57,9 +57,9 @@
"name": "Jellyfin",
"description": "媒體伺服器適配器:同步 Jellyfin 媒體庫並執行重新整理、播放等操作。"
},
"MediaVaultModule": {
"name": "MediaVault",
"description": "媒體伺服器適配器:連接 MediaVault 並同步媒體庫資訊。"
"VyoModule": {
"name": "Vyo",
"description": "媒體伺服器適配器:連接 Vyo(Muvyo 內建媒體服務)並同步媒體庫資訊。"
},
"LrclibModule": {
"name": "LRCLIB",

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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]

View File

@@ -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 []

View File

@@ -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")

View File

@@ -68,7 +68,7 @@ BASELINE_ASSESSED_MODULES = frozenset(
"jellyfin",
"listenbrainz",
"lrclib",
"mediavault",
"vyo",
"musicbrainz",
"navidrome",
"plex",

View File

@@ -592,8 +592,8 @@ class MediaServerType(Enum):
Ugreen = "Ugreen"
# Navidrome 音乐服务器
Navidrome = "Navidrome"
# MediaVault 自建媒体库
MediaVault = "MediaVault"
# Vyo 媒体服务
Vyo = "Vyo"
# 识别器类型

View File

@@ -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 运行时配置读取数据库连接。

View File

@@ -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()

View File

@@ -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",

View File

@@ -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]}}),