From d04b721f84f7c84a9b12e9eda4038ddb6dc8a1b6 Mon Sep 17 00:00:00 2001 From: thsrite Date: Mon, 7 Sep 2026 13:47:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(mediaserver):=20=E6=96=B0=E5=A2=9E=20M?= =?UTF-8?q?ediaVault=20=E8=87=AA=E5=BB=BA=E5=AA=92=E4=BD=93=E5=BA=93?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 MediaVault 管理端口的原生接口接入自建媒体库,凭据为管理面板的长效 API Key: - 媒体库列表、条目分页遍历、条目详情与总数 - 按标题/年份/媒体身份判断电影与剧集存在性,季集齐全度与季集条目 ID - 数量统计、最近入库、继续观看、封面与背景图直链、播放地址 - 入库后按目标路径命中媒体库并排队后台扫描,未命中时回退全库扫描 - 用 MediaVault 账号完成 MoviePilot 用户认证 MediaVault 无音乐库也不外发 Webhook,get_music 与 webhook_parser 不实现; 图片直链自带 API Key,无需 mediaserver_image_cookies。 同时把 mediavault 接入 mediaserver-operation Agent Skill 的 provider 清单。 --- app/agent/tools/impl/service.py | 2 +- app/locales/en-US.json | 3 + app/locales/zh-CN.json | 3 + app/locales/zh-TW.json | 3 + app/modules/mediavault/__init__.py | 29 + app/modules/mediavault/api.py | 124 ++++ app/modules/mediavault/capability.toml | 22 + app/modules/mediavault/mediavault.py | 589 ++++++++++++++++++ app/modules/mediavault/module.py | 231 +++++++ app/runtime/extensions/module/quality.py | 1 + app/schemas/types.py | 2 + docs/architecture-overview.md | 4 +- docs/architecture/optimization-checklist.md | 2 +- docs/mcp-api.md | 2 +- skills/mediaserver-operation/SKILL.md | 30 +- .../scripts/mp-mediaserver.py | 10 +- .../architecture/dependency-baseline.json | 42 +- tests/test_mediavault_module.py | 443 +++++++++++++ .../test_module_manager_capability_adapter.py | 12 +- 19 files changed, 1521 insertions(+), 33 deletions(-) create mode 100644 app/modules/mediavault/__init__.py create mode 100644 app/modules/mediavault/api.py create mode 100644 app/modules/mediavault/capability.toml create mode 100644 app/modules/mediavault/mediavault.py create mode 100644 app/modules/mediavault/module.py create mode 100644 tests/test_mediavault_module.py diff --git a/app/agent/tools/impl/service.py b/app/agent/tools/impl/service.py index f115ad815..d5c17fc4d 100644 --- a/app/agent/tools/impl/service.py +++ b/app/agent/tools/impl/service.py @@ -413,7 +413,7 @@ class MediaServerOperationTool(_ServiceOperationTool): name: str = "mediaserver_operation" description: str = ( "Operate a configured Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia, " - "or Navidrome server. The input schema contains one exact branch per action, " + "Navidrome, or MediaVault server. The input schema contains one exact branch per action, " "including providers, effects, required fields, types, defaults, enums, nested " "item fields, and cross-field constraints." ) diff --git a/app/locales/en-US.json b/app/locales/en-US.json index 5268d15de..3e59ac9ce 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -34,6 +34,9 @@ "JellyfinModule": { "name": "Jellyfin" }, + "MediaVaultModule": { + "name": "MediaVault" + }, "PlexModule": { "name": "Plex" }, diff --git a/app/locales/zh-CN.json b/app/locales/zh-CN.json index fc01646c4..f5be3af97 100644 --- a/app/locales/zh-CN.json +++ b/app/locales/zh-CN.json @@ -34,6 +34,9 @@ "JellyfinModule": { "name": "Jellyfin" }, + "MediaVaultModule": { + "name": "MediaVault" + }, "PlexModule": { "name": "Plex" }, diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 6865fa371..12d1e11ab 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -34,6 +34,9 @@ "JellyfinModule": { "name": "Jellyfin" }, + "MediaVaultModule": { + "name": "MediaVault" + }, "PlexModule": { "name": "Plex" }, diff --git a/app/modules/mediavault/__init__.py b/app/modules/mediavault/__init__.py new file mode 100644 index 000000000..fd163595b --- /dev/null +++ b/app/modules/mediavault/__init__.py @@ -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"] diff --git a/app/modules/mediavault/api.py b/app/modules/mediavault/api.py new file mode 100644 index 000000000..cf14537fe --- /dev/null +++ b/app/modules/mediavault/api.py @@ -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] diff --git a/app/modules/mediavault/capability.toml b/app/modules/mediavault/capability.toml new file mode 100644 index 000000000..70c517782 --- /dev/null +++ b/app/modules/mediavault/capability.toml @@ -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" diff --git a/app/modules/mediavault/mediavault.py b/app/modules/mediavault/mediavault.py new file mode 100644 index 000000000..564dc0dac --- /dev/null +++ b/app/modules/mediavault/mediavault.py @@ -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) diff --git a/app/modules/mediavault/module.py b/app/modules/mediavault/module.py new file mode 100644 index 000000000..010f0e932 --- /dev/null +++ b/app/modules/mediavault/module.py @@ -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 [] diff --git a/app/runtime/extensions/module/quality.py b/app/runtime/extensions/module/quality.py index c7d670c2d..b27f40f5b 100644 --- a/app/runtime/extensions/module/quality.py +++ b/app/runtime/extensions/module/quality.py @@ -67,6 +67,7 @@ BASELINE_ASSESSED_MODULES = frozenset( "jellyfin", "listenbrainz", "lrclib", + "mediavault", "musicbrainz", "musixmatch", "navidrome", diff --git a/app/schemas/types.py b/app/schemas/types.py index 67e8ab8d7..8ab951639 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -594,6 +594,8 @@ class MediaServerType(Enum): Ugreen = "Ugreen" # Navidrome 音乐服务器 Navidrome = "Navidrome" + # MediaVault 自建媒体库 + MediaVault = "MediaVault" # 识别器类型 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 0297ea902..fe6b9f7f3 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -754,8 +754,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 976 | -| 内部导入边 | 8,270 | +| Python 模块 | 980 | +| 内部导入边 | 8,302 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index f8a6b03e0..b73081b84 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 976 / 8,270 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 980 / 8,302 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index f3dab70d4..8febc1c49 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -45,7 +45,7 @@ MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。 | :--- | :--- | :--- | | `moviepilot_api` | MoviePilot 产品业务 API:媒体、搜索、订阅、下载、整理、站点、存储、调度、工作流、插件、过滤规则和系统配置 | `skills/moviepilot-api/SKILL.md`;运行时 schema 为 `app/agent/policy/resources/api_mcp_schema.json` | | `downloader_operation` | qBittorrent、Transmission、rTorrent 原生任务、队列、文件、限速、标签和会话操作 | `skills/downloader-operation/SKILL.md` 与 `skills/downloader-operation/scripts/mp-downloader.py` 的 `ACTIONS` | -| `mediaserver_operation` | Emby、Jellyfin、Plex、ZSpace、UGREEN、TrimeMedia、Navidrome 原生媒体库、搜索、播放、扫描和刷新操作 | `skills/mediaserver-operation/SKILL.md` 与 `skills/mediaserver-operation/scripts/mp-mediaserver.py` 的 `ACTIONS` | +| `mediaserver_operation` | Emby、Jellyfin、Plex、ZSpace、UGREEN、TrimeMedia、Navidrome、MediaVault 原生媒体库、搜索、播放、扫描和刷新操作 | `skills/mediaserver-operation/SKILL.md` 与 `skills/mediaserver-operation/scripts/mp-mediaserver.py` 的 `ACTIONS` | | `database_operation` | MoviePilot 配置数据库表清单、实时 schema、只读 SQL 和明确授权写入 | `skills/database-operation/SKILL.md` 与 `skills/database-operation/scripts/mp-db.py` 的 `ACTIONS` | 这四个工具都要求管理员级 MCP 集成身份;`tools/list` 的可见性不等于绕过业务权限或写操作确认。下载器和媒体服务器工具会在一次调用内自动选择默认/唯一实例;实例不明确时,错误结果会列出可复用的精确实例名。数据库工具不接受任意连接串或凭据,脚本从 MoviePilot 运行时配置读取数据库连接。 diff --git a/skills/mediaserver-operation/SKILL.md b/skills/mediaserver-operation/SKILL.md index 89ae5f0ba..9f9a43f84 100644 --- a/skills/mediaserver-operation/SKILL.md +++ b/skills/mediaserver-operation/SKILL.md @@ -139,46 +139,46 @@ A field name ending in `*` is required. Put every action parameter in the `argum | `server.users.count` | Read provider user count.; no arguments | ### `activity.backdrops` -Read recent provider backdrop images. Effect: `safe_read`. Providers: `ugreen, trimemedia`. +Read recent provider backdrop images. Effect: `safe_read`. Providers: `ugreen, trimemedia, mediavault`. - `limit` (integer; default `50`): Number of items to return, from 1 to 200. - `remote` (boolean; default `False`): Return provider URLs that are remotely accessible. ### `activity.latest` -Read recently added provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`. +Read recently added provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`. - `limit` (integer; default `50`): Number of items to return, from 1 to 200. - `username` (string): Read for this username; supported by Emby, Jellyfin, and ZSpace. ### `activity.resume` -Read in-progress/resumable provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`. +Read in-progress/resumable provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`. - `limit` (integer; default `50`): Number of items to return, from 1 to 200. - `username` (string): Read for this username; supported by Emby, Jellyfin, and ZSpace. ### `capabilities.list` -List supported media-server actions and their complete argument contracts. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`. +List supported media-server actions and their complete argument contracts. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`. - `action_name` (string): Optional exact action name used to return one capability contract. ### `instances.list` -List configured media-server instances without connection secrets. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`. +List configured media-server instances without connection secrets. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`. - `arguments`: `{}` ### `items.count` -Count items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`. +Count items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`. - `parent` (string|integer): Library or parent item ID; Navidrome may omit it and use music. - Rule: parent is required except for Navidrome, which defaults to music. ### `items.detail` -Read one provider item by native ID. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`. +Read one provider item by native ID. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`. - `item_id*` (string): Provider-native item ID returned by the selected media server. ### `items.list` -Page items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`. +Page items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`. - `parent` (string|integer): Library or parent item ID; Navidrome may omit it and use music. - `offset` (integer; default `0`): Zero-based list offset. - `limit` (integer; default `50`): Number of items to return, from 1 to 200. - Rule: parent is required except for Navidrome, which ignores it. ### `items.movies.search` -Search provider-native movie items by title and optional year. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia`. +Search provider-native movie items by title and optional year. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, mediavault`. - `title*` (string): Movie title. - `year` (string|integer): Optional release year. @@ -190,7 +190,7 @@ Search provider-native music by title, artist, or album. Effect: `safe_read`. Pr - Rule: Provide at least one of title, artist, and album. ### `items.season_episodes` -Read native episode coverage for one series and optional season. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia`. +Read native episode coverage for one series and optional season. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, mediavault`. - `item_id` (string): Provider-native item ID returned by the selected media server. - `title` (string): Series title; provide it or item_id. - `year` (string|integer): Optional premiere year. @@ -198,12 +198,12 @@ Read native episode coverage for one series and optional season. Effect: `safe_r - Rule: Provide at least one of item_id and title. ### `libraries.list` -List visible provider libraries. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`. +List visible provider libraries. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`. - `hidden` (boolean; default `False`): Return only libraries configured for synchronization. - `username` (string): Read libraries visible to this username; supported by Emby, Jellyfin, and ZSpace. ### `library.scan` -Trigger a provider library scan. Effect: `external_side_effect`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`. +Trigger a provider library scan. Effect: `external_side_effect`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`. - `scan_mode` (string|integer): UGREEN-native scan mode; omit it for every other provider. ### `metadata.refresh` @@ -215,11 +215,11 @@ Read active playback sessions. Effect: `safe_read`. Providers: `emby, jellyfin, - `arguments`: `{}` ### `playback.url` -Build the provider play URL for one item. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`. +Build the provider play URL for one item. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`. - `item_id*` (string): Provider-native item ID returned by the selected media server. ### `server.statistics` -Read media counts and provider statistics. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`. +Read media counts and provider statistics. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`. - `arguments`: `{}` ### `server.user.library_folders` @@ -227,7 +227,7 @@ Read the current user's visible library folders. Effect: `safe_read`. Providers: - `arguments`: `{}` ### `server.users.count` -Read provider user count. Effect: `safe_read`. Providers: `emby, jellyfin, zspace, ugreen, trimemedia, navidrome`. +Read provider user count. Effect: `safe_read`. Providers: `emby, jellyfin, zspace, ugreen, trimemedia, navidrome, mediavault`. - `arguments`: `{}` ## Safety And Verification diff --git a/skills/mediaserver-operation/scripts/mp-mediaserver.py b/skills/mediaserver-operation/scripts/mp-mediaserver.py index 3848ac095..227671e59 100644 --- a/skills/mediaserver-operation/scripts/mp-mediaserver.py +++ b/skills/mediaserver-operation/scripts/mp-mediaserver.py @@ -25,6 +25,7 @@ ALL_PROVIDERS = ( "ugreen", "trimemedia", "navidrome", + "mediavault", ) PROVIDER_CLASSES = { "emby": "app.modules.emby.emby:Emby", @@ -34,6 +35,7 @@ PROVIDER_CLASSES = { "ugreen": "app.modules.ugreen.ugreen:Ugreen", "trimemedia": "app.modules.trimemedia.trimemedia:TrimeMedia", "navidrome": "app.modules.navidrome.navidrome:Navidrome", + "mediavault": "app.modules.mediavault.mediavault:MediaVault", } _UNSET = object() @@ -122,7 +124,7 @@ ACTIONS: dict[str, ActionSpec] = { "server.users.count": ActionSpec( "Read provider user count.", "safe_read", - ("emby", "jellyfin", "zspace", "ugreen", "trimemedia", "navidrome"), + ("emby", "jellyfin", "zspace", "ugreen", "trimemedia", "navidrome", "mediavault"), ), "server.user.library_folders": ActionSpec( "Read the current user's visible library folders.", @@ -157,7 +159,7 @@ ACTIONS: dict[str, ActionSpec] = { "items.movies.search": ActionSpec( "Search provider-native movie items by title and optional year.", "safe_read", - ("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"), + ("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia", "mediavault"), ( ArgumentSpec("title", "string", "Movie title.", required=True), ArgumentSpec("year", "string|integer", "Optional release year."), @@ -177,7 +179,7 @@ ACTIONS: dict[str, ActionSpec] = { "items.season_episodes": ActionSpec( "Read native episode coverage for one series and optional season.", "safe_read", - ("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"), + ("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia", "mediavault"), ( ITEM_ID, ArgumentSpec("title", "string", "Series title; provide it or item_id."), @@ -199,7 +201,7 @@ ACTIONS: dict[str, ActionSpec] = { "activity.backdrops": ActionSpec( "Read recent provider backdrop images.", "safe_read", - ("ugreen", "trimemedia"), + ("ugreen", "trimemedia", "mediavault"), ( LIMIT, ArgumentSpec("remote", "boolean", "Return provider URLs that are remotely accessible.", default=False), diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 866704f08..ad55e6b6e 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1074,8 +1074,8 @@ "runtime_only": true } }, - "edge_count": 8270, - "edge_sha256": "8f25287ff625b0327fa3ee567ff99ac90818376d0fdc34e91834eba90e133d7c", + "edge_count": 8302, + "edge_sha256": "49000047cece20aa2bd7f1d06916072d06b832f0f2d929693f995206f9a29b84", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -6898,6 +6898,38 @@ "app.modules.lrclib -> app.runtime.settings", "app.modules.lrclib -> app.schemas", "app.modules.lrclib -> app.schemas.types", + "app.modules.mediavault.api -> app.adapters", + "app.modules.mediavault.api -> app.adapters.network", + "app.modules.mediavault.api -> app.adapters.network.http", + "app.modules.mediavault.api -> app.foundation", + "app.modules.mediavault.api -> app.foundation.url", + "app.modules.mediavault.api -> app.runtime", + "app.modules.mediavault.api -> app.runtime.log", + "app.modules.mediavault.api -> app.runtime.settings", + "app.modules.mediavault.mediavault -> app.application", + "app.modules.mediavault.mediavault -> app.application.mediaserver", + "app.modules.mediavault.mediavault -> app.foundation", + "app.modules.mediavault.mediavault -> app.foundation.url", + "app.modules.mediavault.mediavault -> app.modules", + "app.modules.mediavault.mediavault -> app.modules.mediavault", + "app.modules.mediavault.mediavault -> app.modules.mediavault.api", + "app.modules.mediavault.mediavault -> app.runtime", + "app.modules.mediavault.mediavault -> app.runtime.log", + "app.modules.mediavault.mediavault -> app.schemas", + "app.modules.mediavault.mediavault -> app.schemas.dashboard", + "app.modules.mediavault.mediavault -> app.schemas.mediaserver", + "app.modules.mediavault.mediavault -> app.schemas.types", + "app.modules.mediavault.module -> app.modules", + "app.modules.mediavault.module -> app.modules._base", + "app.modules.mediavault.module -> app.modules._base.mediaserver", + "app.modules.mediavault.module -> app.modules.mediavault", + "app.modules.mediavault.module -> app.modules.mediavault.mediavault", + "app.modules.mediavault.module -> app.runtime", + "app.modules.mediavault.module -> app.runtime.log", + "app.modules.mediavault.module -> app.schemas", + "app.modules.mediavault.module -> app.schemas.dashboard", + "app.modules.mediavault.module -> app.schemas.mediaserver", + "app.modules.mediavault.module -> app.schemas.types", "app.modules.musicbrainz -> app.adapters", "app.modules.musicbrainz -> app.adapters.network", "app.modules.musicbrainz -> app.adapters.network.http", @@ -9348,7 +9380,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 976, + "module_count": 980, "modules": [ "app", "app.adapters", @@ -10009,6 +10041,10 @@ "app.modules.jellyfin.jellyfin", "app.modules.listenbrainz", "app.modules.lrclib", + "app.modules.mediavault", + "app.modules.mediavault.api", + "app.modules.mediavault.mediavault", + "app.modules.mediavault.module", "app.modules.musicbrainz", "app.modules.musicbrainz.cache", "app.modules.musixmatch", diff --git a/tests/test_mediavault_module.py b/tests/test_mediavault_module.py new file mode 100644 index 000000000..7f0c86f05 --- /dev/null +++ b/tests/test_mediavault_module.py @@ -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 diff --git a/tests/test_module_manager_capability_adapter.py b/tests/test_module_manager_capability_adapter.py index e8b9534ad..d25d1dbc4 100644 --- a/tests/test_module_manager_capability_adapter.py +++ b/tests/test_module_manager_capability_adapter.py @@ -583,7 +583,7 @@ from app.runtime.extensions.module.adapter import ( registry = build_host_module_registry() specs = registry.list_specs() -assert len(specs) == 40 +assert len(specs) == 41 adapter = HostModuleAdapter() lifecycle_events = [] @@ -630,7 +630,7 @@ from app.schemas.types import EventType registry = build_host_module_registry() specs = registry.list_specs() -assert len(specs) == 40 +assert len(specs) == 41 spec_by_id = {spec.id: spec for spec in specs} events = {spec.id: [] for spec in specs} @@ -797,7 +797,7 @@ from app.runtime.extensions.module.adapter import ( registry = build_host_module_registry() specs = registry.list_specs() -assert len(specs) == 40 +assert len(specs) == 41 configured_specs = tuple( spec for spec in specs if spec.activation is ActivationPolicy.WHEN_CONFIGURED @@ -893,12 +893,12 @@ from app.application.module import configure_module_runtime configure_module_runtime(lambda: ModuleManager()) manager = ModuleManager() -assert len(manager.list_specs()) == 40 +assert len(manager.list_specs()) == 41 assert manager.get_specs() == manager.list_specs() from app.api.endpoints.system import modulelist response = modulelist(None) -assert len(response.data["modules"]) == 40 +assert len(response.data["modules"]) == 41 heavy_prefixes = ( "lark_oapi", @@ -1007,7 +1007,7 @@ from app.runtime.extensions.module.manager import ModuleManager manager = ModuleManager() modules = manager.get_modules() -assert len(modules) == len(manager.list_specs()) == 40 +assert len(modules) == len(manager.list_specs()) == 41 for spec in manager.list_specs(): implementation = modules[spec.id] assert implementation.get_name() == spec.metadata["name"] From 4a66d4336661f3e217c1c53c42ce7ce108233c93 Mon Sep 17 00:00:00 2001 From: thsrite Date: Mon, 7 Sep 2026 14:33:01 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(types):=20=E8=A1=A5=E9=BD=90=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=9F=BA=E7=B1=BB=E6=B3=A8=E8=A7=A3=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E6=96=B0=E6=A8=A1=E5=9D=97=E7=9A=84=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture Contract Gate 的 mypy ratchet 报出新模块两条错误,根因都在基类: - ConfigReloadMixin.__init_subclass__ 缺注解,导致所有继承它的模块在类型化 上下文中被判 no-untyped-call - _ModuleBase.init_setting 的返回注解为 Tuple[...],与其文档说明的 「不实现该方法或返回 None 代表不使用开关」矛盾,子类如实声明 Optional 就会被判 override 不兼容 两处均为纯注解修正,无行为变化。全量 mypy 错误由 9,496 降至 9,441(净减 55, 未新增任何错误),出错文件 514 → 513;同步固化 mypy 低水位与架构清单中的计数。 --- app/modules/__init__.py | 2 +- app/runtime/reload.py | 3 +- docs/architecture/optimization-checklist.md | 2 +- .../fixtures/architecture/mypy-baseline.json | 81 +++++-------------- 4 files changed, 25 insertions(+), 63 deletions(-) diff --git a/app/modules/__init__.py b/app/modules/__init__.py index ad49cd91c..2312e9625 100644 --- a/app/modules/__init__.py +++ b/app/modules/__init__.py @@ -50,7 +50,7 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta): pass @abstractmethod - def init_setting(self) -> Tuple[str, Union[str, bool]]: + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: """ 模块开关设置,返回开关名和开关值,开关值为True时代表有值即打开,不实现该方法或返回None代表不使用开关 部分模块支持同时开启多个,此时设置项以,分隔,开关值使用in判断 diff --git a/app/runtime/reload.py b/app/runtime/reload.py index dac3e52a1..c3df4a88e 100644 --- a/app/runtime/reload.py +++ b/app/runtime/reload.py @@ -1,4 +1,5 @@ import inspect +from typing import Any from app.runtime.events import eventmanager, Event from app.runtime.log import logger @@ -17,7 +18,7 @@ class ConfigReloadMixin: # 统一生命周期管理器可以继承此 Mixin 的重载方法,但由外部唯一负责事件绑定。 CONFIG_RELOAD_MANAGED_EXTERNALLY: bool = False - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs: Any) -> None: """为声明了 CONFIG_WATCH 的子类生成配置变更处理器。""" super().__init_subclass__(**kwargs) diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index b73081b84..881dba8f0 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | -| 全量 mypy 历史债务 | 9,494 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | +| 全量 mypy 历史债务 | 9,441 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | | Ruff 历史诊断 | 538 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 9a7f96214..88875bcfe 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -1395,39 +1395,34 @@ "empty-body": 4, "misc": 3, "no-any-return": 1, - "no-untyped-call": 5, + "no-untyped-call": 4, "no-untyped-def": 7, "union-attr": 4 }, "app/modules/_base/downloader.py": { "assignment": 1, - "attr-defined": 5, - "no-untyped-call": 1 + "attr-defined": 5 }, "app/modules/_base/mediaserver.py": { "arg-type": 2, "assignment": 3, "attr-defined": 5, "no-any-return": 1, - "no-untyped-call": 1, "no-untyped-def": 2, "var-annotated": 1 }, "app/modules/_base/notification.py": { "arg-type": 1, "assignment": 1, - "no-untyped-call": 1, "no-untyped-def": 3, "type-arg": 5 }, "app/modules/acoustid/__init__.py": { "arg-type": 1, - "list-item": 1, - "no-untyped-call": 1 + "list-item": 1 }, "app/modules/anilist/__init__.py": { "assignment": 4, - "no-untyped-call": 1, "no-untyped-def": 5, "return-value": 1, "type-arg": 12 @@ -1442,7 +1437,6 @@ "app/modules/bangumi/__init__.py": { "arg-type": 2, "assignment": 4, - "no-untyped-call": 1, "no-untyped-def": 5, "return-value": 1, "type-arg": 7 @@ -1452,9 +1446,7 @@ }, "app/modules/dingtalk/__init__.py": { "arg-type": 2, - "no-untyped-call": 1, - "no-untyped-def": 1, - "override": 1 + "no-untyped-def": 1 }, "app/modules/dingtalk/dingtalk.py": { "no-untyped-def": 1 @@ -1466,7 +1458,6 @@ "empty-body": 1, "misc": 1, "no-any-return": 1, - "no-untyped-call": 1, "no-untyped-def": 1, "truthy-function": 1, "type-arg": 8 @@ -1487,7 +1478,7 @@ "empty-body": 1, "misc": 2, "no-any-return": 1, - "no-untyped-call": 4, + "no-untyped-call": 3, "no-untyped-def": 7, "return-value": 2, "type-arg": 13, @@ -1512,7 +1503,6 @@ "arg-type": 3, "assignment": 13, "empty-body": 1, - "no-untyped-call": 1, "no-untyped-def": 1, "type-arg": 1 }, @@ -1530,7 +1520,6 @@ "app/modules/fanart/__init__.py": { "misc": 2, "no-any-return": 1, - "no-untyped-call": 1, "no-untyped-def": 2, "type-arg": 10, "var-annotated": 1 @@ -1539,7 +1528,6 @@ "arg-type": 4, "assignment": 6, "attr-defined": 1, - "no-untyped-call": 1, "no-untyped-def": 1, "return-value": 1, "type-arg": 5, @@ -1561,7 +1549,7 @@ "attr-defined": 1, "empty-body": 1, "no-any-return": 2, - "no-untyped-call": 3, + "no-untyped-call": 2, "no-untyped-def": 5, "type-arg": 3, "var-annotated": 3 @@ -1668,7 +1656,7 @@ "assignment": 4, "empty-body": 1, "index": 1, - "no-untyped-call": 2, + "no-untyped-call": 1, "no-untyped-def": 3, "operator": 2, "return": 1, @@ -1678,9 +1666,7 @@ "app/modules/imdb/__init__.py": { "assignment": 10, "no-any-return": 1, - "no-untyped-call": 1, "no-untyped-def": 3, - "override": 1, "type-arg": 5 }, "app/modules/imdb/api.py": { @@ -1694,7 +1680,7 @@ "attr-defined": 1, "empty-body": 1, "no-any-return": 2, - "no-untyped-call": 4, + "no-untyped-call": 3, "no-untyped-def": 1, "return-value": 5, "type-arg": 19, @@ -1913,7 +1899,6 @@ "arg-type": 3, "assignment": 13, "empty-body": 1, - "no-untyped-call": 1, "no-untyped-def": 1, "type-arg": 1 }, @@ -1932,22 +1917,17 @@ }, "app/modules/listenbrainz/__init__.py": { "misc": 2, - "no-any-return": 2, - "no-untyped-call": 1, - "override": 1 + "no-any-return": 2 }, "app/modules/lrclib/__init__.py": { - "misc": 1, - "no-untyped-call": 1, - "override": 1 + "misc": 1 }, "app/modules/musicbrainz/__init__.py": { "arg-type": 1, "assignment": 5, "misc": 4, - "no-untyped-call": 4, + "no-untyped-call": 3, "no-untyped-def": 2, - "override": 1, "type-arg": 2 }, "app/modules/musicbrainz/cache.py": { @@ -1958,13 +1938,10 @@ "union-attr": 1 }, "app/modules/musixmatch/__init__.py": { - "no-any-return": 1, - "no-untyped-call": 1 + "no-any-return": 1 }, "app/modules/navidrome/__init__.py": { "arg-type": 9, - "no-untyped-call": 1, - "override": 1, "type-arg": 1, "union-attr": 1 }, @@ -1976,7 +1953,7 @@ "arg-type": 7, "assignment": 16, "empty-body": 1, - "no-untyped-call": 2, + "no-untyped-call": 1, "no-untyped-def": 5, "type-arg": 1 }, @@ -1994,7 +1971,6 @@ }, "app/modules/postgresql/__init__.py": { "empty-body": 1, - "no-untyped-call": 1, "no-untyped-def": 1 }, "app/modules/qbittorrent/__init__.py": { @@ -2002,7 +1978,6 @@ "assignment": 16, "empty-body": 1, "no-redef": 2, - "no-untyped-call": 1, "operator": 1, "str-bytes-safe": 2, "type-arg": 8, @@ -2029,7 +2004,6 @@ "attr-defined": 1, "empty-body": 1, "no-any-return": 1, - "no-untyped-call": 1, "no-untyped-def": 1, "type-arg": 4, "var-annotated": 1 @@ -2042,7 +2016,7 @@ }, "app/modules/redis/__init__.py": { "empty-body": 1, - "no-untyped-call": 2, + "no-untyped-call": 1, "no-untyped-def": 1 }, "app/modules/rtorrent/__init__.py": { @@ -2050,7 +2024,6 @@ "assignment": 16, "empty-body": 1, "no-redef": 1, - "no-untyped-call": 1, "no-untyped-def": 1, "operator": 1, "str-bytes-safe": 2, @@ -2072,7 +2045,6 @@ "attr-defined": 1, "empty-body": 1, "index": 1, - "no-untyped-call": 1, "no-untyped-def": 1, "type-arg": 7 }, @@ -2089,7 +2061,6 @@ "app/modules/subtitle/__init__.py": { "arg-type": 1, "empty-body": 1, - "no-untyped-call": 1, "no-untyped-def": 2, "var-annotated": 1 }, @@ -2099,7 +2070,6 @@ "attr-defined": 1, "empty-body": 1, "no-any-return": 1, - "no-untyped-call": 1, "no-untyped-def": 2, "type-arg": 4, "var-annotated": 1 @@ -2115,7 +2085,6 @@ "assignment": 9, "attr-defined": 1, "empty-body": 1, - "no-untyped-call": 1, "no-untyped-def": 1, "type-arg": 11, "union-attr": 1 @@ -2135,9 +2104,7 @@ "app/modules/theaudiodb/__init__.py": { "assignment": 4, "misc": 2, - "no-untyped-call": 1, - "no-untyped-def": 2, - "override": 1 + "no-untyped-def": 2 }, "app/modules/themoviedb/__init__.py": { "arg-type": 5, @@ -2145,7 +2112,7 @@ "empty-body": 1, "index": 2, "no-any-return": 4, - "no-untyped-call": 6, + "no-untyped-call": 5, "no-untyped-def": 3, "return-value": 2, "type-arg": 19, @@ -2291,7 +2258,7 @@ "app/modules/thetvdb/__init__.py": { "empty-body": 1, "no-any-return": 2, - "no-untyped-call": 2, + "no-untyped-call": 1, "no-untyped-def": 4, "type-arg": 2 }, @@ -2311,7 +2278,7 @@ "empty-body": 1, "no-any-return": 1, "no-redef": 2, - "no-untyped-call": 10, + "no-untyped-call": 9, "no-untyped-def": 7, "str-bytes-safe": 1, "type-arg": 6 @@ -2333,7 +2300,7 @@ "assignment": 1, "empty-body": 1, "no-any-return": 3, - "no-untyped-call": 2, + "no-untyped-call": 1, "no-untyped-def": 7, "return": 1, "type-arg": 2 @@ -2363,7 +2330,6 @@ "assignment": 1, "empty-body": 1, "no-any-return": 3, - "no-untyped-call": 1, "no-untyped-def": 7, "type-arg": 2 }, @@ -2383,7 +2349,6 @@ "assignment": 6, "attr-defined": 1, "empty-body": 1, - "no-untyped-call": 1, "no-untyped-def": 3, "type-arg": 4, "union-attr": 1 @@ -2398,7 +2363,6 @@ "arg-type": 1, "assignment": 1, "empty-body": 1, - "no-untyped-call": 1, "no-untyped-def": 2, "type-arg": 1, "union-attr": 1 @@ -2409,7 +2373,7 @@ "attr-defined": 1, "empty-body": 1, "no-any-return": 2, - "no-untyped-call": 3, + "no-untyped-call": 2, "no-untyped-def": 5, "type-arg": 4, "union-attr": 4 @@ -2435,7 +2399,6 @@ "assignment": 3, "empty-body": 1, "no-any-return": 1, - "no-untyped-call": 1, "no-untyped-def": 4, "type-arg": 2, "union-attr": 1 @@ -2453,7 +2416,6 @@ "arg-type": 3, "assignment": 12, "empty-body": 1, - "no-untyped-call": 1, "no-untyped-def": 2, "type-arg": 1 }, @@ -2653,7 +2615,7 @@ "app/runtime/reload.py": { "misc": 1, "no-untyped-call": 5, - "no-untyped-def": 6 + "no-untyped-def": 5 }, "app/runtime/scheduling.py": { "import-untyped": 1, @@ -2668,7 +2630,6 @@ "app/runtime/state.py": { "attr-defined": 2, "no-any-return": 2, - "no-untyped-call": 1, "no-untyped-def": 3, "type-arg": 1 },