diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index 0dffd8f31..efa8acce5 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -30,10 +30,8 @@ from requests import Response from app.runtime.cache import cached, is_fresh from app.runtime.config import settings -from app.db.oper.systemconfig import SystemConfigOper from app.adapters.system.package import PackageInstallRequest, build_package_install_strategies from app.runtime.log import logger -from app.schemas.types import SystemConfigKey from app.adapters.network.http import RequestUtils, AsyncRequestUtils from app.foundation.singleton import WeakSingleton @@ -59,6 +57,24 @@ VERSION_BACKWARD_COMPATIBLE_FLAGS: Dict[str, List[str]] = { "v3": ["v2"], } +InstalledPluginsProvider = Callable[[], List[str]] + + +def _empty_installed_plugins() -> List[str]: + """组合根尚未注入配置读取器时返回空安装清单。""" + return [] + + +_installed_plugins_provider: InstalledPluginsProvider = _empty_installed_plugins + + +def configure_installed_plugins_provider( + provider: InstalledPluginsProvider, +) -> None: + """由启动组合层注入已安装插件读取器,避免市场适配器访问数据库。""" + global _installed_plugins_provider + _installed_plugins_provider = provider + def normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]: """规范化插件仓库地址,便于跨来源合并去重。""" @@ -165,10 +181,6 @@ class PluginHelper(metaclass=WeakSingleton): "sqlalchemy, starlette, uvicorn; from pydantic import BaseModel, Field" ) - def __init__(self): - """初始化插件仓库配置访问器。""" - self.systemconfig = SystemConfigOper() - @staticmethod def is_local_repo_url(repo_url: Optional[str]) -> bool: """ @@ -1173,7 +1185,7 @@ class PluginHelper(metaclass=WeakSingleton): try: install_plugins = { plugin_id.lower() - for plugin_id in self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or [] + for plugin_id in _installed_plugins_provider() or [] } for plugin_id in install_plugins: wheels_dir = PLUGIN_DIR / plugin_id / "wheels" @@ -2099,68 +2111,26 @@ class PluginHelper(metaclass=WeakSingleton): return False, f"解压 Release 压缩包失败:{e}" def find_missing_dependencies(self) -> List[str]: - """ - 收集所有需要安装或更新的依赖项 - 1. 收集所有插件的依赖项,合并版本约束 - 2. 获取已安装的包及其版本 - 3. 比较已安装的包与所需的依赖项,找出需要安装或升级的包 - :return: 需要安装或更新的依赖项列表,例如 ["package1>=1.0.0", "package2"] - """ - try: - # 收集所有插件的依赖项 - plugin_dependencies = self.__find_plugin_dependencies() # 返回格式为 {package_name: version_specifier} - # 获取已安装的包及其版本 - installed_packages = self.__get_installed_packages() # 返回格式为 {package_name: Version} - # 需要安装或更新的依赖项列表 - dependencies_to_install = [] - for pkg_name, version_specifier in plugin_dependencies.items(): - spec_set = SpecifierSet(version_specifier) - installed_version = installed_packages.get(pkg_name) - if installed_version is None: - # 包未安装,需要安装 - if version_specifier: - dependencies_to_install.append(f"{pkg_name}{version_specifier}") - else: - dependencies_to_install.append(pkg_name) - elif not spec_set.contains(installed_version, prereleases=True): - # 已安装的版本不满足版本约束,需要升级或降级 - if version_specifier: - dependencies_to_install.append(f"{pkg_name}{version_specifier}") - else: - dependencies_to_install.append(pkg_name) - # 已安装的版本满足要求,无需操作 - return dependencies_to_install - except Exception as e: - logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{e}") - return [] + """兼容旧市场入口,转发到独立依赖适配器。""" + installer = importlib.import_module( + "app.adapters.system.plugin.dependency" + ).PluginDependencyInstaller + return installer( + self, + installed_plugins_provider=_installed_plugins_provider, + plugin_dir=PLUGIN_DIR, + ).find_missing() def install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]: - """ - 安装指定的依赖项列表 - :param dependencies: 需要安装或更新的依赖项列表 - :return: (success, message) - """ - if not dependencies: - return False, "没有传入需要安装的依赖项" - - try: - logger.debug(f"需要安装或更新的依赖项:{dependencies}") - # 创建临时的 requirements.txt 文件用于批量安装 - requirements_temp_file = Path(settings.TEMP_PATH) / "plugin_dependencies" / "requirements.txt" - requirements_temp_file.parent.mkdir(parents=True, exist_ok=True) - with open(requirements_temp_file, "w", encoding="utf-8") as f: - for dep in dependencies: - f.write(dep + "\n") - try: - # 使用自动降级策略安装依赖 - wheels_dirs = self.__collect_plugin_wheels_dirs() - return self.pip_install_with_fallback(requirements_temp_file, wheels_dirs) - finally: - # 删除临时文件 - requirements_temp_file.unlink() - except Exception as e: - logger.error(f"安装依赖项时发生错误:{e}") - return False, f"安装依赖项时发生错误:{e}" + """兼容旧市场入口,转发到独立依赖适配器。""" + installer = importlib.import_module( + "app.adapters.system.plugin.dependency" + ).PluginDependencyInstaller + return installer( + self, + installed_plugins_provider=_installed_plugins_provider, + plugin_dir=PLUGIN_DIR, + ).install(dependencies) @classmethod def __get_installed_packages(cls) -> Dict[str, Version]: @@ -2203,9 +2173,7 @@ class PluginHelper(metaclass=WeakSingleton): try: install_plugins = { plugin_id.lower() # 对应插件的小写目录名 - for plugin_id in SystemConfigOper().get( - SystemConfigKey.UserInstalledPlugins - ) or [] + for plugin_id in _installed_plugins_provider() or [] } for plugin_dir in PLUGIN_DIR.iterdir(): if plugin_dir.is_dir(): @@ -2739,34 +2707,15 @@ class PluginHelper(metaclass=WeakSingleton): return False, False, "不存在依赖" async def async_install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]: - """ - 异步安装指定的依赖项列表 - :param dependencies: 需要安装或更新的依赖项列表 - :return: (success, message) - """ - if not dependencies: - return False, "没有传入需要安装的依赖项" - - try: - logger.debug(f"需要安装或更新的依赖项:{dependencies}") - # 创建临时的 requirements.txt 文件用于批量安装 - requirements_temp_file = AsyncPath(settings.TEMP_PATH) / "plugin_dependencies" / "requirements.txt" - await requirements_temp_file.parent.mkdir(parents=True, exist_ok=True) - - async with aiofiles.open(requirements_temp_file, "w", encoding="utf-8") as f: - for dep in dependencies: - await f.write(dep + "\n") - - try: - # 使用自动降级策略安装依赖 - wheels_dirs = self.__collect_plugin_wheels_dirs() - return await self.__async_pip_install_with_fallback(Path(requirements_temp_file), wheels_dirs) - finally: - # 删除临时文件 - await requirements_temp_file.unlink() - except Exception as e: - logger.error(f"安装依赖项时发生错误:{e}") - return False, f"安装依赖项时发生错误:{e}" + """兼容旧异步市场入口,转发到独立依赖适配器。""" + installer = importlib.import_module( + "app.adapters.system.plugin.dependency" + ).PluginDependencyInstaller + return await installer( + self, + installed_plugins_provider=_installed_plugins_provider, + plugin_dir=PLUGIN_DIR, + ).async_install(dependencies) async def __async_find_plugin_dependencies(self) -> Dict[str, str]: """ @@ -2779,9 +2728,7 @@ class PluginHelper(metaclass=WeakSingleton): try: install_plugins = { plugin_id.lower() # 对应插件的小写目录名 - for plugin_id in SystemConfigOper().get( - SystemConfigKey.UserInstalledPlugins - ) or [] + for plugin_id in _installed_plugins_provider() or [] } plugin_dir_path = AsyncPath(PLUGIN_DIR) @@ -2838,40 +2785,15 @@ class PluginHelper(metaclass=WeakSingleton): return {} async def async_find_missing_dependencies(self) -> List[str]: - """ - 异步收集所有需要安装或更新的依赖项 - 1. 收集所有插件的依赖项,合并版本约束 - 2. 获取已安装的包及其版本 - 3. 比较已安装的包与所需的依赖项,找出需要安装或升级的包 - :return: 需要安装或更新的依赖项列表,例如 ["package1>=1.0.0", "package2"] - """ - try: - # 收集所有插件的依赖项 - plugin_dependencies = await self.__async_find_plugin_dependencies() # 返回格式为 {package_name: version_specifier} - # 获取已安装的包及其版本 - installed_packages = self.__get_installed_packages() # 返回格式为 {package_name: Version} - # 需要安装或更新的依赖项列表 - dependencies_to_install = [] - for pkg_name, version_specifier in plugin_dependencies.items(): - spec_set = SpecifierSet(version_specifier) - installed_version = installed_packages.get(pkg_name) - if installed_version is None: - # 包未安装,需要安装 - if version_specifier: - dependencies_to_install.append(f"{pkg_name}{version_specifier}") - else: - dependencies_to_install.append(pkg_name) - elif not spec_set.contains(installed_version, prereleases=True): - # 已安装的版本不满足版本约束,需要升级或降级 - if version_specifier: - dependencies_to_install.append(f"{pkg_name}{version_specifier}") - else: - dependencies_to_install.append(pkg_name) - # 已安装的版本满足要求,无需操作 - return dependencies_to_install - except Exception as e: - logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{e}") - return [] + """兼容旧异步市场入口,转发到独立依赖适配器。""" + installer = importlib.import_module( + "app.adapters.system.plugin.dependency" + ).PluginDependencyInstaller + return await installer( + self, + installed_plugins_provider=_installed_plugins_provider, + plugin_dir=PLUGIN_DIR, + ).async_find_missing() async def async_install(self, pid: str, repo_url: str, package_version: Optional[str] = None, release_version: Optional[str] = None, diff --git a/app/adapters/external/plugin/__init__.py b/app/adapters/external/plugin/__init__.py new file mode 100644 index 000000000..e7bde04b0 --- /dev/null +++ b/app/adapters/external/plugin/__init__.py @@ -0,0 +1 @@ +"""插件市场外部适配器。""" diff --git a/app/adapters/external/plugin/client.py b/app/adapters/external/plugin/client.py new file mode 100644 index 000000000..7b85569a5 --- /dev/null +++ b/app/adapters/external/plugin/client.py @@ -0,0 +1,95 @@ +"""插件市场查询客户端。""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional + +from app.adapters.external.market import PluginHelper as _PluginHelper +from app.runtime.cache import async_fresh, fresh + + +class PluginMarketClient: + """把插件市场、版本元数据和本地仓库查询隔离为只读客户端。""" + + def __init__(self, helper: Optional[_PluginHelper] = None) -> None: + """复用旧 PluginHelper 实现,保持缓存和弱单例身份不变。""" + self._helper = helper or _PluginHelper() + + def get_plugins( + self, + repo_url: str, + package_version: Optional[str] = None, + force: bool = False, + ) -> Optional[dict[str, dict]]: + """同步读取指定仓库和代际的插件索引。""" + with fresh(force): + return self._helper.get_plugins(repo_url, package_version) + + async def async_get_plugins( + self, + repo_url: str, + package_version: Optional[str] = None, + force: bool = False, + ) -> Optional[dict[str, dict]]: + """异步读取指定仓库和代际的插件索引。""" + async with async_fresh(force): + return await self._helper.async_get_plugins(repo_url, package_version) + + def get_local_candidates(self) -> dict[str, dict]: + """返回全部本地插件仓库候选。""" + return self._helper.get_local_plugin_candidates() + + def get_local_candidate( + self, + plugin_id: str, + package_version: Optional[str] = None, + repo_path: Optional[Path] = None, + **kwargs: Any, + ) -> Optional[dict]: + """返回指定插件的本地仓库候选。""" + return self._helper.get_local_plugin_candidate( + pid=plugin_id, + package_version=package_version, + repo_path=repo_path, + **kwargs, + ) + + @staticmethod + def get_local_repo_paths() -> list[Path]: + """返回配置中有效的本地插件仓库目录。""" + return _PluginHelper.get_local_repo_paths() + + @staticmethod + def make_local_repo_url( + plugin_id: str, + repo_path: Optional[object] = None, + package_version: Optional[str] = None, + ) -> str: + """生成兼容旧入口的本地插件来源标识。""" + return _PluginHelper.make_local_repo_url( + plugin_id, + repo_path, + package_version, + ) + + @staticmethod + def is_local_repo_url(repo_url: Optional[str]) -> bool: + """判断插件来源是否为本地仓库标识。""" + return _PluginHelper.is_local_repo_url(repo_url) + + @staticmethod + def annotate_system_version(plugin_info: dict) -> dict: + """补充插件所需 MoviePilot 版本兼容状态。""" + return _PluginHelper.annotate_plugin_system_version(plugin_info) + + @staticmethod + def is_package_compatible( + plugin_info: dict, + package_version: Optional[str], + ) -> bool: + """判断插件条目是否兼容目标插件包代际。""" + return _PluginHelper.is_package_plugin_compatible( + plugin_info, + package_version, + ) diff --git a/app/adapters/external/server.py b/app/adapters/external/server.py index ecc67f365..f94909151 100644 --- a/app/adapters/external/server.py +++ b/app/adapters/external/server.py @@ -9,9 +9,6 @@ from app.runtime.cache import cached from app.runtime.config import settings from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metabase import MetaBase -from app.db.oper.subscribe import SubscribeOper -from app.db.oper.systemconfig import SystemConfigOper -from app.db.oper.workflow import WorkflowOper from app.runtime.log import logger from app.schemas.types import ( MUSIC_ENTITY_RECORDING, @@ -26,6 +23,21 @@ from app.adapters.system.host import SystemUtils from version import APP_VERSION, FRONTEND_VERSION +_server_report_service: Any = None +_server_sharing_service: Any = None + + +def configure_server_application_services( + *, + report_service: Any, + sharing_service: Any, +) -> None: + """由启动组合根注入分享和存量上报应用服务。""" + global _server_report_service, _server_sharing_service + _server_report_service = report_service + _server_sharing_service = sharing_service + + class MoviePilotServerHelper: """ MoviePilot 服务端请求辅助工具。 @@ -51,22 +63,23 @@ class MoviePilotServerHelper: _RECOGNIZE_SHARE_PATH = "/recognize/share" _USER_PERMISSIONS_PATH = "/user/permissions" _LOCAL_REPO_PREFIX = "local://" - _SUBSCRIBE_STATISTIC_FIELDS = frozenset({ - "name", "year", "type", "media_source", "media_id", "music_type", - "total_tracks", "genre_ids", "season", "poster", "backdrop", "vote", - "description", - }) - _SUBSCRIBE_SHARE_FIELDS = frozenset({ - "share_title", "share_comment", "share_user", "share_uid", "name", - "year", "type", "keyword", "media_source", "media_id", "music_type", - "total_tracks", "season", "poster", "backdrop", "vote", "description", - "genre_ids", "include", "exclude", "quality", "resolution", "effect", - "total_episode", "custom_words", "media_category", "episode_group", - "date", - }) _user_uid: Optional[str] = None _github_user: Optional[str] = None + @classmethod + def _report_service(cls) -> Any: + """返回启动组合根注入的存量上报应用服务。""" + if _server_report_service is None: + raise RuntimeError("中心服务上报用例尚未由启动组合根装配") + return _server_report_service + + @classmethod + def _sharing_service(cls) -> Any: + """返回启动组合根注入的订阅和工作流分享应用服务。""" + if _server_sharing_service is None: + raise RuntimeError("中心服务分享用例尚未由启动组合根装配") + return _server_sharing_service + @classmethod def get_user_uid(cls) -> Optional[str]: """ @@ -334,22 +347,22 @@ class MoviePilotServerHelper: """ 初始化订阅统计上报状态。 """ - systemconfig = SystemConfigOper() - if settings.SUBSCRIBE_STATISTIC_SHARE: - if not systemconfig.get(SystemConfigKey.SubscribeReport): - if cls.sub_report(): - systemconfig.set(SystemConfigKey.SubscribeReport, "1") + cls._report_service().init_report( + enabled=settings.SUBSCRIBE_STATISTIC_SHARE, + state_key=SystemConfigKey.SubscribeReport, + reporter=cls.sub_report, + ) @classmethod def init_plugin_report(cls) -> None: """ 初始化插件安装统计上报状态。 """ - systemconfig = SystemConfigOper() - if settings.PLUGIN_STATISTIC_SHARE: - if not systemconfig.get(SystemConfigKey.PluginInstallReport): - if cls.install_plugin_report(): - systemconfig.set(SystemConfigKey.PluginInstallReport, "1") + cls._report_service().init_report( + enabled=settings.PLUGIN_STATISTIC_SHARE, + state_key=SystemConfigKey.PluginInstallReport, + reporter=cls.install_plugin_report, + ) @staticmethod def _handle_list_response(res) -> List[dict]: @@ -599,26 +612,20 @@ class MoviePilotServerHelper: """ 批量上报存量插件安装统计。 """ - if not settings.PLUGIN_STATISTIC_SHARE: - return False - payload_plugins = cls._build_plugin_report_payload(items) - if not payload_plugins: - return False - res = cls.plugin_install_report(payload_plugins) - return bool(res is not None and res.status_code == 200) + return cls._report_service().report_plugins( + enabled=settings.PLUGIN_STATISTIC_SHARE, + items=items, + ) @classmethod async def async_install_plugin_report(cls, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool: """ 异步批量上报存量插件安装统计。 """ - if not settings.PLUGIN_STATISTIC_SHARE: - return False - payload_plugins = cls._build_plugin_report_payload(items) - if not payload_plugins: - return False - res = await cls.async_plugin_install_report(payload_plugins) - return bool(res is not None and res.status_code == 200) + return await cls._report_service().async_report_plugins( + enabled=settings.PLUGIN_STATISTIC_SHARE, + items=items, + ) @classmethod def subscribe_statistic(cls, params: Dict[str, Any]): @@ -888,20 +895,9 @@ class MoviePilotServerHelper: """ 上报存量订阅统计。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: - return False - subscribes = SubscribeOper().list() - if not subscribes: - return True - payloads = [ - payload - for sub in subscribes - if (payload := cls._build_subscribe_statistic_payload(sub.to_dict())) - ] - if not payloads: - return True - res = cls.subscribe_report(payloads) - return bool(res is not None and res.status_code == 200) + return cls._report_service().report_subscribes( + enabled=settings.SUBSCRIBE_STATISTIC_SHARE, + ) @classmethod def sub_share( @@ -914,21 +910,13 @@ class MoviePilotServerHelper: """ 分享订阅。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: - return False, "当前没有开启订阅数据共享功能" - subscribe = SubscribeOper().get(subscribe_id) - if not subscribe: - return False, "订阅不存在" - payload = cls._build_subscribe_share_payload({ - "share_title": share_title, - "share_comment": share_comment, - "share_user": share_user, - "share_uid": cls.get_user_uuid(), - **subscribe.to_dict(), - }) - if not payload: - return False, "订阅媒体身份不完整" - return cls._handle_response(cls.subscribe_share(payload), cls._clear_subscribe_share_cache) + return cls._sharing_service().share_subscribe( + enabled=settings.SUBSCRIBE_STATISTIC_SHARE, + subscribe_id=subscribe_id, + share_title=share_title, + share_comment=share_comment, + share_user=share_user, + ) @classmethod async def async_sub_share( @@ -941,23 +929,12 @@ class MoviePilotServerHelper: """ 异步分享订阅。 """ - if not settings.SUBSCRIBE_STATISTIC_SHARE: - return False, "当前没有开启订阅数据共享功能" - subscribe = await SubscribeOper().async_get(subscribe_id) - if not subscribe: - return False, "订阅不存在" - payload = cls._build_subscribe_share_payload({ - "share_title": share_title, - "share_comment": share_comment, - "share_user": share_user, - "share_uid": cls.get_user_uuid(), - **subscribe.to_dict(), - }) - if not payload: - return False, "订阅媒体身份不完整" - return cls._handle_response( - await cls.async_subscribe_share(payload), - cls._clear_subscribe_share_cache, + return await cls._sharing_service().async_share_subscribe( + enabled=settings.SUBSCRIBE_STATISTIC_SHARE, + subscribe_id=subscribe_id, + share_title=share_title, + share_comment=share_comment, + share_user=share_user, ) @classmethod @@ -965,38 +942,14 @@ class MoviePilotServerHelper: cls, item: Optional[dict] ) -> Optional[dict]: """构造中心服务订阅统计载荷,只保留统一身份和公开统计字段。""" - if not isinstance(item, dict): - return None - media_source, media_id = resolve_media_identity(media=item) - if not media_source or not media_id: - return None - payload = { - key: value - for key, value in item.items() - if key in cls._SUBSCRIBE_STATISTIC_FIELDS - } - payload["media_source"] = str(media_source) - payload["media_id"] = media_id - return payload + return cls._report_service().build_subscribe_payload(item) @classmethod def _build_subscribe_share_payload( cls, item: Optional[dict] ) -> Optional[dict]: """构造中心服务订阅分享载荷,隔离本地运行字段和旧专用 ID。""" - if not isinstance(item, dict): - return None - media_source, media_id = resolve_media_identity(media=item) - if not media_source or not media_id: - return None - payload = { - key: value - for key, value in item.items() - if key in cls._SUBSCRIBE_SHARE_FIELDS - } - payload["media_source"] = str(media_source) - payload["media_id"] = media_id - return payload + return cls._sharing_service().build_subscribe_payload(item) @classmethod def share_delete(cls, share_id: int) -> Tuple[bool, str]: @@ -1180,17 +1133,12 @@ class MoviePilotServerHelper: """ return await cls._async_get(cls._server_url(cls._WORKFLOW_SHARES_PATH), params=params, timeout=15) - @staticmethod - def _prepare_workflow_data(workflow) -> dict: + @classmethod + def _prepare_workflow_data(cls, workflow) -> dict: """ 准备工作流分享数据。 """ - workflow_dict = workflow.to_dict() - workflow_dict.pop("id", None) - workflow_dict.pop("context", None) - workflow_dict["actions"] = json.dumps(workflow_dict["actions"] or []) - workflow_dict["flows"] = json.dumps(workflow_dict["flows"] or []) - return workflow_dict + return cls._sharing_service().prepare_workflow(workflow) @classmethod def workflow_share_by_id( @@ -1203,20 +1151,13 @@ class MoviePilotServerHelper: """ 分享工作流。 """ - if not settings.WORKFLOW_STATISTIC_SHARE: - return False, "当前没有开启工作流数据共享功能" - workflow = WorkflowOper().get(workflow_id) - valid, message = cls._validate_workflow(workflow) - if not valid: - return False, message - payload = { - "share_title": share_title, - "share_comment": share_comment, - "share_user": share_user, - "share_uid": cls.get_user_uuid(), - **cls._prepare_workflow_data(workflow), - } - return cls._handle_response(cls.workflow_share(payload), cls._clear_workflow_share_cache) + return cls._sharing_service().share_workflow( + enabled=settings.WORKFLOW_STATISTIC_SHARE, + workflow_id=workflow_id, + share_title=share_title, + share_comment=share_comment, + share_user=share_user, + ) @classmethod async def async_workflow_share_by_id( @@ -1229,22 +1170,12 @@ class MoviePilotServerHelper: """ 异步分享工作流。 """ - if not settings.WORKFLOW_STATISTIC_SHARE: - return False, "当前没有开启工作流数据共享功能" - workflow = await WorkflowOper().async_get(workflow_id) - valid, message = cls._validate_workflow(workflow) - if not valid: - return False, message - payload = { - "share_title": share_title, - "share_comment": share_comment, - "share_user": share_user, - "share_uid": cls.get_user_uuid(), - **cls._prepare_workflow_data(workflow), - } - return cls._handle_response( - await cls.async_workflow_share(payload), - cls._clear_workflow_share_cache, + return await cls._sharing_service().async_share_workflow( + enabled=settings.WORKFLOW_STATISTIC_SHARE, + workflow_id=workflow_id, + share_title=share_title, + share_comment=share_comment, + share_user=share_user, ) @classmethod @@ -1327,16 +1258,12 @@ class MoviePilotServerHelper: "count": count, })) - @staticmethod - def _validate_workflow(workflow) -> Tuple[bool, str]: + @classmethod + def _validate_workflow(cls, workflow) -> Tuple[bool, str]: """ 验证工作流是否可以分享。 """ - if not workflow: - return False, "工作流不存在" - if not workflow.actions or not workflow.flows: - return False, "请分享有动作和流程的工作流" - return True, "" + return cls._sharing_service().validate_workflow(workflow) @classmethod def recognize_share_url(cls) -> Optional[str]: @@ -1754,20 +1681,7 @@ class MoviePilotServerHelper: """ 构建批量插件安装统计载荷。 """ - if items: - return [ - { - "plugin_id": plugin_id, - "repo_url": cls.sanitize_plugin_repo_url(repo_url), - } - for plugin_id, repo_url in items - if plugin_id - ] - - plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) - if not plugins: - return [] - return [{"plugin_id": plugin, "repo_url": None} for plugin in plugins] + return cls._report_service().build_plugin_payload(items) @classmethod def _parse_local_repo_plugin_id(cls, repo_url: str) -> Optional[str]: diff --git a/app/adapters/system/host.py b/app/adapters/system/host.py index 5fd5bd97b..dac1ed04f 100644 --- a/app/adapters/system/host.py +++ b/app/adapters/system/host.py @@ -21,7 +21,9 @@ except ImportError: import psutil -from app import schemas +from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo +from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo +from app.schemas.dashboard import ProcessInfo as _SchemaProcessInfo from version import APP_VERSION @@ -674,7 +676,7 @@ class SystemUtils: return psutil.disk_usage(str(path)).total @staticmethod - def processes() -> List[schemas.ProcessInfo]: + def processes() -> List[_SchemaProcessInfo]: """ 获取所有进程 """ @@ -687,7 +689,7 @@ class SystemUtils: mem_info = getattr(proc, 'memory_info', None)() if mem_info is not None: mem_mb = round(mem_info.rss / (1024 * 1024), 1) - processes.append(schemas.ProcessInfo( + processes.append(_SchemaProcessInfo( pid=proc.pid, name=proc.name(), run_time=runtime.seconds, memory=mem_mb )) except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): @@ -695,14 +697,14 @@ class SystemUtils: return processes @staticmethod - def dashboard_system_info() -> schemas.DashboardSystemInfo: + def dashboard_system_info() -> _SchemaDashboardSystemInfo: """ 获取仪表板展示所需的系统摘要信息。 运行时间以当前 MoviePilot 进程为基准,避免宿主机或容器长期运行时间 掩盖服务最近一次重启。 """ - return schemas.DashboardSystemInfo( + return _SchemaDashboardSystemInfo( hostname=socket.gethostname(), operating_system=SystemUtils._operating_system_name(), runtime=max(0, int(time.time() - psutil.Process().create_time())), @@ -761,7 +763,7 @@ class SystemUtils: return psutil.cpu_percent() @staticmethod - def memory_usage() -> schemas.DashboardMemoryInfo: + def memory_usage() -> _SchemaDashboardMemoryInfo: """ 获取当前 MoviePilot 进程内存与系统缓存、可用和总内存信息。 """ @@ -775,7 +777,7 @@ class SystemUtils: ) available = max(0, int(memory.available)) usage = used / total * 100 if total else 0.0 - return schemas.DashboardMemoryInfo( + return _SchemaDashboardMemoryInfo( total=total, used=used, cached=cached, diff --git a/app/adapters/system/plugin/__init__.py b/app/adapters/system/plugin/__init__.py new file mode 100644 index 000000000..867bf64ce --- /dev/null +++ b/app/adapters/system/plugin/__init__.py @@ -0,0 +1 @@ +"""插件包和依赖系统适配器。""" diff --git a/app/adapters/system/plugin/dependency.py b/app/adapters/system/plugin/dependency.py new file mode 100644 index 000000000..03d370ec1 --- /dev/null +++ b/app/adapters/system/plugin/dependency.py @@ -0,0 +1,206 @@ +"""插件 requirements 聚合和 Python 依赖安装适配器。""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from importlib.metadata import distributions +from pathlib import Path +from typing import Any, Optional + +from packaging.requirements import Requirement +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version + +from app.runtime.config import settings +from app.runtime.log import logger + + +class PluginDependencyInstaller: + """独立负责插件依赖扫描、约束合并和 pip 安装。""" + + def __init__( + self, + helper: Any = None, + *, + installed_plugins_provider: Optional[Callable[[], list[str]]] = None, + plugin_dir: Optional[Path] = None, + ) -> None: + """保存 pip 端口和启动层提供的已安装插件读取器。""" + if helper is None: + from app.adapters.external.market import PluginHelper + + helper = PluginHelper() + self._helper = helper + self._installed_plugins_provider = installed_plugins_provider or (lambda: []) + self._plugin_dir = plugin_dir or ( + Path(settings.ROOT_PATH) / "app" / "plugins" + ) + + @staticmethod + def _standardize(name: str) -> str: + """按 PEP 503 兼容规则标准化依赖包名。""" + return (name or "").lower().replace("-", "_").replace(".", "_") + + @classmethod + def _installed_packages(cls) -> dict[str, Version]: + """读取当前 Python 环境中可解析版本的已安装包。""" + installed: dict[str, Version] = {} + try: + for distribution in distributions(): + name = distribution.metadata.get("Name") + version = distribution.metadata.get("Version") or getattr( + distribution, + "version", + None, + ) + if not name or not version: + continue + package_name = cls._standardize(name) + try: + parsed = Version(version) + except InvalidVersion: + logger.debug( + f"无法解析已安装包 '{package_name}' 的版本:{version}" + ) + continue + if package_name not in installed or parsed > installed[package_name]: + installed[package_name] = parsed + except Exception as err: + logger.error(f"获取已安装的包时发生错误:{err}") + return installed + + @classmethod + def _parse_requirements(cls, requirements_file: Path) -> dict[str, list[str]]: + """解析一个 requirements 文件中的包名和版本约束。""" + dependencies: dict[str, list[str]] = {} + try: + for line in requirements_file.read_text( + encoding="utf-8", + errors="replace", + ).splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + try: + requirement = Requirement(line) + except Exception as err: + logger.debug(f"无法解析依赖项 '{line}':{err}") + continue + package_name = cls._standardize(requirement.name) + dependencies.setdefault(package_name, []).append( + str(requirement.specifier) + ) + except Exception as err: + logger.error(f"解析 requirements.txt 时发生错误:{err}") + return dependencies + + @classmethod + def _merge(cls, dependencies: dict[str, set[str]]) -> dict[str, str]: + """求同一包多来源约束的交集,保留冲突约束供 pip 处理。""" + merged: dict[str, str] = {} + for package_name, specifiers in dependencies.items(): + spec_set = SpecifierSet() + for specifier in specifiers: + if not specifier: + continue + try: + spec_set &= SpecifierSet(specifier) + except InvalidSpecifier as err: + logger.error(f"发生版本约束冲突:{err}") + merged[package_name] = str(spec_set) if spec_set else "" + return merged + + def _plugin_dependencies(self) -> dict[str, str]: + """扫描已安装插件的 requirements 并合并版本约束。""" + dependencies: dict[str, set[str]] = {} + installed_plugins = { + plugin_id.lower() + for plugin_id in self._installed_plugins_provider() or [] + } + try: + plugin_dirs = list(self._plugin_dir.iterdir()) + except (FileNotFoundError, OSError): + return {} + for plugin_dir in plugin_dirs: + if not plugin_dir.is_dir(): + continue + requirements_file = plugin_dir / "requirements.txt" + if not requirements_file.is_file(): + continue + if plugin_dir.name not in installed_plugins: + logger.debug(f"忽略插件 {plugin_dir.name} 的依赖") + continue + for package_name, specifiers in self._parse_requirements( + requirements_file + ).items(): + dependencies.setdefault(package_name, set()).update(specifiers) + return self._merge(dependencies) + + def find_missing(self) -> list[str]: + """返回当前插件集合缺失或不满足约束的依赖项。""" + try: + required = self._plugin_dependencies() + installed = self._installed_packages() + missing = [] + for package_name, specifier in required.items(): + installed_version = installed.get(package_name) + try: + satisfied = installed_version is not None and SpecifierSet( + specifier + ).contains(installed_version, prereleases=True) + except InvalidSpecifier as err: + logger.error(f"依赖 {package_name} 约束无效:{err}") + satisfied = False + if not satisfied: + missing.append(f"{package_name}{specifier}") + return missing + except Exception as err: + logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{err}") + return [] + + def _wheels_dirs(self) -> list[Path]: + """收集已安装插件附带的本地 wheels 目录。""" + result = [] + installed_plugins = { + plugin_id.lower() + for plugin_id in self._installed_plugins_provider() or [] + } + for plugin_id in installed_plugins: + wheels_dir = self._plugin_dir / plugin_id / "wheels" + if wheels_dir.is_dir(): + result.append(wheels_dir) + return list(dict.fromkeys(result)) + + def install(self, dependencies: list[str]) -> tuple[bool, str]: + """把依赖写入临时 requirements 并调用现有 pip 健康检查策略。""" + if not dependencies: + return False, "没有传入需要安装的依赖项" + requirements_file = ( + Path(settings.TEMP_PATH) + / "plugin_dependencies" + / "requirements.txt" + ) + try: + requirements_file.parent.mkdir(parents=True, exist_ok=True) + requirements_file.write_text( + "".join(f"{dependency}\n" for dependency in dependencies), + encoding="utf-8", + ) + return self._helper.pip_install_with_fallback( + requirements_file, + self._wheels_dirs(), + ) + except Exception as err: + logger.error(f"安装依赖项时发生错误:{err}") + return False, f"安装依赖项时发生错误:{err}" + finally: + requirements_file.unlink(missing_ok=True) + + async def async_find_missing(self) -> list[str]: + """在线程池中扫描缺失依赖,避免阻塞事件循环。""" + return await asyncio.to_thread(self.find_missing) + + async def async_install(self, dependencies: list[str]) -> tuple[bool, str]: + """在线程池中安装依赖,复用同步 pip 健康检查策略。""" + return await asyncio.to_thread(self.install, dependencies) diff --git a/app/adapters/system/plugin/package.py b/app/adapters/system/plugin/package.py new file mode 100644 index 000000000..c8e9f362f --- /dev/null +++ b/app/adapters/system/plugin/package.py @@ -0,0 +1,374 @@ +"""插件包文件安装、快照恢复和分身处理适配器。""" + +from __future__ import annotations + +import asyncio +import re +import shutil +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from app.adapters.external.market import PluginHelper as _PluginHelper +from app.runtime.config import settings +from app.runtime.log import logger + + +@dataclass(frozen=True, slots=True) +class PluginPackageCheckpoint: + """记录一次插件包变更前可用于补偿恢复的文件快照。""" + + plugin_id: str + plugin_dir: Path + transaction_dir: Path + existed: bool + + +class PluginPackageManager: + """隔离插件包安装、本地同步、分身改写和文件补偿能力。""" + + _COPY_IGNORE = ("__pycache__", "*.pyc", ".DS_Store", "node_modules") + + def __init__(self, helper: Optional[_PluginHelper] = None) -> None: + """保存市场下载实现;文件事务由本适配器独立负责。""" + self._helper = helper or _PluginHelper() + + @staticmethod + def _plugin_dir(plugin_id: str) -> Path: + """解析插件运行目录并拒绝越出宿主插件根目录的标识。""" + plugins_root = (Path(settings.ROOT_PATH) / "app" / "plugins").resolve() + plugin_dir = (plugins_root / plugin_id.lower()).resolve() + if plugin_dir == plugins_root or not plugin_dir.is_relative_to(plugins_root): + raise ValueError(f"非法插件ID:{plugin_id}") + return plugin_dir + + def checkpoint(self, plugin_id: str) -> PluginPackageCheckpoint: + """在包变更前创建独立快照,供后续提交或补偿恢复。""" + plugin_dir = self._plugin_dir(plugin_id) + transaction_dir = ( + Path(settings.TEMP_PATH) + / "plugin_transactions" + / f"{plugin_id.lower()}-{uuid.uuid4().hex}" + ) + existed = plugin_dir.exists() + try: + transaction_dir.mkdir(parents=True, exist_ok=False) + if existed: + shutil.copytree(plugin_dir, transaction_dir / "package") + except Exception: + shutil.rmtree(transaction_dir, ignore_errors=True) + raise + return PluginPackageCheckpoint( + plugin_id=plugin_id, + plugin_dir=plugin_dir, + transaction_dir=transaction_dir, + existed=existed, + ) + + async def async_checkpoint(self, plugin_id: str) -> PluginPackageCheckpoint: + """在线程池中创建插件包文件快照。""" + return await asyncio.to_thread(self.checkpoint, plugin_id) + + @staticmethod + def commit(checkpoint: PluginPackageCheckpoint) -> None: + """确认包变更成功并清理临时快照。""" + shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False) + + async def async_commit(self, checkpoint: PluginPackageCheckpoint) -> None: + """在线程池中清理已提交的插件包快照。""" + await asyncio.to_thread(self.commit, checkpoint) + + @staticmethod + def rollback(checkpoint: PluginPackageCheckpoint) -> None: + """删除当前包并把变更前文件快照恢复到运行目录。""" + if checkpoint.plugin_dir.exists(): + shutil.rmtree(checkpoint.plugin_dir) + snapshot_dir = checkpoint.transaction_dir / "package" + if checkpoint.existed: + if not snapshot_dir.is_dir(): + raise FileNotFoundError( + f"插件 {checkpoint.plugin_id} 的补偿快照不存在:{snapshot_dir}" + ) + shutil.copytree(snapshot_dir, checkpoint.plugin_dir) + shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False) + + async def async_rollback(self, checkpoint: PluginPackageCheckpoint) -> None: + """在线程池中恢复插件包文件快照。""" + await asyncio.to_thread(self.rollback, checkpoint) + + def install( + self, + plugin_id: str, + repo_url: str, + package_version: Optional[str] = None, + release_version: Optional[str] = None, + force_install: bool = False, + ) -> tuple[bool, str]: + """同步安装插件包,下载过程继续复用既有市场兼容策略。""" + return self._helper.install( + pid=plugin_id, + repo_url=repo_url, + package_version=package_version, + release_version=release_version, + force_install=force_install, + ) + + async def async_install( + self, + plugin_id: str, + repo_url: str, + package_version: Optional[str] = None, + release_version: Optional[str] = None, + force_install: bool = False, + ) -> tuple[bool, str]: + """异步安装插件包,下载过程继续复用既有市场兼容策略。""" + return await self._helper.async_install( + pid=plugin_id, + repo_url=repo_url, + package_version=package_version, + release_version=release_version, + force_install=force_install, + ) + + def sync_local(self, plugin_id: str, source_dir: Path) -> bool: + """用本地仓库内容原子替换运行副本,失败时恢复原目录。""" + source_dir = source_dir.resolve() + plugin_dir = self._plugin_dir(plugin_id) + if source_dir == plugin_dir: + return True + checkpoint = self.checkpoint(plugin_id) + try: + if plugin_dir.exists(): + shutil.rmtree(plugin_dir) + shutil.copytree( + source_dir, + plugin_dir, + ignore=shutil.ignore_patterns(*self._COPY_IGNORE), + ) + self.commit(checkpoint) + return True + except Exception as err: + logger.error(f"同步本地插件 {plugin_id} 失败:{err}") + try: + self.rollback(checkpoint) + except Exception as rollback_err: + logger.error( + f"恢复本地插件 {plugin_id} 原目录失败:{rollback_err}", + exc_info=True, + ) + return False + + def clone( + self, + *, + plugin_id: str, + clone_id: str, + original_class_name: str, + suffix: str, + name: str, + description: str, + version: Optional[str] = None, + icon: Optional[str] = None, + ) -> tuple[bool, str]: + """复制并改写插件分身文件,任一步失败都删除不完整目标。""" + original_dir = self._plugin_dir(plugin_id) + clone_dir = self._plugin_dir(clone_id) + if not original_dir.is_dir(): + return False, f"原插件目录 {original_dir} 不存在" + if clone_dir.exists(): + return False, f"分身插件 {clone_id} 已存在" + + checkpoint = self.checkpoint(clone_id) + try: + shutil.copytree(original_dir, clone_dir) + success, message = self._modify_plugin_files( + plugin_dir=clone_dir, + original_class_name=original_class_name, + suffix=suffix, + name=name, + description=description, + version=version, + icon=icon, + ) + if not success: + self.rollback(checkpoint) + return False, message + self.commit(checkpoint) + logger.info(f"已复制插件目录:{original_dir} -> {clone_dir}") + return True, "文件修改成功" + except Exception as err: + try: + self.rollback(checkpoint) + except Exception as rollback_err: + logger.error( + f"清理插件分身 {clone_id} 失败:{rollback_err}", + exc_info=True, + ) + return False, f"创建插件分身文件失败:{err}" + + def _modify_plugin_files( + self, + *, + plugin_dir: Path, + original_class_name: str, + suffix: str, + name: str, + description: str, + version: Optional[str], + icon: Optional[str], + ) -> tuple[bool, str]: + """改写分身的 Python 元数据和联邦前端资源。""" + clone_class_name = f"{original_class_name}{suffix}" + init_file = plugin_dir / "__init__.py" + if init_file.exists(): + success, message = self._modify_python_file( + file_path=init_file, + original_class_name=original_class_name, + clone_class_name=clone_class_name, + name=name, + description=description, + version=version, + icon=icon, + ) + if not success: + return False, message + + dist_dir = plugin_dir / "dist" + if dist_dir.exists(): + success, message = self._modify_federation_files( + dist_dir=dist_dir, + original_class_name=original_class_name, + clone_class_name=clone_class_name, + ) + if not success: + return False, message + return True, "文件修改成功" + + @staticmethod + def _modify_python_file( + *, + file_path: Path, + original_class_name: str, + clone_class_name: str, + name: str, + description: str, + version: Optional[str], + icon: Optional[str], + ) -> tuple[bool, str]: + """改写插件主类名称、展示元数据和独立配置前缀。""" + try: + content = file_path.read_text(encoding="utf-8", errors="replace") + content = content.replace( + f"class {original_class_name}", + f"class {clone_class_name}", + ) + if name: + content = re.sub( + r'plugin_name\s*=\s*["\'][^"\']*["\']', + f'plugin_name = "{name}"', + content, + ) + if description: + content = re.sub( + r'plugin_desc\s*=\s*["\'][^"\']*["\']', + f'plugin_desc = "{description}"', + content, + ) + content = re.sub( + r'plugin_config_prefix\s*=\s*["\'][^"\']*["\']', + f'plugin_config_prefix = "{clone_class_name.lower()}_"', + content, + ) + if version: + content = re.sub( + r'plugin_version\s*=\s*["\'][^"\']*["\']', + f'plugin_version = "{version}"', + content, + ) + if icon and icon.strip(): + content = re.sub( + r'plugin_icon\s*=\s*["\'][^"\']*["\']', + f'plugin_icon = "{icon}"', + content, + ) + if "def init_plugin(self" in content: + init_index = content.index("def init_plugin(self") + content = ( + content[:init_index] + + "is_clone = True\n\n " + + content[init_index:] + ) + file_path.write_text(content, encoding="utf-8") + return True, "Python文件修改成功" + except Exception as err: + logger.error(f"修改Python文件失败:{err}") + return False, f"修改Python文件失败:{err}" + + def _modify_federation_files( + self, + *, + dist_dir: Path, + original_class_name: str, + clone_class_name: str, + ) -> tuple[bool, str]: + """改写联邦构建产物中的插件类名和样式命名空间。""" + try: + for file_path in dist_dir.rglob("*"): + if not file_path.is_file() or file_path.suffix not in {".js", ".css"}: + continue + try: + content = file_path.read_text(encoding="utf-8", errors="replace") + if file_path.suffix == ".js": + content = content.replace(original_class_name, clone_class_name) + content = content.replace( + f'"{original_class_name}"', + f'"{clone_class_name}"', + ) + content = content.replace( + f"'{original_class_name}'", + f"'{clone_class_name}'", + ) + content = content.replace( + f"css__{original_class_name}__", + f"css__{clone_class_name}__", + ) + content = content.replace( + original_class_name.lower(), + clone_class_name.lower(), + ) + file_path.write_text(content, encoding="utf-8") + except Exception as err: + logger.warning(f"修改联邦插件文件 {file_path} 失败:{err}") + self._rename_federation_assets( + dist_dir, + original_class_name, + clone_class_name, + ) + return True, "联邦插件文件修改完成" + except Exception as err: + logger.error(f"修改联邦插件文件失败:{err}") + return False, f"修改联邦插件文件失败:{err}" + + @staticmethod + def _rename_federation_assets( + dist_dir: Path, + original_class_name: str, + clone_class_name: str, + ) -> None: + """重命名包含原类名的顶层联邦资源,避免分身资源冲突。""" + try: + for file_path in dist_dir.glob("*"): + if not file_path.is_file(): + continue + if original_class_name.lower() not in file_path.name.lower(): + continue + new_name = file_path.name.replace( + original_class_name.lower(), + clone_class_name.lower(), + ) + new_path = file_path.parent / new_name + if not new_path.exists(): + file_path.rename(new_path) + except Exception as err: + logger.warning(f"重命名联邦插件资源文件失败:{err}") diff --git a/app/adapters/web/__init__.py b/app/adapters/web/__init__.py new file mode 100644 index 000000000..2d5229eaf --- /dev/null +++ b/app/adapters/web/__init__.py @@ -0,0 +1 @@ +"""Web 框架适配器。""" diff --git a/app/adapters/web/plugin/__init__.py b/app/adapters/web/plugin/__init__.py new file mode 100644 index 000000000..fca3eb725 --- /dev/null +++ b/app/adapters/web/plugin/__init__.py @@ -0,0 +1 @@ +"""插件 Web 适配器。""" diff --git a/app/adapters/web/plugin/routes.py b/app/adapters/web/plugin/routes.py new file mode 100644 index 000000000..93925cbdb --- /dev/null +++ b/app/adapters/web/plugin/routes.py @@ -0,0 +1,106 @@ +"""FastAPI 动态插件路由适配器。""" + +from typing import Any, Callable, Optional + +from fastapi import Depends, FastAPI +from fastapi.routing import APIRoute + + +class FastAPIDynamicRouteRegistry: + """在 FastAPI 上注册插件自由响应路由,并维护 OpenAPI 缓存。""" + + def __init__( + self, + app: FastAPI, + plugin_ids: Callable[[], list[str]], + plugin_apis: Callable[[str], list[dict]], + verify_token: Callable[..., Any], + verify_apikey: Callable[..., Any], + prefix: str, + protected_routes: set[str], + log: Any, + ) -> None: + """注入应用、插件投影、认证依赖和日志端口。""" + self._app = app + self._plugin_ids = plugin_ids + self._plugin_apis = plugin_apis + self._verify_token = verify_token + self._verify_apikey = verify_apikey + self._prefix = prefix + self._protected_routes = protected_routes + self._logger = log + + def update(self, plugin_id: Optional[str], action: str) -> None: + """按插件生命周期新增或移除动态路由。""" + if action not in {"add", "remove"}: + raise ValueError("Action must be 'add' or 'remove'") + + modified = False + existing_paths = {route.path: route for route in self._app.routes} + plugin_ids = [plugin_id] if plugin_id else self._plugin_ids() + for current_id in plugin_ids: + if self.remove(current_id): + modified = True + if action != "add": + continue + for api in self._plugin_apis(current_id): + api_path = f"{self._prefix}{api.get('path', '')}" + try: + api["path"] = api_path + allow_anonymous = api.pop("allow_anonymous", False) + auth_mode = api.pop("auth", "apikey") + dependencies = api.setdefault("dependencies", []) + if not allow_anonymous: + if ( + auth_mode == "bear" + and Depends(self._verify_token) not in dependencies + ): + dependencies.append(Depends(self._verify_token)) + elif Depends(self._verify_apikey) not in dependencies: + dependencies.append(Depends(self._verify_apikey)) + # 插件 API 自行决定响应结构,不使用宿主统一 Response 路由。 + api.setdefault("route_class_override", APIRoute) + self._app.router.add_api_route(**api, tags=["plugin"]) + modified = True + self._logger.debug(f"Added plugin route: {api_path}") + except Exception as error: + self._logger.error( + f"Error adding plugin route {api_path}: {str(error)}" + ) + if modified: + self.clean(existing_paths) + self._app.openapi_schema = None + self._app.setup() + + def remove(self, plugin_id: str) -> bool: + """移除指定插件前缀下的全部动态路由。""" + if not plugin_id: + return False + prefix = f"{self._prefix}/{plugin_id}/" + routes = [ + route for route in self._app.routes + if route.path.startswith(prefix) + ] + removed = False + for route in routes: + try: + self._app.routes.remove(route) + removed = True + self._logger.debug(f"Removed plugin route: {route.path}") + except Exception as error: + self._logger.error( + f"Error removing plugin route {route.path}: {str(error)}" + ) + return removed + + def clean(self, existing_paths: dict) -> None: + """清理 FastAPI 重建时可能重复的受保护文档路由。""" + for protected_route in self._protected_routes: + try: + existing_route = existing_paths.get(protected_route) + if existing_route: + self._app.routes.remove(existing_route) + except Exception as error: + self._logger.error( + f"Error removing protected route {protected_route}: {str(error)}" + ) diff --git a/app/agent/callback/__init__.py b/app/agent/callback/__init__.py index 318f03537..4f7c54c31 100644 --- a/app/agent/callback/__init__.py +++ b/app/agent/callback/__init__.py @@ -8,7 +8,7 @@ from fastapi.concurrency import run_in_threadpool from app.agent.policy import sanitize_for_host from app.chain import ChainBase from app.runtime.log import logger -from app.schemas import Message +from app.schemas.message import Message from app.schemas.message import ( MessageResponse, ChannelCapabilityManager, diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index bc48c93f7..0b1cc389c 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -73,7 +73,10 @@ from app.db.oper.agentchat import AgentChatOper from app.db.oper.agenttask import AgentTaskOper from app.db.oper.user import UserOper from app.runtime.log import logger -from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Message, MessageType +from app.schemas.event import AgentLLMProviderEventData +from app.schemas.event import AgentTokensUsageEventData +from app.schemas.message import Message +from app.schemas.message import MessageType from app.schemas.notification import ChannelCapabilityManager, ChannelCapability from app.schemas.types import ChainEventType, EventType, NotificationChannel from app.foundation.identity import SYSTEM_INTERNAL_USER_ID diff --git a/app/agent/prompt/__init__.py b/app/agent/prompt/__init__.py index cf37042d9..726cb7190 100644 --- a/app/agent/prompt/__init__.py +++ b/app/agent/prompt/__init__.py @@ -12,12 +12,10 @@ import yaml from app.agent.llm.capability import AgentCapabilityManager from app.runtime.config import settings from app.runtime.log import logger -from app.schemas import ( - ChannelCapability, - ChannelCapabilities, - NotificationChannel, - ChannelCapabilityManager, -) +from app.schemas.notification import ChannelCapability +from app.schemas.notification import ChannelCapabilities +from app.schemas.notification import NotificationChannel +from app.schemas.notification import ChannelCapabilityManager from app.adapters.system.host import SystemUtils SYSTEM_TASKS_FILE = "System Tasks.yaml" diff --git a/app/agent/tools/base.py b/app/agent/tools/base.py index 8d78591fc..56c1017c7 100644 --- a/app/agent/tools/base.py +++ b/app/agent/tools/base.py @@ -21,7 +21,7 @@ from app.runtime.config import settings from app.application.messaging.agent import matches_channel_admin from app.runtime.extensions.service_registry import ServiceConfigHelper from app.runtime.log import logger -from app.schemas import Message +from app.schemas.message import Message from app.schemas.types import NotificationChannel, MessageType if TYPE_CHECKING: diff --git a/app/agent/tools/impl/_filter_rule_utils.py b/app/agent/tools/impl/_filter_rule_utils.py index 99aa6e38b..774de5d27 100644 --- a/app/agent/tools/impl/_filter_rule_utils.py +++ b/app/agent/tools/impl/_filter_rule_utils.py @@ -10,7 +10,8 @@ from app.db.oper.systemconfig import SystemConfigOper from app.application.rules import RuleHelper from app.application.rules import RuleParser from app.application.rules import BUILTIN_RULE_SET -from app.schemas import CustomRule, FilterRuleGroup +from app.schemas.rule import CustomRule +from app.schemas.system import FilterRuleGroup from app.schemas.event import ConfigChangeEventData from app.schemas.types import EventType, SystemConfigKey diff --git a/app/agent/tools/impl/_music_utils.py b/app/agent/tools/impl/_music_utils.py index 447b5ec37..9c53b3e39 100644 --- a/app/agent/tools/impl/_music_utils.py +++ b/app/agent/tools/impl/_music_utils.py @@ -1,18 +1,13 @@ """Agent 音乐工具共享的实体校验与结果精简函数。""" -from typing import Any, Optional +from typing import Any from app.domain.context import ( MusicAlbumInfo, MusicArtistInfo, MusicInfo, ) -from app.schemas.types import ( - MUSIC_ENTITY_TYPES, - MUSIC_SUBSCRIBABLE_TYPES, - media_type_to_agent, -) -from app.domain.media import normalize_music_type +from app.schemas.types import media_type_to_agent MUSIC_TRACK_PREVIEW_LIMIT = 100 diff --git a/app/agent/tools/impl/_plugin_tool_utils.py b/app/agent/tools/impl/_plugin_tool_utils.py index 049f7cbf9..165c05c3d 100644 --- a/app/agent/tools/impl/_plugin_tool_utils.py +++ b/app/agent/tools/impl/_plugin_tool_utils.py @@ -6,9 +6,11 @@ from typing import Any, Optional from app.runtime.config import settings from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.install import PluginInstallCommand from app.db.oper.systemconfig import SystemConfigOper from app.adapters.external.server import MoviePilotServerHelper from app.adapters.external.market import PluginHelper +from app.adapters.system.plugin.package import PluginPackageManager from app.schemas.types import SystemConfigKey # 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。 @@ -65,22 +67,24 @@ def build_preview_payload(value: Any, max_chars: Optional[int]) -> tuple[bool, i return True, len(serialized), len(preview), preview -def reload_plugin_runtime(plugin_id: str) -> None: - """ - 重载插件并重新注册其命令、定时任务和 API。 - """ +def refresh_plugin_registrations(plugin_id: str) -> None: + """重新注册插件的定时任务、命令和动态 API 路由。""" # 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。 from app.application.plugins import register_plugin_api from app.application.commands import init_commands from app.application.scheduling import update_plugin_job - plugin_manager = PluginManager() - plugin_manager.reload_plugin(plugin_id) update_plugin_job(plugin_id) init_commands(plugin_id) register_plugin_api(plugin_id) +def reload_plugin_runtime(plugin_id: str) -> None: + """重载插件实例并重新注册其命令、定时任务和 API。""" + PluginManager().reload_plugin(plugin_id) + refresh_plugin_registrations(plugin_id) + + def summarize_plugin(plugin: Any) -> dict[str, Any]: """ 提取插件对象中对 Agent 有价值的摘要字段。 @@ -296,37 +300,80 @@ async def install_plugin_runtime( """ 按现有插件接口的行为安装插件,并刷新运行态注册信息。 """ - install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] plugin_manager = PluginManager() plugin_helper = PluginHelper() - - refreshed_only = False - if not force and plugin_id in plugin_manager.get_plugin_ids(): - refreshed_only = True - await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url) - message = "插件已存在,已刷新加载" - else: - if not repo_url: - return False, "没有传入仓库地址,无法正确安装插件,请检查配置", False - state, message = await plugin_helper.async_install( - pid=plugin_id, - repo_url=repo_url, - force_install=force, - ) - if not state: - return False, message, False - await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url) - - if plugin_id not in install_plugins: - install_plugins.append(plugin_id) - await SystemConfigOper().async_set( - SystemConfigKey.UserInstalledPlugins, install_plugins - ) + package_manager = PluginPackageManager(plugin_helper) from app.agent.tools.base import run_agent_blocking - await run_agent_blocking("plugin", reload_plugin_runtime, plugin_id) - return True, message or "插件安装成功", refreshed_only + async def save_installed_plugins(plugin_ids: list[str]) -> object: + """保存智能体安装用例确认后的插件列表。""" + return await SystemConfigOper().async_set( + SystemConfigKey.UserInstalledPlugins, + plugin_ids, + ) + + async def install_package( + target_id: str, + target_repo: str, + _release_version: Optional[str], + force_install: bool, + ) -> tuple[bool, str]: + """调用插件包适配器执行异步安装。""" + return await package_manager.async_install( + plugin_id=target_id, + repo_url=target_repo, + force_install=force_install, + ) + + async def skip_compatibility_check( + _target_id: str, + _target_repo: str, + ) -> None: + """保持 Agent 旧安装入口不额外执行系统版本预检查。""" + return None + + async def reload_runtime(target_id: str) -> object: + """通过 Agent 阻塞任务适配器重建插件实例。""" + return await run_agent_blocking( + "plugin", + plugin_manager.reload_plugin, + target_id, + ) + + async def refresh_registrations(target_id: str) -> object: + """通过 Agent 阻塞任务适配器刷新服务、命令和动态路由。""" + return await run_agent_blocking( + "plugin", + refresh_plugin_registrations, + target_id, + ) + + result = await PluginInstallCommand( + installed_plugins_reader=lambda: SystemConfigOper().get( + SystemConfigKey.UserInstalledPlugins + ) or [], + installed_plugins_writer=save_installed_plugins, + plugin_ids_provider=plugin_manager.get_plugin_ids, + compatibility_checker=skip_compatibility_check, + package_installer=install_package, + package_checkpointer=package_manager.async_checkpoint, + package_committer=package_manager.async_commit, + package_rollback=package_manager.async_rollback, + install_reporter=lambda target_id, target_repo: ( + MoviePilotServerHelper.async_install_plugin_reg( + plugin_id=target_id, + repo_url=target_repo, + ) + ), + plugin_reloader=reload_runtime, + registration_refresher=refresh_registrations, + ).execute( + plugin_id=plugin_id, + repo_url=repo_url, + force=force, + ) + return result.success, result.message, result.refreshed_only async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]: diff --git a/app/agent/tools/impl/add_download_tasks.py b/app/agent/tools/impl/add_download_tasks.py index f68af1c2c..f38ead4a4 100644 --- a/app/agent/tools/impl/add_download_tasks.py +++ b/app/agent/tools/impl/add_download_tasks.py @@ -18,7 +18,7 @@ from app.domain.metainfo import MetaInfo from app.db.oper.site import SiteOper from app.application.directory import DirectoryHelper, validate_download_save_path from app.runtime.log import logger -from app.schemas import FileURI +from app.schemas.file import FileURI from app.foundation.crypto import HashUtils diff --git a/app/agent/tools/impl/add_subscribe.py b/app/agent/tools/impl/add_subscribe.py index 51663a5ad..23e026f5d 100644 --- a/app/agent/tools/impl/add_subscribe.py +++ b/app/agent/tools/impl/add_subscribe.py @@ -10,7 +10,7 @@ from app.chain.subscribe import SubscribeChain from app.db.oper.user import UserOper from app.runtime.log import logger from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, NotificationChannel -from ._music_utils import normalize_music_type +from app.domain.media import normalize_music_type class AddSubscribeInput(BaseModel): diff --git a/app/agent/tools/impl/ask_user_choice.py b/app/agent/tools/impl/ask_user_choice.py index 840787346..4a82a6e8e 100644 --- a/app/agent/tools/impl/ask_user_choice.py +++ b/app/agent/tools/impl/ask_user_choice.py @@ -12,7 +12,8 @@ from app.application.messaging.agent import ( build_agent_choice_callback, ) from app.runtime.log import logger -from app.schemas import Message, MessageType +from app.schemas.message import Message +from app.schemas.message import MessageType from app.schemas.notification import ChannelCapabilityManager from app.schemas.types import NotificationChannel diff --git a/app/agent/tools/impl/delete_transfer_history.py b/app/agent/tools/impl/delete_transfer_history.py index 363449221..e66c5b882 100644 --- a/app/agent/tools/impl/delete_transfer_history.py +++ b/app/agent/tools/impl/delete_transfer_history.py @@ -9,7 +9,7 @@ from app.agent.tools.tags import ToolTag from app.chain.storage import StorageChain from app.db.oper.transferhistory import TransferHistoryOper from app.runtime.log import logger -from app.schemas import FileItem +from app.schemas.workflow import FileItem class DeleteTransferHistoryInput(BaseModel): diff --git a/app/agent/tools/impl/get_recommendations.py b/app/agent/tools/impl/get_recommendations.py index 583cde6de..575138345 100644 --- a/app/agent/tools/impl/get_recommendations.py +++ b/app/agent/tools/impl/get_recommendations.py @@ -20,7 +20,8 @@ from app.schemas.types import ( MediaType, media_type_to_agent, ) -from ._music_utils import normalize_music_type, simplify_music_info +from app.domain.media import normalize_music_type +from ._music_utils import simplify_music_info class GetRecommendationsInput(BaseModel): diff --git a/app/agent/tools/impl/list_slash_commands.py b/app/agent/tools/impl/list_slash_commands.py index 89984c211..a6741073e 100644 --- a/app/agent/tools/impl/list_slash_commands.py +++ b/app/agent/tools/impl/list_slash_commands.py @@ -3,7 +3,7 @@ import json from typing import Optional, Type -from pydantic import BaseModel, Field +from pydantic import BaseModel from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag diff --git a/app/agent/tools/impl/query_custom_identifiers.py b/app/agent/tools/impl/query_custom_identifiers.py index 68af8135c..db2ecaca1 100644 --- a/app/agent/tools/impl/query_custom_identifiers.py +++ b/app/agent/tools/impl/query_custom_identifiers.py @@ -3,7 +3,7 @@ import json from typing import Optional, Type -from pydantic import BaseModel, Field +from pydantic import BaseModel from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag diff --git a/app/agent/tools/impl/query_download_tasks.py b/app/agent/tools/impl/query_download_tasks.py index ce62c904b..1cae65a32 100644 --- a/app/agent/tools/impl/query_download_tasks.py +++ b/app/agent/tools/impl/query_download_tasks.py @@ -10,7 +10,7 @@ from app.agent.tools.tags import ToolTag from app.chain.download import DownloadChain from app.db.oper.downloadhistory import DownloadHistoryOper from app.runtime.log import logger -from app.schemas import DownloaderTorrent +from app.schemas.transfer import DownloaderTorrent from app.schemas.types import MUSIC_ENTITY_RECORDING, TorrentQueryStatus, media_type_to_agent diff --git a/app/agent/tools/impl/query_downloaders.py b/app/agent/tools/impl/query_downloaders.py index 1ae9f11b1..881555c68 100644 --- a/app/agent/tools/impl/query_downloaders.py +++ b/app/agent/tools/impl/query_downloaders.py @@ -3,7 +3,7 @@ import json from typing import Optional, Type -from pydantic import BaseModel, Field +from pydantic import BaseModel from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag diff --git a/app/agent/tools/impl/query_library_exists.py b/app/agent/tools/impl/query_library_exists.py index f3af96c2f..fa3d59981 100644 --- a/app/agent/tools/impl/query_library_exists.py +++ b/app/agent/tools/impl/query_library_exists.py @@ -18,7 +18,7 @@ from app.schemas.types import ( MediaType, media_type_to_agent, ) -from ._music_utils import normalize_music_type +from app.domain.media import normalize_music_type def _sort_seasons(seasons: Optional[dict]) -> dict: diff --git a/app/agent/tools/impl/query_media_detail.py b/app/agent/tools/impl/query_media_detail.py index a3221cd52..8aa4b3946 100644 --- a/app/agent/tools/impl/query_media_detail.py +++ b/app/agent/tools/impl/query_media_detail.py @@ -17,8 +17,8 @@ from app.schemas.types import ( MediaSource, MediaType, ) +from app.domain.media import normalize_music_type from ._music_utils import ( - normalize_music_type, simplify_music_album, simplify_music_artist, simplify_music_info, diff --git a/app/agent/tools/impl/query_popular_subscribes.py b/app/agent/tools/impl/query_popular_subscribes.py index 2339d84b1..a0845419c 100644 --- a/app/agent/tools/impl/query_popular_subscribes.py +++ b/app/agent/tools/impl/query_popular_subscribes.py @@ -12,7 +12,7 @@ from app.domain.context import MediaInfo from app.adapters.external.server import MoviePilotServerHelper from app.runtime.log import logger from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaType, media_type_to_agent -from ._music_utils import normalize_music_type +from app.domain.media import normalize_music_type MAX_PAGE_SIZE = 50 diff --git a/app/agent/tools/impl/query_subscribe_history.py b/app/agent/tools/impl/query_subscribe_history.py index 1926ec2a2..d340ba0f9 100644 --- a/app/agent/tools/impl/query_subscribe_history.py +++ b/app/agent/tools/impl/query_subscribe_history.py @@ -10,7 +10,7 @@ from app.agent.tools.tags import ToolTag from app.db.oper.subscribehistory import SubscribeHistoryOper from app.runtime.log import logger from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaType, media_type_to_agent -from ._music_utils import normalize_music_type +from app.domain.media import normalize_music_type PAGE_SIZE = 20 diff --git a/app/agent/tools/impl/query_subscribe_shares.py b/app/agent/tools/impl/query_subscribe_shares.py index 283159041..39eb96ad9 100644 --- a/app/agent/tools/impl/query_subscribe_shares.py +++ b/app/agent/tools/impl/query_subscribe_shares.py @@ -10,7 +10,7 @@ from app.agent.tools.tags import ToolTag from app.adapters.external.server import MoviePilotServerHelper from app.runtime.log import logger from app.schemas.types import MUSIC_ENTITY_RECORDING, media_type_to_agent -from ._music_utils import normalize_music_type +from app.domain.media import normalize_music_type MAX_PAGE_SIZE = 50 diff --git a/app/agent/tools/impl/query_subscribes.py b/app/agent/tools/impl/query_subscribes.py index f20f61acc..2fcb24971 100644 --- a/app/agent/tools/impl/query_subscribes.py +++ b/app/agent/tools/impl/query_subscribes.py @@ -16,7 +16,7 @@ from app.schemas.types import ( MediaType, media_type_to_agent, ) -from ._music_utils import normalize_music_type +from app.domain.media import normalize_music_type PAGE_SIZE = 100 diff --git a/app/agent/tools/impl/scrape_metadata.py b/app/agent/tools/impl/scrape_metadata.py index a72f0039b..70340cb9e 100644 --- a/app/agent/tools/impl/scrape_metadata.py +++ b/app/agent/tools/impl/scrape_metadata.py @@ -12,7 +12,7 @@ from app.chain.media import MediaChain from app.chain.scraping import ScrapingChain from app.runtime.config import settings from app.runtime.log import logger -from app.schemas import FileItem +from app.schemas.workflow import FileItem from app.schemas.types import ( MUSIC_ENTITY_ARTIST, MediaSource, @@ -20,7 +20,8 @@ from app.schemas.types import ( media_type_to_agent, ) from app.schemas.media import normalize_media_source -from ._music_utils import normalize_music_type, simplify_music_info +from app.domain.media import normalize_music_type +from ._music_utils import simplify_music_info class ScrapeMetadataInput(BaseModel): diff --git a/app/agent/tools/impl/search_media.py b/app/agent/tools/impl/search_media.py index d59b8dbea..9be0c96ad 100644 --- a/app/agent/tools/impl/search_media.py +++ b/app/agent/tools/impl/search_media.py @@ -11,7 +11,8 @@ from app.chain.media import MediaChain from app.runtime.log import logger from app.schemas.types import MediaType, media_type_to_agent from app.schemas.media import resolve_media_identity -from ._music_utils import normalize_music_type, simplify_music_info +from app.domain.media import normalize_music_type +from ._music_utils import simplify_music_info class SearchMediaInput(BaseModel): diff --git a/app/agent/tools/impl/search_torrents.py b/app/agent/tools/impl/search_torrents.py index 5a8a09165..767be5361 100644 --- a/app/agent/tools/impl/search_torrents.py +++ b/app/agent/tools/impl/search_torrents.py @@ -12,7 +12,7 @@ from app.db.oper.systemconfig import SystemConfigOper from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.runtime.log import logger from app.schemas.types import MediaSource, MediaType, SystemConfigKey -from ._music_utils import normalize_music_type +from app.domain.media import normalize_music_type from ._torrent_search_utils import ( SEARCH_RESULT_CACHE_FILE, build_filter_options, diff --git a/app/agent/tools/impl/send_local_file.py b/app/agent/tools/impl/send_local_file.py index 3a737f30e..e245036dc 100644 --- a/app/agent/tools/impl/send_local_file.py +++ b/app/agent/tools/impl/send_local_file.py @@ -8,7 +8,8 @@ from pydantic import BaseModel, Field, model_validator from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.runtime.log import logger -from app.schemas import Message, MessageType +from app.schemas.message import Message +from app.schemas.message import MessageType from app.schemas.notification import ChannelCapabilityManager, ChannelCapability from app.schemas.types import NotificationChannel diff --git a/app/agent/tools/impl/send_message.py b/app/agent/tools/impl/send_message.py index 15073ea77..faf3b0ad4 100644 --- a/app/agent/tools/impl/send_message.py +++ b/app/agent/tools/impl/send_message.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, model_validator from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.runtime.log import logger -from app.schemas import Message +from app.schemas.message import Message from app.schemas.types import MessageType diff --git a/app/agent/tools/impl/send_voice_message.py b/app/agent/tools/impl/send_voice_message.py index a748bf90a..eafecf34b 100644 --- a/app/agent/tools/impl/send_voice_message.py +++ b/app/agent/tools/impl/send_voice_message.py @@ -8,7 +8,8 @@ from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.runtime.config import settings from app.runtime.log import logger -from app.schemas import Message, MessageType +from app.schemas.message import Message +from app.schemas.message import MessageType class SendVoiceMessageInput(BaseModel): diff --git a/app/agent/tools/impl/switch_persona.py b/app/agent/tools/impl/switch_persona.py index b15745038..ba4bf607e 100644 --- a/app/agent/tools/impl/switch_persona.py +++ b/app/agent/tools/impl/switch_persona.py @@ -1,7 +1,7 @@ """切换当前激活人格工具。""" import json -from typing import Type, Optional +from typing import Type from pydantic import BaseModel, Field diff --git a/app/agent/tools/impl/transfer_file.py b/app/agent/tools/impl/transfer_file.py index df0a2055f..4133f18c0 100644 --- a/app/agent/tools/impl/transfer_file.py +++ b/app/agent/tools/impl/transfer_file.py @@ -8,9 +8,10 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.runtime.log import logger -from app.schemas import FileItem, MediaType +from app.schemas.workflow import FileItem +from app.schemas.types import MediaType from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource -from ._music_utils import normalize_music_type +from app.domain.media import normalize_music_type class TransferFileInput(BaseModel): diff --git a/app/api/deps.py b/app/api/deps.py index a7c3745f3..cd43387a8 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -6,19 +6,257 @@ HTTPException 表达。它们此前住在 app/db/oper/user.py 里,与数据访 鉴权是 HTTP 层的关注点,产出的是 403/400 而不是数据。放在 db 包里既让数据层反向 依赖了 fastapi,也使这部分逻辑无法与数据访问分开度量。 """ -from fastapi import Depends, HTTPException +from fastapi import BackgroundTasks, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session -from app import schemas +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.application.subscription.delete import DeleteSubscribeCommand +from app.application.subscription.identity import ( + DeleteSubscriptionsByIdentityCommand, +) +from app.application.subscription.search import SearchSubscriptionsCommand +from app.application.site.mutation import SiteMutationCommand +from app.application.workflow import ( + WorkflowDefinitionCommand, + WorkflowMutationCommand, +) +from app.application.history import ( + DownloadHistoryMutationCommand, + TransferHistoryMutationCommand, + clear_transfer_failures, +) +from app.application.plugin.config import PluginConfigCommand +from app.application.commands import init_commands +from app.application.plugins import register_plugin_api +from app.application.scheduling import update_plugin_job from app.application.security.access import verify_token +from app.adapters.external.server import MoviePilotServerHelper from app.db import get_async_db, get_db from app.db.models.user import User +from app.db.oper.subscribe import SubscribeOper +from app.db.oper.site import SiteOper +from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork +from app.runtime.events import eventmanager +from app.runtime.extensions.plugin_manager import PluginManager +from app.runtime.log import logger +from app.schemas.event import PluginDataResetEventData +from app.schemas.types import ChainEventType, EventType +from app.scheduler import Scheduler +from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module +from app.domain import site as site_rules +from app.foundation import url as url_tools +from app.db.oper.systemconfig import SystemConfigOper +from app.db.oper.workflow import WorkflowOper +from app.db.oper.downloadhistory import DownloadHistoryOper +from app.db.oper.transferhistory import TransferHistoryOper +from app.runtime.config import global_vars +from app.workflow import WorkFlowManager +from app.chain.storage import StorageChain +from app.schemas.workflow import FileItem as _SchemaFileItem + + +async def _publish_subscribe_deleted( + subscribe_id: int, + subscribe_info: dict, +) -> None: + """通过宿主事件总线发布已提交的订阅删除事件。""" + await eventmanager.async_send_event( + EventType.SubscribeDeleted, + {"subscribe_id": subscribe_id, "subscribe_info": subscribe_info}, + ) + + +def get_delete_subscribe_command( + db: AsyncSession = Depends(get_async_db), +) -> DeleteSubscribeCommand: + """组装请求级订阅删除用例及其具体适配器。""" + return DeleteSubscribeCommand( + repository=SubscribeOper(db), + unit_of_work=SqlAlchemyAsyncUnitOfWork(db), + publish_deleted=_publish_subscribe_deleted, + report_deleted=MoviePilotServerHelper.sub_done_async, + ) + + +def _log_subscribe_deleted_event_error( + subscribe_id: int, + error: Exception, +) -> None: + """记录按媒体身份删除时的单条事件失败并允许后续事件继续。""" + logger.error( + f"发送订阅删除事件失败:{subscribe_id} - {error}", + exc_info=True, + ) + + +def get_delete_subscriptions_by_identity_command( + db: AsyncSession = Depends(get_async_db), +) -> DeleteSubscriptionsByIdentityCommand: + """组装请求级按媒体身份删除订阅用例。""" + return DeleteSubscriptionsByIdentityCommand( + repository=SubscribeOper(db), + unit_of_work=SqlAlchemyAsyncUnitOfWork(db), + publish_deleted=_publish_subscribe_deleted, + handle_event_error=_log_subscribe_deleted_event_error, + ) + + +def get_search_subscriptions_command( + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_async_db), +) -> SearchSubscriptionsCommand: + """组装手工订阅搜索用例,并把调度延迟到响应后的后台任务。""" + def schedule_search(subscribe_id: int | None, state: str | None) -> None: + """按历史参数提交订阅搜索调度任务。""" + background_tasks.add_task( + Scheduler().start, + job_id="subscribe_search", + sid=subscribe_id, + state=state, + manual=True, + ) + + return SearchSubscriptionsCommand( + repository=SubscribeOper(db), + schedule_search=schedule_search, + ) + + +async def _publish_site_updated(payload: dict) -> None: + """发布已提交的站点更新事件。""" + await eventmanager.async_send_event(EventType.SiteUpdated, payload) + + +async def _publish_site_deleted(payload: dict) -> None: + """发布已提交的站点删除事件。""" + await eventmanager.async_send_event(EventType.SiteDeleted, payload) + + +def get_site_mutation_command( + db: AsyncSession = Depends(get_async_db), +) -> SiteMutationCommand: + """组装请求级站点写用例及其事务和外部目录依赖。""" + sites_helper = SitesHelper() + + def normalize_url(value: str) -> str: + """沿用站点接口的 scheme/netloc 规范化格式。""" + scheme, netloc = url_tools.split_netloc(value) + return f"{scheme}://{netloc}/" + + return SiteMutationCommand( + repository=SiteOper(db), + unit_of_work=SqlAlchemyAsyncUnitOfWork(db), + auth_level_provider=lambda: sites_helper.auth_level, + indexer_loader=sites_helper.async_get_indexer, + domain_extractor=site_rules.extract_domain, + url_normalizer=normalize_url, + publish_updated=_publish_site_updated, + publish_deleted=_publish_site_deleted, + ) + + +def get_workflow_mutation_command( + db: Session = Depends(get_db), +) -> WorkflowMutationCommand: + """组装请求级工作流写用例和提交后的调度副作用。""" + scheduler = Scheduler() + workflow_manager = WorkFlowManager() + return WorkflowMutationCommand( + repository=WorkflowOper(db), + unit_of_work=SqlAlchemyUnitOfWork(db), + add_timer=scheduler.update_workflow_job, + remove_timer=scheduler.remove_workflow_job, + load_event=workflow_manager.load_workflow_events, + remove_event=workflow_manager.remove_workflow_event, + refresh_event=workflow_manager.update_workflow_event, + stop_running=global_vars.stop_workflow, + delete_cache=lambda workflow_id: SystemConfigOper().delete( + f"WorkflowCache-{workflow_id}" + ), + ) + + +def get_workflow_definition_command( + db: AsyncSession = Depends(get_async_db), +) -> WorkflowDefinitionCommand: + """组装工作流创建、复用和重置的异步写用例。""" + return WorkflowDefinitionCommand( + repository=WorkflowOper(db), + unit_of_work=SqlAlchemyAsyncUnitOfWork(db), + stop_running=global_vars.stop_workflow, + delete_cache=lambda workflow_id: SystemConfigOper().delete( + f"WorkflowCache-{workflow_id}" + ), + report_fork=MoviePilotServerHelper.async_workflow_fork_by_id, + ) + + +def get_download_history_mutation_command( + db: Session = Depends(get_db), +) -> DownloadHistoryMutationCommand: + """组装下载历史删除用例及其请求级事务。""" + return DownloadHistoryMutationCommand( + repository=DownloadHistoryOper(db), + unit_of_work=SqlAlchemyUnitOfWork(db), + ) + + +def get_transfer_history_mutation_command( + db: Session = Depends(get_db), +) -> TransferHistoryMutationCommand: + """组装整理历史删除、文件处理和事件发布用例。""" + storage_chain = StorageChain() + return TransferHistoryMutationCommand( + repository=TransferHistoryOper(db), + download_repository=DownloadHistoryOper(db), + unit_of_work=SqlAlchemyUnitOfWork(db), + file_item_factory=lambda payload: _SchemaFileItem(**payload), + delete_media_file=storage_chain.delete_media_file, + publish_download_file_deleted=lambda payload: eventmanager.send_event( + EventType.DownloadFileDeleted, + payload, + ), + clear_failures=clear_transfer_failures, + ) + + +def get_plugin_config_command() -> PluginConfigCommand: + """组装插件配置更新与重置用例,隔离 API 对运行时写操作的编排。""" + manager = PluginManager() + + def publish_reset(plugin_id: str) -> None: + """在清理持久化数据前通知目标插件执行补偿。""" + eventmanager.send_event( + ChainEventType.PluginDataReset, + PluginDataResetEventData( + plugin_id=plugin_id, + reset_config=True, + reset_data=True, + ), + ) + + def refresh_registrations(plugin_id: str) -> None: + """按服务、命令、动态路由顺序刷新插件宿主注册。""" + update_plugin_job(plugin_id) + init_commands(plugin_id) + register_plugin_api(plugin_id) + + return PluginConfigCommand( + save_config=manager.save_plugin_config, + initialize=manager.init_plugin, + stop=manager.stop, + delete_config=manager.delete_plugin_config, + delete_data=manager.delete_plugin_data, + reload_runtime=manager.reload_plugin, + publish_reset=publish_reset, + refresh_registrations=refresh_registrations, + ) def get_current_user( db: Session = Depends(get_db), - token_data: schemas.TokenPayload = Depends(verify_token) + token_data: _SchemaTokenPayload = Depends(verify_token) ) -> User: """ 获取当前用户 @@ -31,7 +269,7 @@ def get_current_user( async def get_current_user_async( db: AsyncSession = Depends(get_async_db), - token_data: schemas.TokenPayload = Depends(verify_token) + token_data: _SchemaTokenPayload = Depends(verify_token) ) -> User: """ 异步获取当前用户 diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index b45190a35..782fcf22f 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -18,7 +18,21 @@ from fastapi.concurrency import run_in_threadpool from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession -from app import schemas +from app.schemas.agent import AgentChatDisplaySaveRequest as _SchemaAgentChatDisplaySaveRequest +from app.schemas.agent import AgentChatSessionDetail as _SchemaAgentChatSessionDetail +from app.schemas.agent import AgentChatSessionSummary as _SchemaAgentChatSessionSummary +from app.schemas.agent import AgentChatUploadAttachment as _SchemaAgentChatUploadAttachment +from app.schemas.agent import AgentMcpServerListData as _SchemaAgentMcpServerListData +from app.schemas.agent import AgentMcpServerTestRequest as _SchemaAgentMcpServerTestRequest +from app.schemas.agent import AgentMcpServerTestResult as _SchemaAgentMcpServerTestResult +from app.schemas.agent import AgentMcpServersSaveRequest as _SchemaAgentMcpServersSaveRequest +from app.schemas.agent import AgentSessionStopData as _SchemaAgentSessionStopData +from app.schemas.agent import AgentWebCallbackData as _SchemaAgentWebCallbackData +from app.schemas.agent import AgentWebCommandInfo as _SchemaAgentWebCommandInfo +from app.schemas.message import AgentWebChatRequest as _SchemaAgentWebChatRequest +from app.schemas.message import AgentWebChoiceRequest as _SchemaAgentWebChoiceRequest +from app.schemas.message import Message as _SchemaMessage +from app.schemas.response import Response as _SchemaResponse from app.api.response import ResponseAPIRouter from app.agent.contracts import ReplyMode, build_display_message from app.agent.llm.capability import AgentCapabilityManager @@ -65,7 +79,7 @@ WEB_AGENT_STREAM_COALESCE_MAX_CHARS = 256 WEB_AGENT_STREAM_HEARTBEAT_SECONDS = 15.0 WEB_AGENT_STREAM_QUEUE_MAX_SIZE = 64 _WEB_AGENT_FILE_REGISTRY: dict[str, dict[str, Any]] = {} -_WEB_AGENT_MESSAGE_QUEUES: dict[str, list[Queue[schemas.Message]]] = {} +_WEB_AGENT_MESSAGE_QUEUES: dict[str, list[Queue[_SchemaMessage]]] = {} _WEB_AGENT_MESSAGE_LOCK = Lock() _WEB_AGENT_MESSAGE_LISTENER_REGISTERED = False _WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task] = set() @@ -182,18 +196,18 @@ def _ensure_superuser(user: User) -> None: @router.get( "/mcp/servers", summary="查询 Agent MCP 服务器配置", - response_model=schemas.Response[schemas.AgentMcpServerListData], + response_model=_SchemaResponse[_SchemaAgentMcpServerListData], ) async def list_agent_mcp_servers( current_user: User = Depends(get_current_active_user), -) -> schemas.Response: +) -> _SchemaResponse: """ 查询 Agent 外部 MCP 服务器配置。 """ _ensure_superuser(current_user) servers = agent_mcp_manager.get_servers() enabled_count = len([server for server in servers if server.enabled]) - return schemas.Response( + return _SchemaResponse( success=True, data={ "servers": [server.model_dump() for server in servers], @@ -206,18 +220,18 @@ async def list_agent_mcp_servers( @router.post( "/mcp/servers", summary="保存 Agent MCP 服务器配置", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) async def save_agent_mcp_servers( - request: schemas.AgentMcpServersSaveRequest, + request: _SchemaAgentMcpServersSaveRequest, current_user: User = Depends(get_current_active_user), -) -> schemas.Response: +) -> _SchemaResponse: """ 保存 Agent 外部 MCP 服务器配置。 """ _ensure_superuser(current_user) success = await agent_mcp_manager.save_servers(request.servers) - return schemas.Response( + return _SchemaResponse( success=success, message="保存MCP配置成功" if success else "保存MCP配置失败", ) @@ -226,26 +240,26 @@ async def save_agent_mcp_servers( @router.post( "/mcp/servers/test", summary="测试 Agent MCP 服务器", - response_model=schemas.Response[schemas.AgentMcpServerTestResult], + response_model=_SchemaResponse[_SchemaAgentMcpServerTestResult], ) async def test_agent_mcp_server( - request: schemas.AgentMcpServerTestRequest, + request: _SchemaAgentMcpServerTestRequest, current_user: User = Depends(get_current_active_user), -) -> schemas.Response: +) -> _SchemaResponse: """ 测试 Agent 外部 MCP 服务器连接并读取工具列表。 """ _ensure_superuser(current_user) try: result = await agent_mcp_manager.test_server(request.server) - return schemas.Response( + return _SchemaResponse( success=result.success, message=result.message, data=result.model_dump(), ) except Exception as err: logger.warning(f"测试 Agent MCP 服务器失败: {err}") - return schemas.Response( + return _SchemaResponse( success=False, message=f"测试MCP服务器失败: {str(err)}", data={ @@ -374,7 +388,7 @@ class _WebAgentMoviePilotAgentMixin: def __init__( self, *args: Any, - message_callback: Optional[Callable[[schemas.Message], None]] = None, + message_callback: Optional[Callable[[_SchemaMessage], None]] = None, **kwargs: Any, ) -> None: super().__init__(*args, **kwargs) @@ -391,7 +405,7 @@ class _WebAgentMoviePilotAgentMixin: def set_message_callback( self, - message_callback: Optional[Callable[[schemas.Message], None]], + message_callback: Optional[Callable[[_SchemaMessage], None]], ) -> None: """ 更新 Web SSE 通知回调,复用 Agent 实例时指向当前请求队列。 @@ -1034,7 +1048,7 @@ def _merge_web_agent_prompt_with_transcript(prompt: str, transcript: Optional[st return "\n".join(merged_parts).strip() -def _build_web_agent_choice_event(message: schemas.Message) -> Optional[dict]: +def _build_web_agent_choice_event(message: _SchemaMessage) -> Optional[dict]: """ 将带按钮通知转换为 Web Agent 选择卡片事件。 @@ -1115,7 +1129,7 @@ def _resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optio def _build_web_agent_message_events( - message: schemas.Message, + message: _SchemaMessage, ) -> list[dict]: """ 将 Agent 工具通知转换为 Web SSE 事件。 @@ -1216,7 +1230,7 @@ def _has_web_agent_traditional_interaction(user_id: str) -> bool: def _extract_web_agent_message_from_event_data( data: dict, -) -> Optional[schemas.Message]: +) -> Optional[_SchemaMessage]: """ 从 NoticeMessage 事件数据中提取 WebAgent 通知。 @@ -1228,17 +1242,17 @@ def _extract_web_agent_message_from_event_data( try: message = data.get("message") - if isinstance(message, schemas.Message): + if isinstance(message, _SchemaMessage): message = message elif isinstance(message, dict): message_data = copy.deepcopy(message) message_data.pop("type", None) - message = schemas.Message(**message_data) + message = _SchemaMessage(**message_data) else: message_data = copy.deepcopy(data) message_data.pop("type", None) message_data.pop("current_time", None) - message = schemas.Message(**message_data) + message = _SchemaMessage(**message_data) except Exception as err: logger.debug(f"解析WebAgent通知事件失败: {err}") return None @@ -1251,7 +1265,7 @@ def _extract_web_agent_message_from_event_data( def _is_web_agent_message_for_user( - message: schemas.Message, + message: _SchemaMessage, user_id: str, ) -> bool: """ @@ -1268,7 +1282,7 @@ def _is_web_agent_message_for_user( return False -def _get_web_agent_message_user_id(message: schemas.Message) -> Optional[str]: +def _get_web_agent_message_user_id(message: _SchemaMessage) -> Optional[str]: """ 从 NoticeMessage 事件中解析 WebAgent 目标用户。 @@ -1327,7 +1341,7 @@ def _ensure_web_agent_message_listener() -> None: _WEB_AGENT_MESSAGE_LISTENER_REGISTERED = True -def _attach_web_agent_message_queue(user_id: str, message_queue: Queue[schemas.Message]) -> None: +def _attach_web_agent_message_queue(user_id: str, message_queue: Queue[_SchemaMessage]) -> None: """ 为当前 WebAgent 请求挂载通知收集队列。 @@ -1339,7 +1353,7 @@ def _attach_web_agent_message_queue(user_id: str, message_queue: Queue[schemas.M _WEB_AGENT_MESSAGE_QUEUES.setdefault(str(user_id), []).append(message_queue) -def _detach_web_agent_message_queue(user_id: str, message_queue: Queue[schemas.Message]) -> None: +def _detach_web_agent_message_queue(user_id: str, message_queue: Queue[_SchemaMessage]) -> None: """ 移除当前 WebAgent 请求的通知收集队列。 @@ -1439,7 +1453,7 @@ async def _collect_web_agent_traditional_events( :param original_chat_id: WebAgent 原聊天 ID :return: 可直接发送给前端的 SSE 事件列表 """ - message_queue: Queue[schemas.Message] = Queue() + message_queue: Queue[_SchemaMessage] = Queue() edit_queue: Queue[dict] = Queue() user_id = str(current_user.id) @@ -1618,13 +1632,13 @@ async def download_web_agent_file(file_id: str) -> FileResponse: @router.post( "/upload", summary="上传 Web 智能助手附件", - response_model=schemas.Response[schemas.AgentChatUploadAttachment], + response_model=_SchemaResponse[_SchemaAgentChatUploadAttachment], ) async def upload_web_agent_file( file: UploadFile = File(...), session_id: Optional[str] = Form(None), current_user: User = Depends(get_current_active_user), -) -> schemas.Response: +) -> _SchemaResponse: """ 上传 Web 智能助手对话附件。 @@ -1646,7 +1660,7 @@ async def upload_web_agent_file( ) if not attachment: target_path.unlink(missing_ok=True) - return schemas.Response(success=False, message="附件保存失败") + return _SchemaResponse(success=False, message="附件保存失败") attachment.update( { @@ -1656,18 +1670,18 @@ async def upload_web_agent_file( "size": size, } ) - return schemas.Response(success=True, data=attachment) + return _SchemaResponse(success=True, data=attachment) @router.post( "/callback", summary="Web 智能助手按钮回调", - response_model=schemas.Response[schemas.AgentWebCallbackData], + response_model=_SchemaResponse[_SchemaAgentWebCallbackData], ) async def web_agent_callback( - payload: schemas.AgentWebChoiceRequest, + payload: _SchemaAgentWebChoiceRequest, current_user: User = Depends(get_current_active_user), -) -> schemas.Response: +) -> _SchemaResponse: """ 接收 Web 智能助手选择卡片回调。 @@ -1678,8 +1692,8 @@ async def web_agent_callback( if not parse_agent_choice_callback(payload.callback_data): denied_message = _ensure_web_agent_command_allowed(current_user) if denied_message: - return schemas.Response(success=False, message=denied_message) - return schemas.Response( + return _SchemaResponse(success=False, message=denied_message) + return _SchemaResponse( success=True, data=_build_web_agent_traditional_callback_payload( payload.callback_data, @@ -1693,18 +1707,18 @@ async def web_agent_callback( user_id=str(current_user.id), ) if not result: - return schemas.Response(success=False, message="该选择已失效,请重新发起选择") - return schemas.Response(success=True, data=result) + return _SchemaResponse(success=False, message="该选择已失效,请重新发起选择") + return _SchemaResponse(success=True, data=result) @router.get( "/commands", summary="获取 Web 智能助手可用命令", - response_model=schemas.Response[list[schemas.AgentWebCommandInfo]], + response_model=_SchemaResponse[list[_SchemaAgentWebCommandInfo]], ) async def list_web_agent_commands( current_user: User = Depends(get_current_active_user), -) -> schemas.Response: +) -> _SchemaResponse: """ 获取当前 Web 智能助手可补全的斜杠命令。 @@ -1713,21 +1727,21 @@ async def list_web_agent_commands( """ denied_message = _ensure_web_agent_command_allowed(current_user) if denied_message: - return schemas.Response(success=False, message=denied_message) - return schemas.Response(success=True, data=_build_web_agent_command_items()) + return _SchemaResponse(success=False, message=denied_message) + return _SchemaResponse(success=True, data=_build_web_agent_command_items()) @router.get( "/sessions", summary="获取 Agent 历史会话", - response_model=schemas.Response[list[schemas.AgentChatSessionSummary]], + response_model=_SchemaResponse[list[_SchemaAgentChatSessionSummary]], ) async def list_agent_chat_sessions( current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_async_db), page: Optional[int] = 1, count: Optional[int] = 30, -) -> schemas.Response: +) -> _SchemaResponse: """ 获取当前用户可访问的 Agent 历史会话列表。 @@ -1745,7 +1759,7 @@ async def list_agent_chat_sessions( user_id=user_id, username=username, ) - return schemas.Response( + return _SchemaResponse( success=True, data=[AgentChatOper.to_summary(chat) for chat in chats], ) @@ -1754,13 +1768,13 @@ async def list_agent_chat_sessions( @router.get( "/sessions/{session_id}", summary="获取 Agent 历史会话详情", - response_model=schemas.Response[schemas.AgentChatSessionDetail], + response_model=_SchemaResponse[_SchemaAgentChatSessionDetail], ) async def get_agent_chat_session( session_id: str, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_async_db), -) -> schemas.Response: +) -> _SchemaResponse: """ 获取一条 Agent 历史会话详情。 @@ -1779,7 +1793,7 @@ async def get_agent_chat_session( if not chat: manager = get_running_agent_manager() if manager and manager.is_session_busy(server_session_id): - return schemas.Response( + return _SchemaResponse( success=True, data={ "session_id": server_session_id, @@ -1788,26 +1802,26 @@ async def get_agent_chat_session( "is_processing": True, }, ) - return schemas.Response(success=False, message="会话不存在或无权访问") + return _SchemaResponse(success=False, message="会话不存在或无权访问") data = AgentChatOper.to_detail(chat) manager = get_running_agent_manager() data["is_processing"] = bool( manager and manager.is_session_busy(chat.session_id) ) - return schemas.Response(success=True, data=data) + return _SchemaResponse(success=True, data=data) @router.put( "/sessions/{session_id}/display", summary="保存 Agent 展示会话", - response_model=schemas.Response[schemas.AgentChatSessionSummary], + response_model=_SchemaResponse[_SchemaAgentChatSessionSummary], ) async def save_agent_chat_display( session_id: str, - payload: schemas.AgentChatDisplaySaveRequest, + payload: _SchemaAgentChatDisplaySaveRequest, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_async_db), -) -> schemas.Response: +) -> _SchemaResponse: """ 保存前端聚合后的 Agent 展示消息。 @@ -1820,7 +1834,7 @@ async def save_agent_chat_display( oper = AgentChatOper(db) existing_chat = await oper.async_get(session_id=session_id) if existing_chat and not _can_access_agent_chat(existing_chat, current_user): - return schemas.Response(success=False, message="会话不存在或无权访问") + return _SchemaResponse(success=False, message="会话不存在或无权访问") messages = [ message.model_dump(exclude_none=True) @@ -1835,20 +1849,20 @@ async def save_agent_chat_display( ) chat = await oper.async_get(session_id=session_id) if not chat: - return schemas.Response(success=False, message="会话保存失败") - return schemas.Response(success=True, data=AgentChatOper.to_summary(chat)) + return _SchemaResponse(success=False, message="会话保存失败") + return _SchemaResponse(success=True, data=AgentChatOper.to_summary(chat)) @router.delete( "/sessions/{session_id}", summary="删除 Agent 历史会话", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) async def delete_agent_chat_session( session_id: str, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_async_db), -) -> schemas.Response: +) -> _SchemaResponse: """ 删除一条 Agent 历史会话。 @@ -1860,21 +1874,21 @@ async def delete_agent_chat_session( oper = AgentChatOper(db) chat = await _get_accessible_agent_chat(oper, session_id, current_user) if not chat: - return schemas.Response(success=False, message="会话不存在或无权访问") + return _SchemaResponse(success=False, message="会话不存在或无权访问") deleted = await oper.async_delete(session_id=session_id) - return schemas.Response(success=deleted, message="删除成功" if deleted else "删除失败") + return _SchemaResponse(success=deleted, message="删除成功" if deleted else "删除失败") @router.post( "/sessions/{session_id}/stop", summary="停止 Web 智能助手当前任务", - response_model=schemas.Response[schemas.AgentSessionStopData], + response_model=_SchemaResponse[_SchemaAgentSessionStopData], ) async def stop_web_agent_session_task( session_id: str, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_async_db), -) -> schemas.Response: +) -> _SchemaResponse: """ 停止当前 Web 智能助手会话正在执行的任务。 @@ -1890,11 +1904,11 @@ async def stop_web_agent_session_task( if not chat and server_session_id != session_id: chat = await _get_accessible_agent_chat(AgentChatOper(db), session_id, current_user) if chat and not _can_access_agent_chat(chat, current_user): - return schemas.Response(success=False, message="会话不存在或无权访问") + return _SchemaResponse(success=False, message="会话不存在或无权访问") manager = get_running_agent_manager() stopped = await manager.stop_current_task(server_session_id) if manager else False - return schemas.Response( + return _SchemaResponse( success=True, data={"stopped": stopped}, message="已停止" if stopped else "当前没有正在执行的任务", @@ -1914,7 +1928,7 @@ async def stop_web_agent_session_task( }, ) async def web_agent_stream( - payload: schemas.AgentWebChatRequest, + payload: _SchemaAgentWebChatRequest, request: Request, current_user: User = Depends(get_current_active_user), ) -> StreamingResponse: @@ -2170,7 +2184,7 @@ async def web_agent_stream( _apply_web_agent_display_event(item, assistant_display_message) event_publisher.publish(item) - def message_callback(message: schemas.Message) -> None: + def message_callback(message: _SchemaMessage) -> None: """ 接收 Agent 工具主动发送的 Web 通知。 """ diff --git a/app/api/endpoints/anilist.py b/app/api/endpoints/anilist.py index 97813086b..fdd7b7057 100644 --- a/app/api/endpoints/anilist.py +++ b/app/api/endpoints/anilist.py @@ -2,7 +2,9 @@ from typing import Annotated, Optional from fastapi import Depends, Query -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.anilist import AniListChain from app.domain.context import MediaInfo @@ -14,26 +16,26 @@ PageParam = Annotated[int, Query(ge=1)] CountParam = Annotated[int, Query(ge=1, le=50)] -def _serialize_medias(medias: list[MediaInfo]) -> list[schemas.MediaInfo]: +def _serialize_medias(medias: list[MediaInfo]) -> list[_SchemaMediaInfo]: """ 将内部媒体对象转换为 REST 响应模型。 :param medias: 统一媒体信息列表 :return: REST 媒体响应列表 """ - return [schemas.MediaInfo(**media.to_dict()) for media in medias] + return [_SchemaMediaInfo(**media.to_dict()) for media in medias] @router.get( "/trending", summary="查询 AniList 当前趋势榜", - response_model=list[schemas.MediaInfo], + response_model=list[_SchemaMediaInfo], ) async def anilist_trending( page: PageParam = 1, count: CountParam = 20, - _: schemas.TokenPayload = Depends(verify_token), -) -> list[schemas.MediaInfo]: + _: _SchemaTokenPayload = Depends(verify_token), +) -> list[_SchemaMediaInfo]: """查询 AniList TRENDING NOW 榜单""" medias = await AniListChain().async_trending(page=page, count=count) return _serialize_medias(medias) @@ -42,13 +44,13 @@ async def anilist_trending( @router.get( "/popular-this-season", summary="查询 AniList 本季热门榜", - response_model=list[schemas.MediaInfo], + response_model=list[_SchemaMediaInfo], ) async def anilist_popular_this_season( page: PageParam = 1, count: CountParam = 20, - _: schemas.TokenPayload = Depends(verify_token), -) -> list[schemas.MediaInfo]: + _: _SchemaTokenPayload = Depends(verify_token), +) -> list[_SchemaMediaInfo]: """查询 AniList POPULAR THIS SEASON 榜单""" medias = await AniListChain().async_popular_this_season(page=page, count=count) return _serialize_medias(medias) @@ -57,7 +59,7 @@ async def anilist_popular_this_season( @router.get( "/discover", summary="探索 AniList 动画", - response_model=list[schemas.MediaInfo], + response_model=list[_SchemaMediaInfo], ) async def anilist_discover( page: PageParam = 1, @@ -70,8 +72,8 @@ async def anilist_discover( status: Optional[str] = None, country: Optional[str] = None, sort: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), -) -> list[schemas.MediaInfo]: + _: _SchemaTokenPayload = Depends(verify_token), +) -> list[_SchemaMediaInfo]: """按标题、类型、风格、季度、年份、状态、地区和排序探索 AniList 动画""" medias = await AniListChain().async_discover( page=page, @@ -91,14 +93,14 @@ async def anilist_discover( @router.get( "/credits/{anilist_id}", summary="查询 AniList 配音演员", - response_model=list[schemas.MediaPerson], + response_model=list[_SchemaMediaPerson], ) async def anilist_credits( anilist_id: int, page: PageParam = 1, count: CountParam = 20, - _: schemas.TokenPayload = Depends(verify_token), -) -> list[schemas.MediaPerson]: + _: _SchemaTokenPayload = Depends(verify_token), +) -> list[_SchemaMediaPerson]: """查询 AniList 动画的日语配音演员""" return await AniListChain().async_credits( anilist_id=anilist_id, page=page, count=count @@ -108,14 +110,14 @@ async def anilist_credits( @router.get( "/recommend/{anilist_id}", summary="查询 AniList 相关推荐", - response_model=list[schemas.MediaInfo], + response_model=list[_SchemaMediaInfo], ) async def anilist_recommendations( anilist_id: int, page: PageParam = 1, count: CountParam = 20, - _: schemas.TokenPayload = Depends(verify_token), -) -> list[schemas.MediaInfo]: + _: _SchemaTokenPayload = Depends(verify_token), +) -> list[_SchemaMediaInfo]: """查询 AniList 动画相关推荐""" medias = await AniListChain().async_recommendations( anilist_id=anilist_id, page=page, count=count @@ -126,12 +128,12 @@ async def anilist_recommendations( @router.get( "/person/{person_id}", summary="查询 AniList 人物详情", - response_model=schemas.MediaPerson, + response_model=_SchemaMediaPerson, ) async def anilist_person( person_id: int, - _: schemas.TokenPayload = Depends(verify_token), -) -> Optional[schemas.MediaPerson]: + _: _SchemaTokenPayload = Depends(verify_token), +) -> Optional[_SchemaMediaPerson]: """根据 AniList 人物 ID 查询详情""" return await AniListChain().async_person_detail(person_id=person_id) @@ -139,14 +141,14 @@ async def anilist_person( @router.get( "/person/credits/{person_id}", summary="查询 AniList 人物作品", - response_model=list[schemas.MediaInfo], + response_model=list[_SchemaMediaInfo], ) async def anilist_person_credits( person_id: int, page: PageParam = 1, count: CountParam = 20, - _: schemas.TokenPayload = Depends(verify_token), -) -> list[schemas.MediaInfo]: + _: _SchemaTokenPayload = Depends(verify_token), +) -> list[_SchemaMediaInfo]: """查询 AniList 人物参与的动画作品""" medias = await AniListChain().async_person_credits( person_id=person_id, page=page, count=count @@ -157,14 +159,14 @@ async def anilist_person_credits( @router.get( "/{anilist_id}", summary="查询 AniList 动画详情", - response_model=schemas.MediaInfo, + response_model=_SchemaMediaInfo, ) async def anilist_info( anilist_id: int, - _: schemas.TokenPayload = Depends(verify_token), -) -> schemas.MediaInfo: + _: _SchemaTokenPayload = Depends(verify_token), +) -> _SchemaMediaInfo: """根据 AniList 媒体 ID 查询动画详情""" info = await AniListChain().async_info(anilist_id) if not info: - return schemas.MediaInfo() - return schemas.MediaInfo(**MediaInfo(anilist_info=info).to_dict()) + return _SchemaMediaInfo() + return _SchemaMediaInfo(**MediaInfo(anilist_info=info).to_dict()) diff --git a/app/api/endpoints/anthropic.py b/app/api/endpoints/anthropic.py index f18cbaf97..723704742 100644 --- a/app/api/endpoints/anthropic.py +++ b/app/api/endpoints/anthropic.py @@ -6,7 +6,11 @@ from typing import AsyncIterator, List, Optional from fastapi import APIRouter, Header, Security from fastapi.responses import JSONResponse, StreamingResponse -from app import schemas +from app.schemas.openai import AnthropicErrorDetail as _SchemaAnthropicErrorDetail +from app.schemas.openai import AnthropicErrorResponse as _SchemaAnthropicErrorResponse +from app.schemas.openai import AnthropicMessagesRequest as _SchemaAnthropicMessagesRequest +from app.schemas.openai import AnthropicMessagesResponse as _SchemaAnthropicMessagesResponse +from app.schemas.openai import AnthropicTextBlock as _SchemaAnthropicTextBlock from app.api.endpoints.openai import ( MODEL_ID, _is_manager_unavailable, @@ -22,11 +26,11 @@ from app.runtime.config import settings from app.application.security.access import anthropic_api_key_header ANTHROPIC_ERROR_RESPONSES = { - 400: {"model": schemas.AnthropicErrorResponse, "description": "请求格式错误"}, - 401: {"model": schemas.AnthropicErrorResponse, "description": "认证失败"}, - 422: {"model": schemas.AnthropicErrorResponse, "description": "请求参数校验失败"}, - 500: {"model": schemas.AnthropicErrorResponse, "description": "服务内部错误"}, - 503: {"model": schemas.AnthropicErrorResponse, "description": "AI Agent 不可用"}, + 400: {"model": _SchemaAnthropicErrorResponse, "description": "请求格式错误"}, + 401: {"model": _SchemaAnthropicErrorResponse, "description": "认证失败"}, + 422: {"model": _SchemaAnthropicErrorResponse, "description": "请求参数校验失败"}, + 500: {"model": _SchemaAnthropicErrorResponse, "description": "服务内部错误"}, + 503: {"model": _SchemaAnthropicErrorResponse, "description": "AI Agent 不可用"}, } router = APIRouter(responses=ANTHROPIC_ERROR_RESPONSES) @@ -41,8 +45,8 @@ def _anthropic_error_response( ) -> JSONResponse: return JSONResponse( status_code=status_code, - content=schemas.AnthropicErrorResponse( - error=schemas.AnthropicErrorDetail(type=error_type, message=message) + content=_SchemaAnthropicErrorResponse( + error=_SchemaAnthropicErrorDetail(type=error_type, message=message) ).model_dump(), ) @@ -126,7 +130,7 @@ async def _stream_anthropic_response( @router.post( "/messages", summary="Anthropic compatible messages", - response_model=schemas.AnthropicMessagesResponse, + response_model=_SchemaAnthropicMessagesResponse, responses={ 200: { "description": "Anthropic message 或 SSE 数据流", @@ -137,7 +141,7 @@ async def _stream_anthropic_response( }, ) async def messages( - payload: schemas.AnthropicMessagesRequest, + payload: _SchemaAnthropicMessagesRequest, x_api_key: Optional[str] = Security(anthropic_api_key_header), anthropic_version: Optional[str] = Header(default=None, alias="anthropic-version"), ): @@ -217,8 +221,8 @@ async def messages( if not content: content = "未获得有效回复。" - return schemas.AnthropicMessagesResponse( + return _SchemaAnthropicMessagesResponse( id=f"msg_{uuid.uuid4().hex}", - content=[schemas.AnthropicTextBlock(text=content)], + content=[_SchemaAnthropicTextBlock(text=content)], model=MODEL_ID, ) diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py index 9e732d862..207286316 100644 --- a/app/api/endpoints/auth.py +++ b/app/api/endpoints/auth.py @@ -3,7 +3,8 @@ from typing import Any from fastapi import HTTPException from pydantic import BaseModel -from app import schemas +from app.schemas.token import Token as _SchemaToken +from app.schemas.user import AuthProviderInfo as _SchemaAuthProviderInfo from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter from app.application.security.auth import build_token_response, consume_plugin_auth_ticket from app.runtime.extensions.plugin_manager import PluginManager @@ -43,7 +44,7 @@ def _system_auth_providers() -> list[dict[str, Any]]: @router.get( "/providers", summary="查询登录认证提供方", - response_model=list[schemas.AuthProviderInfo], + response_model=list[_SchemaAuthProviderInfo], ) def auth_providers() -> list[dict[str, Any]]: """ @@ -59,10 +60,10 @@ def auth_providers() -> list[dict[str, Any]]: @router.post( "/exchange", summary="兑换插件认证登录票据", - response_model=schemas.Token, + response_model=_SchemaToken, openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True}, ) -def auth_exchange(body: AuthExchangeRequest) -> schemas.Token: +def auth_exchange(body: AuthExchangeRequest) -> _SchemaToken: """ 将插件认证成功后生成的一次性票据兑换为系统 Token。 diff --git a/app/api/endpoints/bangumi.py b/app/api/endpoints/bangumi.py index 9f7b7f856..6aad4908f 100644 --- a/app/api/endpoints/bangumi.py +++ b/app/api/endpoints/bangumi.py @@ -2,7 +2,9 @@ from typing import List, Any, Optional from fastapi import Depends -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.bangumi import BangumiChain from app.domain.context import MediaInfo @@ -14,13 +16,13 @@ router = ResponseAPIRouter() @router.get( "/credits/{bangumiid}", summary="查询Bangumi演职员表", - response_model=List[schemas.MediaPerson], + response_model=List[_SchemaMediaPerson], ) async def bangumi_credits( bangumiid: int, page: Optional[int] = 1, count: Optional[int] = 20, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 查询Bangumi演职员表 @@ -34,13 +36,13 @@ async def bangumi_credits( @router.get( "/recommend/{bangumiid}", summary="查询Bangumi推荐", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def bangumi_recommend( bangumiid: int, page: Optional[int] = 1, count: Optional[int] = 20, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 查询Bangumi推荐 @@ -52,10 +54,10 @@ async def bangumi_recommend( @router.get( - "/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson + "/person/{person_id}", summary="人物详情", response_model=_SchemaMediaPerson ) async def bangumi_person( - person_id: int, _: schemas.TokenPayload = Depends(verify_token) + person_id: int, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 根据人物ID查询人物详情 @@ -66,13 +68,13 @@ async def bangumi_person( @router.get( "/person/credits/{person_id}", summary="人物参演作品", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def bangumi_person_credits( person_id: int, page: Optional[int] = 1, count: Optional[int] = 20, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据人物ID查询人物参演作品 @@ -83,9 +85,9 @@ async def bangumi_person_credits( return [] -@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo) +@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=_SchemaMediaInfo) async def bangumi_info( - bangumiid: int, _: schemas.TokenPayload = Depends(verify_token) + bangumiid: int, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 查询Bangumi详情 @@ -94,4 +96,4 @@ async def bangumi_info( if info: return MediaInfo(bangumi_info=info).to_dict() else: - return schemas.MediaInfo() + return _SchemaMediaInfo() diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index 72cd0ec38..67057838d 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -4,7 +4,15 @@ from typing import Any, List, Optional, Annotated from fastapi import Depends from sqlalchemy.orm import Session -from app import schemas +from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo +from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo +from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo +from app.schemas.dashboard import ProcessInfo as _SchemaProcessInfo +from app.schemas.dashboard import ScheduleInfo as _SchemaScheduleInfo +from app.schemas.dashboard import ScheduleProgress as _SchemaScheduleProgress +from app.schemas.dashboard import Statistic as _SchemaStatistic +from app.schemas.dashboard import Storage as _SchemaStorage +from app.schemas.response import Response as _SchemaResponse from app.api.response import ResponseAPIRouter from app.chain.dashboard import DashboardChain from app.chain.storage import StorageChain @@ -21,16 +29,16 @@ from app.adapters.system.host import SystemUtils router = ResponseAPIRouter() -def _build_statistic(db: Session, name: Optional[str] = None) -> schemas.Statistic: +def _build_statistic(db: Session, name: Optional[str] = None) -> _SchemaStatistic: """ 构建媒体数量统计信息。 """ - media_statistics: Optional[List[schemas.Statistic]] = ( + media_statistics: Optional[List[_SchemaStatistic]] = ( DashboardChain().media_statistic(name) ) if media_statistics: # 汇总各媒体库统计信息 - ret_statistic = schemas.Statistic() + ret_statistic = _SchemaStatistic() has_episode_count = False for media_statistic in media_statistics: ret_statistic.movie_count += media_statistic.movie_count or 0 @@ -44,7 +52,7 @@ def _build_statistic(db: Session, name: Optional[str] = None) -> schemas.Statist # 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。 ret_statistic.episode_count = None else: - ret_statistic = schemas.Statistic() + ret_statistic = _SchemaStatistic() movie_count_month, tv_count_month, episode_count_month, music_count_month = ( TransferHistory.monthly_media_statistics(db) @@ -56,14 +64,14 @@ def _build_statistic(db: Session, name: Optional[str] = None) -> schemas.Statist return ret_statistic -def _build_storage() -> schemas.Storage: +def _build_storage() -> _SchemaStorage: """ 构建本地存储空间信息。 """ total, available = 0, 0 dirs = DirectoryHelper().get_dirs() if not dirs: - return schemas.Storage(total_storage=total, used_storage=total - available) + return _SchemaStorage(total_storage=total, used_storage=total - available) # 下载目录按 storage、媒体库目录按 library_storage 汇总存储集合, # 用 set 去重存储名,避免同一存储被重复统计; # 各存储的 usage 内部已按磁盘(st_dev / Btrfs FSID)去重,相同磁盘的不同目录不会重复累加。 @@ -77,10 +85,10 @@ def _build_storage() -> schemas.Storage: if _usage: total += _usage.get("total") or 0 available += _usage.get("available") or 0 - return schemas.Storage(total_storage=total, used_storage=total - available) + return _SchemaStorage(total_storage=total, used_storage=total - available) -def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo: +def _build_downloader(name: Optional[str] = None) -> _SchemaDownloaderInfo: """ 构建下载器统计信息。 """ @@ -91,7 +99,7 @@ def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo: btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP, ) # 下载器信息 - downloader_info = schemas.DownloaderInfo() + downloader_info = _SchemaDownloaderInfo() transfer_infos = DashboardChain().downloader_info(name) if transfer_infos: for transfer_info in transfer_infos: @@ -103,7 +111,7 @@ def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo: return downloader_info -@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic) +@router.get("/statistic", summary="媒体数量统计", response_model=_SchemaStatistic) def statistic( name: Optional[str] = None, db: Session = Depends(get_db), @@ -116,7 +124,7 @@ def statistic( @router.get( - "/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic + "/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=_SchemaStatistic ) def statistic2( _: Annotated[str, Depends(verify_apitoken)], @@ -128,7 +136,7 @@ def statistic2( return _build_statistic(db) -@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage) +@router.get("/storage", summary="本地存储空间", response_model=_SchemaStorage) def storage(_: Any = Depends(get_current_active_superuser)) -> Any: """ 查询本地存储空间信息 @@ -137,7 +145,7 @@ def storage(_: Any = Depends(get_current_active_superuser)) -> Any: @router.get( - "/storage2", summary="本地存储空间(API_TOKEN)", response_model=schemas.Storage + "/storage2", summary="本地存储空间(API_TOKEN)", response_model=_SchemaStorage ) def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ @@ -146,7 +154,7 @@ def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: return _build_storage() -@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo]) +@router.get("/processes", summary="进程信息", response_model=List[_SchemaProcessInfo]) def processes(_: Any = Depends(get_current_active_superuser)) -> Any: """ 查询进程信息 @@ -154,7 +162,7 @@ def processes(_: Any = Depends(get_current_active_superuser)) -> Any: return SystemUtils.processes() -@router.get("/system", summary="系统摘要信息", response_model=schemas.DashboardSystemInfo) +@router.get("/system", summary="系统摘要信息", response_model=_SchemaDashboardSystemInfo) def system_info(_: Any = Depends(get_current_active_superuser)) -> Any: """ 查询仪表板系统摘要信息 @@ -162,7 +170,7 @@ def system_info(_: Any = Depends(get_current_active_superuser)) -> Any: return SystemUtils.dashboard_system_info() -@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo) +@router.get("/downloader", summary="下载器信息", response_model=_SchemaDownloaderInfo) def downloader( name: Optional[str] = None, _: Any = Depends(get_current_active_superuser) ) -> Any: @@ -175,7 +183,7 @@ def downloader( @router.get( "/downloader2", summary="下载器信息(API_TOKEN)", - response_model=schemas.DownloaderInfo, + response_model=_SchemaDownloaderInfo, ) def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ @@ -184,7 +192,7 @@ def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: return _build_downloader() -@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo]) +@router.get("/schedule", summary="后台服务", response_model=List[_SchemaScheduleInfo]) async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any: """ 查询后台服务信息 @@ -195,7 +203,7 @@ async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any: @router.get( "/schedule/{job_id}/progress", summary="后台服务进度", - response_model=schemas.Response[schemas.ScheduleProgress], + response_model=_SchemaResponse[_SchemaScheduleProgress], ) async def schedule_progress( job_id: str, _: Any = Depends(get_current_active_superuser) @@ -205,14 +213,14 @@ async def schedule_progress( """ progress = Scheduler().get_progress(job_id) if not progress: - return schemas.Response(success=False, message="后台服务不存在") - return schemas.Response(success=True, data=progress.model_dump()) + return _SchemaResponse(success=False, message="后台服务不存在") + return _SchemaResponse(success=True, data=progress.model_dump()) @router.get( "/schedule2", summary="后台服务(API_TOKEN)", - response_model=List[schemas.ScheduleInfo], + response_model=List[_SchemaScheduleInfo], ) async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ @@ -224,7 +232,7 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: @router.get( "/schedule2/{job_id}/progress", summary="后台服务进度(API_TOKEN)", - response_model=schemas.Response[schemas.ScheduleProgress], + response_model=_SchemaResponse[_SchemaScheduleProgress], ) async def schedule_progress2( job_id: str, _: Annotated[str, Depends(verify_apitoken)] @@ -234,8 +242,8 @@ async def schedule_progress2( """ progress = Scheduler().get_progress(job_id) if not progress: - return schemas.Response(success=False, message="后台服务不存在") - return schemas.Response(success=True, data=progress.model_dump()) + return _SchemaResponse(success=False, message="后台服务不存在") + return _SchemaResponse(success=True, data=progress.model_dump()) @router.get("/transfer", summary="文件整理统计", response_model=List[int]) @@ -270,7 +278,7 @@ def cpu2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: @router.get( "/memory", summary="获取当前应用与系统内存信息", - response_model=schemas.DashboardMemoryInfo, + response_model=_SchemaDashboardMemoryInfo, ) def memory(_: Any = Depends(get_current_active_superuser)) -> Any: """ @@ -282,7 +290,7 @@ def memory(_: Any = Depends(get_current_active_superuser)) -> Any: @router.get( "/memory2", summary="获取当前应用与系统内存信息(API_TOKEN)", - response_model=schemas.DashboardMemoryInfo, + response_model=_SchemaDashboardMemoryInfo, ) def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ diff --git a/app/api/endpoints/discover.py b/app/api/endpoints/discover.py index 15c81a4a3..ea7adc0f3 100644 --- a/app/api/endpoints/discover.py +++ b/app/api/endpoints/discover.py @@ -2,14 +2,16 @@ from typing import Any, List, Optional from fastapi import Depends -from app import schemas +from app.schemas.event import DiscoverMediaSource as _SchemaDiscoverMediaSource +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.bangumi import BangumiChain from app.chain.douban import DoubanChain from app.chain.tmdb import TmdbChain from app.runtime.events import eventmanager from app.application.security.access import verify_token -from app.schemas import DiscoverSourceEventData +from app.schemas.event import DiscoverSourceEventData from app.schemas.types import ChainEventType, MediaType router = ResponseAPIRouter() @@ -18,9 +20,9 @@ router = ResponseAPIRouter() @router.get( "/source", summary="获取探索数据源", - response_model=List[schemas.DiscoverMediaSource], + response_model=List[_SchemaDiscoverMediaSource], ) -def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +def source(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 获取探索数据源 """ @@ -35,7 +37,7 @@ def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any: return [] -@router.get("/bangumi", summary="探索Bangumi", response_model=List[schemas.MediaInfo]) +@router.get("/bangumi", summary="探索Bangumi", response_model=List[_SchemaMediaInfo]) async def bangumi( type: Optional[int] = 2, cat: Optional[int] = None, @@ -43,7 +45,7 @@ async def bangumi( year: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 探索Bangumi @@ -57,14 +59,14 @@ async def bangumi( @router.get( - "/douban_movies", summary="探索豆瓣电影", response_model=List[schemas.MediaInfo] + "/douban_movies", summary="探索豆瓣电影", response_model=List[_SchemaMediaInfo] ) async def douban_movies( sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 浏览豆瓣电影信息 @@ -76,14 +78,14 @@ async def douban_movies( @router.get( - "/douban_tvs", summary="探索豆瓣剧集", response_model=List[schemas.MediaInfo] + "/douban_tvs", summary="探索豆瓣剧集", response_model=List[_SchemaMediaInfo] ) async def douban_tvs( sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 浏览豆瓣剧集信息 @@ -95,7 +97,7 @@ async def douban_tvs( @router.get( - "/tmdb_movies", summary="探索TMDB电影", response_model=List[schemas.MediaInfo] + "/tmdb_movies", summary="探索TMDB电影", response_model=List[_SchemaMediaInfo] ) async def tmdb_movies( sort_by: Optional[str] = "popularity.desc", @@ -107,7 +109,7 @@ async def tmdb_movies( vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 浏览TMDB电影信息 @@ -127,7 +129,7 @@ async def tmdb_movies( return [movie.to_dict() for movie in movies] if movies else [] -@router.get("/tmdb_tvs", summary="探索TMDB剧集", response_model=List[schemas.MediaInfo]) +@router.get("/tmdb_tvs", summary="探索TMDB剧集", response_model=List[_SchemaMediaInfo]) async def tmdb_tvs( sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", @@ -138,7 +140,7 @@ async def tmdb_tvs( vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 浏览TMDB剧集信息 diff --git a/app/api/endpoints/douban.py b/app/api/endpoints/douban.py index 3d7062d32..9e0f68b81 100644 --- a/app/api/endpoints/douban.py +++ b/app/api/endpoints/douban.py @@ -2,21 +2,23 @@ from typing import Any, List, Optional from fastapi import Depends -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.douban import DoubanChain from app.domain.context import MediaInfo from app.application.security.access import verify_token -from app.schemas import MediaType +from app.schemas.types import MediaType router = ResponseAPIRouter() @router.get( - "/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson + "/person/{person_id}", summary="人物详情", response_model=_SchemaMediaPerson ) async def douban_person( - person_id: int, _: schemas.TokenPayload = Depends(verify_token) + person_id: int, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 根据人物ID查询人物详情 @@ -27,12 +29,12 @@ async def douban_person( @router.get( "/person/credits/{person_id}", summary="人物参演作品", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def douban_person_credits( person_id: int, page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据人物ID查询人物参演作品 @@ -46,10 +48,10 @@ async def douban_person_credits( @router.get( "/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", - response_model=List[schemas.MediaPerson], + response_model=List[_SchemaMediaPerson], ) async def douban_credits( - doubanid: str, type_name: str, _: schemas.TokenPayload = Depends(verify_token) + doubanid: str, type_name: str, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 根据豆瓣ID查询演员阵容,type_name: 电影/电视剧 @@ -65,10 +67,10 @@ async def douban_credits( @router.get( "/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def douban_recommend( - doubanid: str, type_name: str, _: schemas.TokenPayload = Depends(verify_token) + doubanid: str, type_name: str, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 根据豆瓣ID查询推荐电影/电视剧,type_name: 电影/电视剧 @@ -85,9 +87,9 @@ async def douban_recommend( return [] -@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo) +@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=_SchemaMediaInfo) async def douban_info( - doubanid: str, _: schemas.TokenPayload = Depends(verify_token) + doubanid: str, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 根据豆瓣ID查询豆瓣媒体信息 @@ -96,4 +98,4 @@ async def douban_info( if doubaninfo: return MediaInfo(douban_info=doubaninfo).to_dict() else: - return schemas.MediaInfo() + return _SchemaMediaInfo() diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 9962ba3e2..b4023f0f2 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -2,7 +2,18 @@ from typing import Any, List, Annotated, Optional, Union from fastapi import Depends, Body -from app import schemas +from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo +from app.schemas.download import DownloadAddedData as _SchemaDownloadAddedData +from app.schemas.download import DownloadDirectory as _SchemaDownloadDirectory +from app.schemas.download import SubtitleDownloadData as _SchemaSubtitleDownloadData +from app.schemas.file import FileURI as _SchemaFileURI +from app.schemas.response import Response as _SchemaResponse +from app.schemas.search import SubtitleInfo as _SchemaSubtitleInfo +from app.schemas.system import TorrentInfo as _SchemaTorrentInfo +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.transfer import DownloaderTorrent as _SchemaDownloaderTorrent +from app.schemas.transfer import MusicInfo as _SchemaMusicInfo +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.download import DownloadChain from app.chain.media import MediaChain @@ -53,9 +64,9 @@ def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]: return True, "" -@router.get("/", summary="正在下载", response_model=List[schemas.DownloaderTorrent]) +@router.get("/", summary="正在下载", response_model=List[_SchemaDownloaderTorrent]) def current( - name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token) + name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 查询正在下载的任务 @@ -66,11 +77,11 @@ def current( @router.post( "/", summary="添加下载(含媒体信息)", - response_model=schemas.Response[schemas.DownloadAddedData], + response_model=_SchemaResponse[_SchemaDownloadAddedData], ) def download( - media_in: Union[schemas.MusicInfo, schemas.MediaInfo], - torrent_in: schemas.TorrentInfo, + media_in: Union[_SchemaMusicInfo, _SchemaMediaInfo], + torrent_in: _SchemaTorrentInfo, downloader: Annotated[str | None, Body()] = None, save_path: Annotated[str | None, Body()] = None, current_user: User = Depends(get_current_active_user), @@ -78,7 +89,7 @@ def download( """ 添加下载任务(含媒体信息) """ - if isinstance(media_in, schemas.MusicInfo): + if isinstance(media_in, _SchemaMusicInfo): mediainfo = MusicInfo.from_dict(media_in.model_dump()) metainfo = MetaMusic.from_music_info(mediainfo) metainfo.org_string = torrent_in.title @@ -102,17 +113,17 @@ def download( source="Manual", ) if not did: - return schemas.Response(success=False, message="任务添加失败") - return schemas.Response(success=True, data={"download_id": did}) + return _SchemaResponse(success=False, message="任务添加失败") + return _SchemaResponse(success=True, data={"download_id": did}) @router.post( "/add", summary="添加下载(不含媒体信息)", - response_model=schemas.Response[schemas.DownloadAddedData], + response_model=_SchemaResponse[_SchemaDownloadAddedData], ) def add( - torrent_in: schemas.TorrentInfo, + torrent_in: _SchemaTorrentInfo, media_source: Annotated[MediaSource | None, Body()] = None, media_id: Annotated[str | None, Body()] = None, music_type: Annotated[MusicTargetEntityType | None, Body()] = None, @@ -126,12 +137,12 @@ def add( """ normalized_music_type = normalize_music_type(music_type, allow_artist=False) if music_type is not None and not normalized_music_type: - return schemas.Response( + return _SchemaResponse( success=False, message="音乐实体类型无效,仅支持 recording 或 album", ) if (media_source is None) != (media_id is None): - return schemas.Response( + return _SchemaResponse( success=False, message="媒体来源和媒体 ID 必须同时提供", ) @@ -141,7 +152,7 @@ def add( or normalized_music_type is not None ) if is_music and media_source and not is_music_media_source(media_source): - return schemas.Response( + return _SchemaResponse( success=False, message="音乐下载只能使用音乐元数据源", ) @@ -171,7 +182,7 @@ def add( music_type=normalized_music_type, ) if not mediainfo: - return schemas.Response(success=False, message="无法识别媒体信息") + return _SchemaResponse(success=False, message="无法识别媒体信息") # 种子信息 torrentinfo = TorrentInfo() torrentinfo.from_dict(torrent_in.model_dump()) @@ -188,17 +199,17 @@ def add( source="Manual", ) if not did: - return schemas.Response(success=False, message="任务添加失败") - return schemas.Response(success=True, data={"download_id": did}) + return _SchemaResponse(success=False, message="任务添加失败") + return _SchemaResponse(success=True, data={"download_id": did}) @router.post( "/subtitle", summary="下载字幕", - response_model=schemas.Response[schemas.SubtitleDownloadData], + response_model=_SchemaResponse[_SchemaSubtitleDownloadData], ) def download_subtitle( - subtitle_in: schemas.SubtitleInfo, + subtitle_in: _SchemaSubtitleInfo, media_source: Annotated[MediaSource, Body()], media_id: Annotated[str, Body()], save_path: Annotated[str | None, Body()] = None, @@ -211,7 +222,7 @@ def download_subtitle( subtitle_info.from_dict(subtitle_in.model_dump()) valid, message = _prepare_subtitle_download(subtitle_info) if not valid: - return schemas.Response(success=False, message=message) + return _SchemaResponse(success=False, message=message) success, message, saved_files = DownloadChain().download_subtitle( subtitle=subtitle_info, @@ -220,45 +231,45 @@ def download_subtitle( save_path=save_path, username=current_user.name, ) - return schemas.Response( + return _SchemaResponse( success=success, message=message, data={"files": saved_files} if saved_files else None, ) -@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response[None]) +@router.get("/start/{hashString}", summary="开始任务", response_model=_SchemaResponse[None]) def start( hashString: str, name: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 开如下载任务 """ ret = DownloadChain().set_downloading(hashString, "start", name=name) - return schemas.Response(success=True if ret else False) + return _SchemaResponse(success=True if ret else False) -@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response[None]) +@router.get("/stop/{hashString}", summary="暂停任务", response_model=_SchemaResponse[None]) def stop( hashString: str, name: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 暂停下载任务 """ ret = DownloadChain().set_downloading(hashString, "stop", name=name) - return schemas.Response(success=True if ret else False) + return _SchemaResponse(success=True if ret else False) @router.get( "/clients", summary="查询可用下载器", - response_model=List[schemas.ServiceClientInfo], + response_model=List[_SchemaServiceClientInfo], ) -async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def clients(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询可用下载器 """ @@ -273,18 +284,18 @@ async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get( - "/paths", summary="查询可用下载路径", response_model=List[schemas.DownloadDirectory] + "/paths", summary="查询可用下载路径", response_model=List[_SchemaDownloadDirectory] ) -def paths(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +def paths(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询可直接用于下载接口 save_path 参数的下载路径 """ return [ - schemas.DownloadDirectory( + _SchemaDownloadDirectory( name=dir_info.name, storage=dir_info.storage or "local", download_path=dir_info.download_path, - save_path=schemas.FileURI( + save_path=_SchemaFileURI( storage=dir_info.storage or "local", path=dir_info.download_path, ).uri, @@ -297,14 +308,14 @@ def paths(_: schemas.TokenPayload = Depends(verify_token)) -> Any: ] -@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response[None]) +@router.delete("/{hashString}", summary="删除下载任务", response_model=_SchemaResponse[None]) def delete( hashString: str, name: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 删除下载任务 """ ret = DownloadChain().remove_downloading(hashString, name=name) - return schemas.Response(success=True if ret else False) + return _SchemaResponse(success=True if ret else False) diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index d14cdb13d..69cc7b352 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -1,13 +1,19 @@ import asyncio import time -from pathlib import Path from typing import List, Any, Optional from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session -from app import schemas +from app.schemas.common import BatchProgressKeyData as _SchemaBatchProgressKeyData +from app.schemas.common import ProgressKeyData as _SchemaProgressKeyData +from app.schemas.history import BatchTransferHistoryRedoRequest as _SchemaBatchTransferHistoryRedoRequest +from app.schemas.history import TransferHistory as _SchemaTransferHistory +from app.schemas.history import TransferHistoryPage as _SchemaTransferHistoryPage +from app.schemas.response import Response as _SchemaResponse +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.history import DownloadHistory as _SchemaDownloadHistory from app.api.response import ResponseAPIRouter from app.agent.contracts import ReplyMode from app.agent.runtime_loader import get_running_agent_manager @@ -15,22 +21,23 @@ from app.agent.prompt.transfer_redo import ( build_batch_manual_redo_prompt, build_manual_redo_prompt, ) -from app.chain.storage import StorageChain from app.runtime.config import settings, global_vars -from app.runtime.events import eventmanager from app.application.security.access import verify_token from app.db import get_async_db, get_db from app.db.models import User -from app.db.models.downloadhistory import DownloadHistory, DownloadFiles +from app.db.models.downloadhistory import DownloadHistory from app.db.models.transferhistory import TransferHistory from app.api.deps import ( get_current_active_manage_user, get_current_active_superuser, - get_current_active_superuser_async, + get_download_history_mutation_command, + get_transfer_history_mutation_command, ) from app.runtime.progress import ProgressHelper -from app.application.history import clear_transfer_failures -from app.schemas.types import EventType +from app.application.history import ( + DownloadHistoryMutationCommand, + TransferHistoryMutationCommand, +) from app.foundation.text import cut as jieba_cut from app.runtime.log import logger @@ -143,13 +150,13 @@ def _start_batch_ai_redo_task( @router.get( "/download", summary="查询下载历史记录", - response_model=List[schemas.DownloadHistory], + response_model=List[_SchemaDownloadHistory], ) async def download_history( page: Optional[int] = 1, count: Optional[int] = 30, db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 按下载时间倒序查询下载历史记录 @@ -160,18 +167,20 @@ async def download_history( @router.delete( "/download", summary="删除下载历史记录", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) -async def delete_download_history( - history_in: schemas.DownloadHistory, - db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token), +def delete_download_history( + history_in: _SchemaDownloadHistory, + command: DownloadHistoryMutationCommand = Depends( + get_download_history_mutation_command + ), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 删除下载历史记录 """ - await DownloadHistory.async_delete(db, history_in.id) - return schemas.Response(success=True) + result = command.delete(history_in.id) + return _SchemaResponse(success=result.success, message=result.message) def _glob_to_like(pattern: str) -> str: @@ -185,7 +194,7 @@ def _glob_to_like(pattern: str) -> str: @router.get( "/transfer", summary="查询整理记录", - response_model=schemas.Response[schemas.TransferHistoryPage], + response_model=_SchemaResponse[_SchemaTransferHistoryPage], ) async def transfer_history( title: Optional[str] = None, @@ -193,7 +202,7 @@ async def transfer_history( count: Optional[int] = 30, status: Optional[bool] = None, db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 查询整理记录,title 支持通配符 * 和 ?(如 *.mkv、*2024*) @@ -229,7 +238,7 @@ async def transfer_history( ) total = await TransferHistory.async_count(db, status=status) - return schemas.Response( + return _SchemaResponse( success=True, data={ "list": [item.to_dict() for item in result], @@ -238,51 +247,31 @@ async def transfer_history( ) -@router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response[None]) +@router.delete("/transfer", summary="删除整理记录", response_model=_SchemaResponse[None]) def delete_transfer_history( - history_in: schemas.TransferHistory, + history_in: _SchemaTransferHistory, deletesrc: Optional[bool] = False, deletedest: Optional[bool] = False, - db: Session = Depends(get_db), + command: TransferHistoryMutationCommand = Depends( + get_transfer_history_mutation_command + ), _: User = Depends(get_current_active_manage_user), ) -> Any: """ - 删除整理记录 + 删除整理记录。 """ - history: TransferHistory = TransferHistory.get(db, history_in.id) - if not history: - return schemas.Response(success=False, message="记录不存在") - # 册除媒体库文件 - if deletedest and history.dest_fileitem: - dest_fileitem = schemas.FileItem(**history.dest_fileitem) - StorageChain().delete_media_file(dest_fileitem) - - # 删除源文件 - if deletesrc and history.src_fileitem: - src_fileitem = schemas.FileItem(**history.src_fileitem) - state = StorageChain().delete_media_file(src_fileitem) - if not state: - return schemas.Response( - success=False, message=f"{src_fileitem.path} 删除失败" - ) - # 删除下载记录中关联的文件 - DownloadFiles.delete_by_fullpath(db, Path(src_fileitem.path).as_posix()) - # 发送事件 - eventmanager.send_event( - EventType.DownloadFileDeleted, - {"src": history.src, "hash": history.download_hash}, - ) - # 删除记录 - TransferHistory.delete(db, history_in.id) - # 删除记录是用户显式要求重来,失败重试计数一并清零,否则重整仍会受上一轮次数限制 - clear_transfer_failures(history.src, history.src_storage) - return schemas.Response(success=True) + result = command.delete( + history_in.id, + delete_source=bool(deletesrc), + delete_destination=bool(deletedest), + ) + return _SchemaResponse(success=result.success, message=result.message) @router.post( "/transfer/{history_id}/ai-redo", summary="智能助手重新整理", - response_model=schemas.Response[schemas.ProgressKeyData], + response_model=_SchemaResponse[_SchemaProgressKeyData], ) def ai_redo_transfer_history( history_id: int, @@ -293,11 +282,11 @@ def ai_redo_transfer_history( 手动触发单条历史记录的 AI 重新整理,并返回进度键。 """ if not settings.AI_AGENT_ENABLE: - return schemas.Response(success=False, message="MoviePilot智能助手未启用") + return _SchemaResponse(success=False, message="MoviePilot智能助手未启用") history = TransferHistory.get(db, history_id) if not history: - return schemas.Response(success=False, message="整理记录不存在") + return _SchemaResponse(success=False, message="整理记录不存在") prompt = build_manual_redo_prompt(history) progress_key = f"ai_redo_transfer_{history_id}_{int(time.time() * 1000)}" @@ -307,16 +296,16 @@ def ai_redo_transfer_history( progress_key=progress_key, ) - return schemas.Response(success=True, data={"progress_key": progress_key}) + return _SchemaResponse(success=True, data={"progress_key": progress_key}) @router.post( "/transfer/ai-redo", summary="智能助手批量重新整理", - response_model=schemas.Response[schemas.BatchProgressKeyData], + response_model=_SchemaResponse[_SchemaBatchProgressKeyData], ) def batch_ai_redo_transfer_history( - payload: schemas.BatchTransferHistoryRedoRequest, + payload: _SchemaBatchTransferHistoryRedoRequest, db: Session = Depends(get_db), _: User = Depends(get_current_active_manage_user), ) -> Any: @@ -324,11 +313,11 @@ def batch_ai_redo_transfer_history( 手动触发多条历史记录的 AI 批量重新整理,并返回进度键。 """ if not settings.AI_AGENT_ENABLE: - return schemas.Response(success=False, message="MoviePilot智能助手未启用") + return _SchemaResponse(success=False, message="MoviePilot智能助手未启用") history_ids = normalize_history_ids(payload.history_ids) if not history_ids: - return schemas.Response(success=False, message="未提供有效的整理记录") + return _SchemaResponse(success=False, message="未提供有效的整理记录") histories = [] missing_ids = [] @@ -340,7 +329,7 @@ def batch_ai_redo_transfer_history( histories.append(history) if missing_ids: - return schemas.Response( + return _SchemaResponse( success=False, message="整理记录不存在: " + ", ".join(str(history_id) for history_id in missing_ids), @@ -354,7 +343,7 @@ def batch_ai_redo_transfer_history( progress_key=progress_key, ) - return schemas.Response( + return _SchemaResponse( success=True, data={"progress_key": progress_key, "history_ids": history_ids}, ) @@ -363,14 +352,16 @@ def batch_ai_redo_transfer_history( @router.get( "/empty/transfer", summary="清空整理记录", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) -async def empty_transfer_history( - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_superuser_async), +def empty_transfer_history( + command: TransferHistoryMutationCommand = Depends( + get_transfer_history_mutation_command + ), + _: User = Depends(get_current_active_superuser), ) -> Any: """ 清空整理记录 """ - await TransferHistory.async_truncate(db) - return schemas.Response(success=True) + result = command.truncate() + return _SchemaResponse(success=result.success, message=result.message) diff --git a/app/api/endpoints/llm.py b/app/api/endpoints/llm.py index 711332e2d..609febfb0 100644 --- a/app/api/endpoints/llm.py +++ b/app/api/endpoints/llm.py @@ -3,7 +3,8 @@ from typing import Any, Dict, List, Optional, Union from fastapi import Depends, Request, Response from fastapi.responses import HTMLResponse -from app import schemas +from app.schemas.common import ManageRequest as _SchemaManageRequest +from app.schemas.response import Response as _SchemaResponse from app.api.response import ResponseAPIRouter from app.db.models import User from app.api.deps import get_current_active_superuser_async @@ -23,11 +24,11 @@ def _get_llm_provider_manager_type() -> type: summary="LLM提供商统一管理", # 各动作 data 形态不一:目录查询返回列表,其余动作返回映射, # 须用具体联合类型声明,而非单一开放映射 - response_model=schemas.Response[Union[List[Dict[str, Any]], Dict[str, Any]]], + response_model=_SchemaResponse[Union[List[Dict[str, Any]], Dict[str, Any]]], ) async def manage_provider( request: Request, - payload: schemas.ManageRequest, + payload: _SchemaManageRequest, _: User = Depends(get_current_active_superuser_async), ): """ @@ -46,7 +47,7 @@ async def manage_provider( result = await _get_llm_provider_manager_type()().provider_manage( payload.target, payload.action, **params ) - return schemas.Response( + return _SchemaResponse( success=bool(result.get("success")), message=result.get("message"), data=result.get("data"), diff --git a/app/api/endpoints/login.py b/app/api/endpoints/login.py index 574003ad1..0831b2caa 100644 --- a/app/api/endpoints/login.py +++ b/app/api/endpoints/login.py @@ -5,7 +5,10 @@ from fastapi import Depends, Form, HTTPException, Request, Response from fastapi.security import OAuth2PasswordRequestForm from fastapi.responses import JSONResponse -from app import schemas +from app.schemas.response import Response as _SchemaResponse +from app.schemas.token import MfaChallenge as _SchemaMfaChallenge +from app.schemas.token import Token as _SchemaToken +from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter from app.chain.user import MfaRequired, UserChain from app.application.security import access as security @@ -21,10 +24,10 @@ router = ResponseAPIRouter() @router.post( "/access-token", summary="获取token", - response_model=schemas.Token, + response_model=_SchemaToken, responses={ 401: { - "model": schemas.Response[schemas.MfaChallenge], + "model": _SchemaResponse[_SchemaMfaChallenge], "description": "需要二次验证或认证失败", } }, @@ -46,10 +49,10 @@ def login_access_token( if not success: # 只有密码已经验证通过时才返回 MFA 方法,避免泄露账号安全配置。 if isinstance(user_or_message, MfaRequired): - challenge = schemas.Response[schemas.MfaChallenge]( + challenge = _SchemaResponse[_SchemaMfaChallenge]( success=False, message="需要二次验证", - data=schemas.MfaChallenge( + data=_SchemaMfaChallenge( mfa_methods=list(user_or_message.methods) ), ) @@ -77,7 +80,7 @@ def login_access_token( security.set_or_refresh_resource_token_cookie( request, response, - schemas.TokenPayload( + _SchemaTokenPayload( sub=user_or_message.id, username=user_or_message.name, super_user=user_or_message.is_superuser, @@ -86,7 +89,7 @@ def login_access_token( ), ) - return schemas.Token( + return _SchemaToken( access_token=access_token, token_type="bearer", super_user=user_or_message.is_superuser, @@ -102,7 +105,7 @@ def login_access_token( @router.get( "/wallpaper", summary="登录页面电影海报", - response_model=schemas.Response[str], + response_model=_SchemaResponse[str], ) def wallpaper() -> Any: """ @@ -110,8 +113,8 @@ def wallpaper() -> Any: """ url = WallpaperHelper().get_wallpaper() if url: - return schemas.Response(success=True, data=url) - return schemas.Response(success=False) + return _SchemaResponse(success=True, data=url) + return _SchemaResponse(success=False) @router.get("/wallpapers", summary="登录页面电影海报列表", response_model=List[str]) diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index 2d3b7c853..e9a23e294 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -3,7 +3,14 @@ from typing import List, Any, Dict, Annotated, Union from fastapi import Depends, HTTPException, Request from fastapi.responses import JSONResponse, Response -from app import schemas +from app.schemas.mcp import MCP_JSONRPC_REQUEST_SCHEMA as _SchemaMCP_JSONRPC_REQUEST_SCHEMA +from app.schemas.mcp import McpJsonRpcError as _SchemaMcpJsonRpcError +from app.schemas.mcp import McpJsonRpcResponse as _SchemaMcpJsonRpcResponse +from app.schemas.mcp import McpJsonSchema as _SchemaMcpJsonSchema +from app.schemas.mcp import McpToolInfo as _SchemaMcpToolInfo +from app.schemas.mcp import ToolCallData as _SchemaToolCallData +from app.schemas.mcp import ToolCallRequest as _SchemaToolCallRequest +from app.schemas.response import Response as _SchemaResponse from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter from app.agent.tools.manager import moviepilot_tool_manager from app.application.security.access import verify_apikey @@ -30,13 +37,13 @@ MCP_HIDDEN_TOOLS = { "read_file", } MCP_JSONRPC_ERROR_RESPONSES = { - 400: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 请求错误"}, - 401: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 认证失败"}, - 403: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 访问被拒绝"}, - 404: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 方法不存在"}, - 409: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 请求冲突"}, - 422: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 参数校验失败"}, - 500: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 内部错误"}, + 400: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 请求错误"}, + 401: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 认证失败"}, + 403: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 访问被拒绝"}, + 404: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 方法不存在"}, + 409: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 请求冲突"}, + 422: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 参数校验失败"}, + 500: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 内部错误"}, } @@ -80,13 +87,13 @@ def create_jsonrpc_error( @router.post( "", summary="MCP JSON-RPC 端点", - response_model=schemas.McpJsonRpcResponse, + response_model=_SchemaMcpJsonRpcResponse, openapi_extra={ RAW_RESPONSE_OPENAPI_KEY: True, "requestBody": { "required": True, "content": { - "application/json": {"schema": schemas.MCP_JSONRPC_REQUEST_SCHEMA} + "application/json": {"schema": _SchemaMCP_JSONRPC_REQUEST_SCHEMA} }, }, }, @@ -290,7 +297,7 @@ async def delete_mcp_session( @router.get( "/tools", summary="列出所有可用工具", - response_model=List[schemas.McpToolInfo], + response_model=List[_SchemaMcpToolInfo], ) async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any: """ @@ -321,10 +328,10 @@ async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any: @router.post( "/tools/call", summary="调用工具", - response_model=schemas.Response[schemas.ToolCallData], + response_model=_SchemaResponse[_SchemaToolCallData], ) async def call_tool( - request: schemas.ToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None + request: _SchemaToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None ) -> Any: """ 调用指定的工具 @@ -340,19 +347,19 @@ async def call_tool( request.tool_name, request.arguments ) - return schemas.Response( + return _SchemaResponse( success=True, - data=schemas.ToolCallData(result=result_text), + data=_SchemaToolCallData(result=result_text), ) except Exception as e: logger.error(f"调用工具 {request.tool_name} 失败: {e}", exc_info=True) - return schemas.Response(success=False, message="调用工具失败") + return _SchemaResponse(success=False, message="调用工具失败") @router.get( "/tools/{tool_name}", summary="获取工具详情", - response_model=schemas.McpToolInfo, + response_model=_SchemaMcpToolInfo, ) async def get_tool_info( tool_name: str, _: Annotated[str, Depends(verify_apikey)] @@ -387,7 +394,7 @@ async def get_tool_info( @router.get( "/tools/{tool_name}/schema", summary="获取工具参数Schema", - response_model=schemas.McpJsonSchema, + response_model=_SchemaMcpJsonSchema, ) async def get_tool_schema( tool_name: str, _: Annotated[str, Depends(verify_apikey)] diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 1ca35b697..2f9d8da2d 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -5,7 +5,17 @@ from uuid import UUID from fastapi import Depends, Query from pydantic import BeforeValidator -from app import schemas +from app.schemas.category import CategoryConfig as _SchemaCategoryConfig +from app.schemas.category import MediaCategoryMap as _SchemaMediaCategoryMap +from app.schemas.context import MediaEpisodeGroup as _SchemaMediaEpisodeGroup +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.context import MediaSearchResults as _SchemaMediaSearchResults +from app.schemas.context import MediaSeason as _SchemaMediaSeason +from app.schemas.response import Response as _SchemaResponse +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.workflow import Context as _SchemaContext +from app.schemas.workflow import FileItem as _SchemaFileItem +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.scraping import ScrapingChain @@ -18,9 +28,8 @@ from app.domain.metainfo import MetaInfo, MetaInfoPath from app.application.security.access import verify_token, verify_apitoken from app.db.models import User from app.api.deps import get_current_active_user, get_current_active_superuser -from app.schemas import MediaType from app.schemas.category import CategoryConfig -from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource +from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource, MediaType from app.domain.media import is_music_media_source, normalize_music_type, parse_media_source_selection from app.schemas.media import normalize_media_source, resolve_media_identity @@ -103,14 +112,14 @@ def _build_recognize_metainfo( def _build_media_seasons( mediainfo: Any, season: Optional[int] = None, -) -> List[schemas.MediaSeason]: +) -> List[_SchemaMediaSeason]: """将任意数据源的统一媒体信息转换为季信息响应。""" seasons_info = [] for item in mediainfo.season_info or []: season_number = item.get("season_number") if season is not None and season_number != season: continue - seasons_info.append(schemas.MediaSeason( + seasons_info.append(_SchemaMediaSeason( air_date=item.get("air_date"), episode_count=item.get("episode_count"), name=item.get("name"), @@ -128,7 +137,7 @@ def _build_media_seasons( elif not season_numbers: season_numbers = [mediainfo.season or 1] return [ - schemas.MediaSeason( + _SchemaMediaSeason( season_number=season_number, poster_path=mediainfo.poster_path, name=f"第 {season_number} 季", @@ -145,14 +154,14 @@ def _build_media_seasons( @router.get( - "/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context + "/recognize", summary="识别媒体信息(种子)", response_model=_SchemaContext ) async def recognize( title: str, subtitle: Optional[str] = None, custom_words: Optional[str] = None, media_source: Optional[MediaSource] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据标题、副标题识别媒体信息 @@ -173,13 +182,13 @@ async def recognize( ) if mediainfo: return Context(meta_info=metainfo, media_info=mediainfo).to_dict() - return schemas.Context() + return _SchemaContext() @router.get( "/recognize2", summary="识别种子媒体信息(API_TOKEN)", - response_model=schemas.Context, + response_model=_SchemaContext, ) async def recognize2( _: Annotated[str, Depends(verify_apitoken)], @@ -196,12 +205,12 @@ async def recognize2( @router.get( - "/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context + "/recognize_file", summary="识别媒体信息(文件)", response_model=_SchemaContext ) async def recognize_file( path: str, media_source: Optional[MediaSource] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据文件路径识别媒体信息,影视与音乐统一走媒体链路径识别入口 @@ -212,13 +221,13 @@ async def recognize_file( ) if context: return context.to_dict() - return schemas.Context() + return _SchemaContext() @router.get( "/recognize_file2", summary="识别文件媒体信息(API_TOKEN)", - response_model=schemas.Context, + response_model=_SchemaContext, ) async def recognize_file2( path: str, @@ -235,7 +244,7 @@ async def recognize_file2( @router.get( "/search", summary="搜索媒体/人物信息", - response_model=schemas.MediaSearchResults, + response_model=_SchemaMediaSearchResults, ) async def search( title: str, @@ -243,7 +252,7 @@ async def search( page: int = 1, count: int = 8, media_source: MediaSourceQuery = (), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 模糊搜索媒体、合集、人物或音乐信息列表。 @@ -257,7 +266,7 @@ async def search( :return: 搜索结果列表 """ - def __get_source(obj: Union[schemas.MediaInfo, schemas.MediaPerson, dict]): + def __get_source(obj: Union[_SchemaMediaInfo, _SchemaMediaPerson, dict]): """ 获取对象属性 """ @@ -317,16 +326,16 @@ async def search( @router.post( - "/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response[None] + "/scrape/{storage}", summary="刮削媒体信息", response_model=_SchemaResponse[None] ) def scrape( - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, storage: Optional[str] = "local", media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, type_name: Optional[MediaType] = None, music_type: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 刮削媒体信息,可按请求指定媒体数据源及其原生ID @@ -340,17 +349,17 @@ def scrape( :param _: Token校验 """ if not fileitem or not fileitem.path: - return schemas.Response(success=False, message="刮削路径无效") + return _SchemaResponse(success=False, message="刮削路径无效") has_explicit_media_id = media_id is not None normalized_media_id = str(media_id).strip() if has_explicit_media_id else None if has_explicit_media_id and not normalized_media_id: - return schemas.Response(success=False, message="媒体ID格式无效") + return _SchemaResponse(success=False, message="媒体ID格式无效") if normalized_media_id and not media_source: - return schemas.Response( + return _SchemaResponse( success=False, message="指定媒体ID时必须同时指定媒体数据源" ) if normalized_media_id and not _is_valid_source_media_id(media_source, normalized_media_id): - return schemas.Response(success=False, message="媒体ID格式无效") + return _SchemaResponse(success=False, message="媒体ID格式无效") is_music = ( type_name == MediaType.MUSIC @@ -359,7 +368,7 @@ def scrape( ) if is_music: if type_name not in (None, MediaType.MUSIC): - return schemas.Response(success=False, message="音乐元数据源只能用于音乐刮削") + return _SchemaResponse(success=False, message="音乐元数据源只能用于音乐刮削") music_info: Optional[MusicInfo] = None if normalized_media_id: normalized_music_type = normalize_music_type( @@ -367,7 +376,7 @@ def scrape( allow_artist=False, ) if not normalized_music_type: - return schemas.Response( + return _SchemaResponse( success=False, message="音乐实体类型无效,仅支持 recording 或 album", ) @@ -379,14 +388,14 @@ def scrape( music_type=normalized_music_type, ) if not music_info: - return schemas.Response(success=False, message="刮削失败,无法识别音乐信息") + return _SchemaResponse(success=False, message="刮削失败,无法识别音乐信息") success, message = ScrapingChain().scrape_music_metadata( fileitem=fileitem, mediainfo=music_info, overwrite=True, media_source=media_source, ) - return schemas.Response(success=success, message=message) + return _SchemaResponse(success=success, message=message) chain = MediaChain() if normalized_media_id: @@ -410,12 +419,12 @@ def scrape( media_info = context.media_info if context else None if not media_info: - return schemas.Response(success=False, message="刮削失败,无法识别媒体信息") + return _SchemaResponse(success=False, message="刮削失败,无法识别媒体信息") if media_source: media_info.scrape_source = media_source if storage == "local": if not Path(fileitem.path).exists(): - return schemas.Response(success=False, message="刮削路径不存在") + return _SchemaResponse(success=False, message="刮削路径不存在") # 手动刮削 (暂时使用同步版本,可以后续优化为异步) ScrapingChain().scrape_metadata( fileitem=fileitem, @@ -423,24 +432,24 @@ def scrape( mediainfo=media_info, overwrite=True, ) - return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成") + return _SchemaResponse(success=True, message=f"{fileitem.path} 刮削完成") @router.get( "/category/config", summary="获取分类策略配置", - response_model=schemas.Response[schemas.CategoryConfig], + response_model=_SchemaResponse[_SchemaCategoryConfig], ) def get_category_config(_: User = Depends(get_current_active_user)): """ 获取分类策略配置 """ config = MediaChain().category_config() - return schemas.Response(success=True, data=config.model_dump()) + return _SchemaResponse(success=True, data=config.model_dump()) @router.post( - "/category/config", summary="保存分类策略配置", response_model=schemas.Response[None] + "/category/config", summary="保存分类策略配置", response_model=_SchemaResponse[None] ) def save_category_config( config: CategoryConfig, _: User = Depends(get_current_active_superuser) @@ -449,17 +458,17 @@ def save_category_config( 保存分类策略配置 """ if MediaChain().save_category_config(config): - return schemas.Response(success=True, message="保存成功") + return _SchemaResponse(success=True, message="保存成功") else: - return schemas.Response(success=False, message="保存失败") + return _SchemaResponse(success=False, message="保存失败") @router.get( "/category", summary="查询自动分类配置", - response_model=schemas.MediaCategoryMap, + response_model=_SchemaMediaCategoryMap, ) -async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def category(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询自动分类配置 """ @@ -469,10 +478,10 @@ async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get( "/group/seasons/{episode_group}", summary="查询剧集组季信息", - response_model=List[schemas.MediaSeason], + response_model=List[_SchemaMediaSeason], ) async def group_seasons( - episode_group: str, _: schemas.TokenPayload = Depends(verify_token) + episode_group: str, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 查询剧集组季信息(themoviedb) @@ -489,9 +498,9 @@ async def group_seasons( @router.get( "/groups/{tmdbid}", summary="查询媒体剧集组", - response_model=List[schemas.MediaEpisodeGroup], + response_model=List[_SchemaMediaEpisodeGroup], ) -async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def groups(tmdbid: int, _: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询媒体剧集组列表(themoviedb) """ @@ -512,7 +521,7 @@ async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) - @router.get( - "/seasons", summary="查询媒体季信息", response_model=List[schemas.MediaSeason] + "/seasons", summary="查询媒体季信息", response_model=List[_SchemaMediaSeason] ) async def seasons( media_source: Optional[MediaSource] = None, @@ -520,7 +529,7 @@ async def seasons( title: Optional[str] = None, year: str = None, season: int = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 查询媒体季信息 @@ -581,12 +590,12 @@ async def seasons( return [] -@router.get("/{media_id}", summary="查询媒体详情", response_model=schemas.MediaInfo) +@router.get("/{media_id}", summary="查询媒体详情", response_model=_SchemaMediaInfo) async def detail( media_id: str, media_source: MediaSource, type_name: str, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据媒体来源和原生 ID 查询媒体信息,type_name: 电影/电视剧 @@ -597,7 +606,7 @@ async def detail( media_id=media_id, ) if not normalized_source or not normalized_media_id: - return schemas.MediaInfo() + return _SchemaMediaInfo() mediachain = MediaChain() mediainfo = await mediachain.async_recognize_media( media_source=normalized_source, @@ -614,4 +623,4 @@ async def detail( mediainfo.tvdb_slug = slug return mediainfo.to_dict() - return schemas.MediaInfo() + return _SchemaMediaInfo() diff --git a/app/api/endpoints/mediaserver.py b/app/api/endpoints/mediaserver.py index 23f9dba3e..f48230ae4 100644 --- a/app/api/endpoints/mediaserver.py +++ b/app/api/endpoints/mediaserver.py @@ -1,9 +1,19 @@ -from typing import Any, List, Dict, Optional +from typing import Any, List, Optional from fastapi import Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app import schemas +from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo +from app.schemas.mediaserver import ExistMediaInfo as _SchemaExistMediaInfo +from app.schemas.mediaserver import MediaServerExistingEpisodes as _SchemaMediaServerExistingEpisodes +from app.schemas.mediaserver import MediaServerExistsData as _SchemaMediaServerExistsData +from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary +from app.schemas.mediaserver import MediaServerPlayData as _SchemaMediaServerPlayData +from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem +from app.schemas.mediaserver import NotExistMediaInfo as _SchemaNotExistMediaInfo +from app.schemas.response import Response as _SchemaResponse +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.download import DownloadChain from app.chain.mediaserver import MediaServerChain @@ -15,8 +25,8 @@ from app.db.oper.mediaserver import MediaServerOper from app.db.models import MediaServerItem from app.db.oper.systemconfig import SystemConfigOper from app.application.mediaserver import MediaServerHelper -from app.schemas import MediaType, NotExistMediaInfo -from app.schemas.types import MediaSource, SystemConfigKey +from app.schemas.mediaserver import NotExistMediaInfo +from app.schemas.types import MediaSource, MediaType, SystemConfigKey from app.schemas.media import build_media_key, resolve_media_identity router = ResponseAPIRouter() @@ -37,26 +47,26 @@ def _require_mediaserver_result(result: Optional[List[Any]]) -> List[Any]: @router.get( "/play/{itemid:path}", summary="在线播放", - response_model=schemas.Response[schemas.MediaServerPlayData], + response_model=_SchemaResponse[_SchemaMediaServerPlayData], ) def play_item( - itemid: str, _: schemas.TokenPayload = Depends(verify_token) -) -> schemas.Response: + itemid: str, _: _SchemaTokenPayload = Depends(verify_token) +) -> _SchemaResponse: """ 获取媒体服务器播放页面地址 """ if not itemid: - return schemas.Response(success=False, message="参数错误") + return _SchemaResponse(success=False, message="参数错误") configs = MediaServerHelper().get_configs() if not configs: - return schemas.Response(success=False, message="未配置媒体服务器") + return _SchemaResponse(success=False, message="未配置媒体服务器") media_chain = MediaServerChain() for name in configs.keys(): item = media_chain.iteminfo(server=name, item_id=itemid) if item: play_url = media_chain.get_play_url(server=name, item_id=itemid) if play_url: - return schemas.Response( + return _SchemaResponse( success=True, data={ "url": play_url, @@ -65,13 +75,13 @@ def play_item( "server_type": item.server, }, ) - return schemas.Response(success=False, message="未找到播放地址") + return _SchemaResponse(success=False, message="未找到播放地址") @router.get( "/exists", summary="查询本地是否存在(数据库)", - response_model=schemas.Response[schemas.MediaServerExistsData], + response_model=_SchemaResponse[_SchemaMediaServerExistsData], ) async def exists_local( title: Optional[str] = None, @@ -81,7 +91,7 @@ async def exists_local( media_id: Optional[str] = None, season: Optional[int] = None, db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 判断本地是否存在 @@ -107,16 +117,16 @@ async def exists_local( ) if exist: ret_info = {"id": exist.item_id} - return schemas.Response(success=True, data={"item": ret_info}) + return _SchemaResponse(success=True, data={"item": ret_info}) @router.post( "/exists_remote", summary="查询已存在的剧集信息(媒体服务器)", - response_model=schemas.MediaServerExistingEpisodes, + response_model=_SchemaMediaServerExistingEpisodes, ) def exists( - media_in: schemas.MediaInfo, _: schemas.TokenPayload = Depends(verify_token) + media_in: _SchemaMediaInfo, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 根据媒体信息查询媒体库已存在的剧集信息 @@ -124,7 +134,7 @@ def exists( # 转化为媒体信息对象 mediainfo = MediaInfo() mediainfo.from_dict(media_in.model_dump()) - existsinfo: schemas.ExistMediaInfo = MediaServerChain().media_exists( + existsinfo: _SchemaExistMediaInfo = MediaServerChain().media_exists( mediainfo=mediainfo ) if not existsinfo: @@ -137,10 +147,10 @@ def exists( @router.post( "/notexists", summary="查询媒体库缺失信息(媒体服务器)", - response_model=List[schemas.NotExistMediaInfo], + response_model=List[_SchemaNotExistMediaInfo], ) def not_exists( - media_in: schemas.MediaInfo, _: schemas.TokenPayload = Depends(verify_token) + media_in: _SchemaMediaInfo, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 根据媒体信息查询缺失电影/剧集 @@ -173,12 +183,12 @@ def not_exists( @router.get( - "/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem] + "/latest", summary="最新入库条目", response_model=List[_SchemaMediaServerPlayItem] ) def latest( server: str, count: Optional[int] = 20, - userinfo: schemas.TokenPayload = Depends(verify_token), + userinfo: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 获取媒体服务器最新入库条目 @@ -193,12 +203,12 @@ def latest( @router.get( - "/playing", summary="正在播放条目", response_model=List[schemas.MediaServerPlayItem] + "/playing", summary="正在播放条目", response_model=List[_SchemaMediaServerPlayItem] ) def playing( server: str, count: Optional[int] = 12, - userinfo: schemas.TokenPayload = Depends(verify_token), + userinfo: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 获取媒体服务器正在播放条目 @@ -213,12 +223,12 @@ def playing( @router.get( - "/library", summary="媒体库列表", response_model=List[schemas.MediaServerLibrary] + "/library", summary="媒体库列表", response_model=List[_SchemaMediaServerLibrary] ) def library( server: str, hidden: Optional[bool] = False, - userinfo: schemas.TokenPayload = Depends(verify_token), + userinfo: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 获取媒体服务器媒体库列表 @@ -235,9 +245,9 @@ def library( @router.get( "/clients", summary="查询可用媒体服务器", - response_model=List[schemas.ServiceClientInfo], + response_model=List[_SchemaServiceClientInfo], ) -async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def clients(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询可用媒体服务器 """ diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index ece22f3f3..e0e7f2e0f 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -8,7 +8,15 @@ from fastapi import BackgroundTasks, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from starlette.responses import PlainTextResponse -from app import schemas +from app.schemas.message import MessageClearBefore as _SchemaMessageClearBefore +from app.schemas.message import MessageClearData as _SchemaMessageClearData +from app.schemas.message import MessageClearScope as _SchemaMessageClearScope +from app.schemas.message import MessageHistoryItem as _SchemaMessageHistoryItem +from app.schemas.message import Subscription as _SchemaSubscription +from app.schemas.message import SubscriptionMessage as _SchemaSubscriptionMessage +from app.schemas.message import WebMessageItem as _SchemaWebMessageItem +from app.schemas.response import Response as _SchemaResponse +from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import ResponseAPIRouter from app.chain.message import MessageChain from app.runtime.config import settings, global_vars @@ -71,18 +79,18 @@ def _normalize_notification_clear_timestamp(value: Any) -> int: return normalized_value if normalized_value > 0 else 0 -def _get_notification_clear_before() -> schemas.MessageClearBefore: +def _get_notification_clear_before() -> _SchemaMessageClearBefore: """ 读取通知中心清理时间配置。 """ value = SystemConfigOper().get(SystemConfigKey.NotificationClearBefore) if isinstance(value, dict): - return schemas.MessageClearBefore( + return _SchemaMessageClearBefore( all=_normalize_notification_clear_timestamp(value.get("all")), system=_normalize_notification_clear_timestamp(value.get("system")), media=_normalize_notification_clear_timestamp(value.get("media")), ) - return schemas.MessageClearBefore( + return _SchemaMessageClearBefore( all=_normalize_notification_clear_timestamp(value), ) @@ -104,11 +112,11 @@ def start_message_chain(body: Any, form: Any, args: Any): MessageChain().process(body=body, form=form, args=args) -@router.post("/", summary="接收用户消息", response_model=schemas.Response[None]) +@router.post("/", summary="接收用户消息", response_model=_SchemaResponse[None]) async def user_message( background_tasks: BackgroundTasks, request: Request, - _: schemas.TokenPayload = Depends(verify_apitoken), + _: _SchemaTokenPayload = Depends(verify_apitoken), ): """ 用户消息响应,配置请求中需要添加参数:token=API_TOKEN&source=消息配置名 @@ -141,10 +149,10 @@ async def user_message( image_markers, ) background_tasks.add_task(start_message_chain, body, form, args) - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.post("/web", summary="接收WEB消息", response_model=schemas.Response[None]) +@router.post("/web", summary="接收WEB消息", response_model=_SchemaResponse[None]) async def web_message( request: Request, text: Optional[str] = None, @@ -180,12 +188,12 @@ async def web_message( text=text or "", images=images, ) - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.get("/web", summary="获取WEB消息", response_model=List[schemas.WebMessageItem]) +@router.get("/web", summary="获取WEB消息", response_model=List[_SchemaWebMessageItem]) async def get_web_message( - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), db: AsyncSession = Depends(get_async_db), page: Optional[int] = 1, count: Optional[int] = 20, @@ -204,9 +212,9 @@ async def get_web_message( return ret_messages -@router.get("/notification", summary="获取通知消息", response_model=List[schemas.MessageHistoryItem]) +@router.get("/notification", summary="获取通知消息", response_model=List[_SchemaMessageHistoryItem]) async def get_notification_message( - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), db: AsyncSession = Depends(get_async_db), page: Optional[int] = 1, count: Optional[int] = 20, @@ -222,17 +230,17 @@ async def get_notification_message( system_clear_before=_format_notification_clear_time(clear_before.system), media_clear_before=_format_notification_clear_time(clear_before.media), ) - return [schemas.MessageHistoryItem(**message.to_dict()) for message in messages] + return [_SchemaMessageHistoryItem(**message.to_dict()) for message in messages] @router.delete( "/notification", summary="清理通知消息", - response_model=schemas.Response[schemas.MessageClearData], + response_model=_SchemaResponse[_SchemaMessageClearData], ) async def clear_notification_message( - scope: schemas.MessageClearScope = schemas.MessageClearScope.All, - _: schemas.TokenPayload = Depends(verify_token), + scope: _SchemaMessageClearScope = _SchemaMessageClearScope.All, + _: _SchemaTokenPayload = Depends(verify_token), ): """ 记录通知中心清理时间,后续通知历史查询会在服务端过滤。 @@ -241,7 +249,7 @@ async def clear_notification_message( value = clear_before.model_dump() value[scope.value] = int(time.time() * 1000) await SystemConfigOper().async_set(SystemConfigKey.NotificationClearBefore, value) - return schemas.Response(success=True, data={"clear_before": value}) + return _SchemaResponse(success=True, data={"clear_before": value}) def wechat_verify( @@ -325,7 +333,7 @@ def incoming_verify( timestamp: Union[str, int] = None, nonce: Optional[str] = None, source: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_apitoken), + _: _SchemaTokenPayload = Depends(verify_apitoken), ) -> Any: """ 微信/VoceChat等验证响应 @@ -342,10 +350,10 @@ def incoming_verify( @router.post( "/webpush/subscribe", summary="客户端webpush通知订阅", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) async def subscribe( - subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token) + subscription: _SchemaSubscription, _: _SchemaTokenPayload = Depends(verify_token) ): """ 客户端webpush通知订阅 @@ -353,15 +361,15 @@ async def subscribe( subinfo = subscription.model_dump() global_vars.push_subscription(subinfo) logger.debug(f"通知订阅成功: {subinfo}") - return schemas.Response(success=True) + return _SchemaResponse(success=True) @router.post( - "/webpush/send", summary="发送webpush通知", response_model=schemas.Response[None] + "/webpush/send", summary="发送webpush通知", response_model=_SchemaResponse[None] ) def send_notification( - payload: schemas.SubscriptionMessage, - _: schemas.TokenPayload = Depends(verify_token), + payload: _SchemaSubscriptionMessage, + _: _SchemaTokenPayload = Depends(verify_token), ): """ 发送webpush通知 @@ -382,4 +390,4 @@ def send_notification( if is_webpush_subscription_gone(err) and global_vars.remove_subscription(sub): logger.info(f"已移除失效WebPush订阅: {sub.get('endpoint')}") continue - return schemas.Response(success=True) + return _SchemaResponse(success=True) diff --git a/app/api/endpoints/mfa.py b/app/api/endpoints/mfa.py index 2a148ebd2..35d9038fd 100644 --- a/app/api/endpoints/mfa.py +++ b/app/api/endpoints/mfa.py @@ -11,7 +11,15 @@ from app.application.site.sites import SitesHelper # pylint: disable=no-name-in from fastapi import Depends, HTTPException, Body, Request, Response from sqlalchemy.ext.asyncio import AsyncSession -from app import schemas +from app.schemas.mcp import BaseModel as _SchemaBaseModel +from app.schemas.mcp import JsonData as _SchemaJsonData +from app.schemas.mfa import MfaStatusData as _SchemaMfaStatusData +from app.schemas.mfa import OtpGenerateData as _SchemaOtpGenerateData +from app.schemas.mfa import PasskeyInfo as _SchemaPasskeyInfo +from app.schemas.mfa import PasskeyStartData as _SchemaPasskeyStartData +from app.schemas.response import Response as _SchemaResponse +from app.schemas.token import Token as _SchemaToken +from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter from app.application.security import access as security from app.runtime.config import settings @@ -93,20 +101,20 @@ def _verify_passkey_and_update( # ==================== 请求模型 ==================== -class OtpVerifyRequest(schemas.BaseModel): +class OtpVerifyRequest(_SchemaBaseModel): """OTP验证请求""" uri: str otpPassword: str -class OtpDisableRequest(schemas.BaseModel): +class OtpDisableRequest(_SchemaBaseModel): """OTP禁用请求""" password: str -class PassKeyDeleteRequest(schemas.BaseModel): +class PassKeyDeleteRequest(_SchemaBaseModel): """PassKey删除请求""" passkey_id: int @@ -119,7 +127,7 @@ class PassKeyDeleteRequest(schemas.BaseModel): @router.get( "/status/{username}", summary="判断用户是否开启二次验证", - response_model=schemas.Response[schemas.MfaStatusData], + response_model=_SchemaResponse[_SchemaMfaStatusData], ) async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any: """ @@ -127,12 +135,12 @@ async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> """ user: User = await User.async_get_by_name(db, username) if not user: - return schemas.Response(success=False, message="用户不存在") + return _SchemaResponse(success=False, message="用户不存在") # 检查是否启用了OTP has_otp = user.is_otp - return schemas.Response(success=True, data={"enabled": bool(has_otp)}) + return _SchemaResponse(success=True, data={"enabled": bool(has_otp)}) # ==================== OTP 相关接口 ==================== @@ -141,17 +149,17 @@ async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> @router.post( "/otp/generate", summary="生成 OTP 验证 URI", - response_model=schemas.Response[schemas.OtpGenerateData], + response_model=_SchemaResponse[_SchemaOtpGenerateData], ) def otp_generate( current_user: Annotated[User, Depends(get_current_active_user)], ) -> Any: """生成 OTP 密钥及对应的 URI""" secret, uri = OtpUtils.generate_secret_key(current_user.name) - return schemas.Response(success=secret != "", data={"secret": secret, "uri": uri}) + return _SchemaResponse(success=secret != "", data={"secret": secret, "uri": uri}) -@router.post("/otp/verify", summary="绑定并验证 OTP", response_model=schemas.Response[None]) +@router.post("/otp/verify", summary="绑定并验证 OTP", response_model=_SchemaResponse[None]) async def otp_verify( data: OtpVerifyRequest, db: AsyncSession = Depends(get_async_db), @@ -159,17 +167,17 @@ async def otp_verify( ) -> Any: """验证用户输入的 OTP 码,验证通过后正式开启 OTP 验证""" if not OtpUtils.is_legal(data.uri, data.otpPassword): - return schemas.Response(success=False, message="验证码错误") + return _SchemaResponse(success=False, message="验证码错误") await current_user.async_update_otp_by_name( db, current_user.name, True, OtpUtils.get_secret(data.uri) ) - return schemas.Response(success=True) + return _SchemaResponse(success=True) @router.post( "/otp/disable", summary="关闭当前用户的 OTP 验证", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) async def otp_disable( data: OtpDisableRequest, @@ -179,45 +187,45 @@ async def otp_disable( """关闭当前用户的 OTP 验证功能""" # 验证密码 if not security.verify_password(data.password, str(current_user.hashed_password)): - return schemas.Response(success=False, message="密码错误") + return _SchemaResponse(success=False, message="密码错误") await current_user.async_update_otp_by_name(db, current_user.name, False, "") - return schemas.Response(success=True) + return _SchemaResponse(success=True) # ==================== PassKey 相关接口 ==================== -class PassKeyRegistrationStart(schemas.BaseModel): +class PassKeyRegistrationStart(_SchemaBaseModel): """PassKey注册开始请求""" name: str = "通行密钥" -class PassKeyRegistrationFinish(schemas.BaseModel): +class PassKeyRegistrationFinish(_SchemaBaseModel): """PassKey注册完成请求""" - credential: dict[str, schemas.JsonData] + credential: dict[str, _SchemaJsonData] transaction_token: str name: str = "通行密钥" -class PassKeyAuthenticationStart(schemas.BaseModel): +class PassKeyAuthenticationStart(_SchemaBaseModel): """PassKey认证开始请求""" username: Optional[str] = None -class PassKeyAuthenticationFinish(schemas.BaseModel): +class PassKeyAuthenticationFinish(_SchemaBaseModel): """PassKey认证完成请求""" - credential: dict[str, schemas.JsonData] + credential: dict[str, _SchemaJsonData] transaction_token: str @router.post( "/passkey/register/start", summary="开始注册 PassKey", - response_model=schemas.Response[schemas.PasskeyStartData], + response_model=_SchemaResponse[_SchemaPasskeyStartData], ) def passkey_register_start( current_user: Annotated[User, Depends(get_current_active_user)], @@ -245,7 +253,7 @@ def passkey_register_start( purpose="registration", user_id=current_user.id, ) - return schemas.Response( + return _SchemaResponse( success=True, data={ "options": json.loads(options_json), @@ -254,13 +262,13 @@ def passkey_register_start( ) except Exception as e: logger.error(f"生成PassKey注册选项失败: {e}") - return schemas.Response(success=False, message=f"生成注册选项失败: {str(e)}") + return _SchemaResponse(success=False, message=f"生成注册选项失败: {str(e)}") @router.post( "/passkey/register/finish", summary="完成注册 PassKey", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) def passkey_register_finish( passkey_req: PassKeyRegistrationFinish, @@ -273,7 +281,7 @@ def passkey_register_finish( purpose="registration", ) if not challenge_state or challenge_state.user_id != current_user.id: - return schemas.Response( + return _SchemaResponse( success=False, message="注册请求已失效,请重新发起注册", ) @@ -308,26 +316,26 @@ def passkey_register_finish( logger.info(f"用户 {current_user.name} 成功注册PassKey: {passkey_req.name}") - return schemas.Response(success=True, message="通行密钥注册成功") + return _SchemaResponse(success=True, message="通行密钥注册成功") except PassKeyRegistrationOriginMismatchError: - return schemas.Response( + return _SchemaResponse( success=False, message="访问域名与系统配置不一致,请使用配置的域名重试", ) except PassKeyRegistrationVerificationError: - return schemas.Response( + return _SchemaResponse( success=False, message="通行密钥注册验证失败,请重新发起注册后重试", ) except Exception as e: logger.error(f"注册PassKey失败: {e}") - return schemas.Response(success=False, message="通行密钥注册失败,请稍后重试") + return _SchemaResponse(success=False, message="通行密钥注册失败,请稍后重试") @router.post( "/passkey/authenticate/start", summary="开始 PassKey 认证", - response_model=schemas.Response[schemas.PasskeyStartData], + response_model=_SchemaResponse[_SchemaPasskeyStartData], ) def passkey_authenticate_start( passkey_req: PassKeyAuthenticationStart = Body(...), @@ -345,7 +353,7 @@ def passkey_authenticate_start( ) if not user or not existing_passkeys: - return schemas.Response(success=False, message="认证失败") + return _SchemaResponse(success=False, message="认证失败") existing_credentials = _build_credential_list(existing_passkeys) user_id = user.id @@ -360,7 +368,7 @@ def passkey_authenticate_start( purpose="authentication", user_id=user_id, ) - return schemas.Response( + return _SchemaResponse( success=True, data={ "options": json.loads(options_json), @@ -369,13 +377,13 @@ def passkey_authenticate_start( ) except Exception as e: logger.error(f"生成PassKey认证选项失败: {e}") - return schemas.Response(success=False, message="认证失败") + return _SchemaResponse(success=False, message="认证失败") @router.post( "/passkey/authenticate/finish", summary="完成 PassKey 认证", - response_model=schemas.Token, + response_model=_SchemaToken, openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True}, ) def passkey_authenticate_finish( @@ -436,7 +444,7 @@ def passkey_authenticate_finish( security.set_or_refresh_resource_token_cookie( request, response, - schemas.TokenPayload( + _SchemaTokenPayload( sub=user.id, username=user.name, super_user=user.is_superuser, @@ -445,7 +453,7 @@ def passkey_authenticate_finish( ), ) - return schemas.Token( + return _SchemaToken( access_token=access_token, token_type="bearer", super_user=user.is_superuser, @@ -466,7 +474,7 @@ def passkey_authenticate_finish( @router.get( "/passkey/list", summary="获取当前用户的 PassKey 列表", - response_model=schemas.Response[list[schemas.PasskeyInfo]], + response_model=_SchemaResponse[list[_SchemaPasskeyInfo]], ) def passkey_list( current_user: Annotated[User, Depends(get_current_active_user)], @@ -493,16 +501,16 @@ def passkey_list( else [] ) - return schemas.Response(success=True, data=key_list) + return _SchemaResponse(success=True, data=key_list) except Exception as e: logger.error(f"获取PassKey列表失败: {e}") - return schemas.Response(success=False, message=f"获取列表失败: {str(e)}") + return _SchemaResponse(success=False, message=f"获取列表失败: {str(e)}") @router.post( "/passkey/delete", summary="删除 PassKey", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) async def passkey_delete( data: PassKeyDeleteRequest, @@ -514,7 +522,7 @@ async def passkey_delete( if not security.verify_password( data.password, str(current_user.hashed_password) ): - return schemas.Response(success=False, message="密码错误") + return _SchemaResponse(success=False, message="密码错误") success = PassKey.delete_by_id( db=None, passkey_id=data.passkey_id, user_id=current_user.id @@ -522,9 +530,9 @@ async def passkey_delete( if success: logger.info(f"用户 {current_user.name} 删除了PassKey: {data.passkey_id}") - return schemas.Response(success=True, message="通行密钥已删除") + return _SchemaResponse(success=True, message="通行密钥已删除") else: - return schemas.Response(success=False, message="通行密钥不存在或无权删除") + return _SchemaResponse(success=False, message="通行密钥不存在或无权删除") except Exception as e: logger.error(f"删除PassKey失败: {e}") - return schemas.Response(success=False, message=f"删除失败: {str(e)}") + return _SchemaResponse(success=False, message=f"删除失败: {str(e)}") diff --git a/app/api/endpoints/music.py b/app/api/endpoints/music.py index 64afc4cd9..c3344c4ac 100644 --- a/app/api/endpoints/music.py +++ b/app/api/endpoints/music.py @@ -2,7 +2,13 @@ from typing import Annotated, Optional from fastapi import Depends, HTTPException, Query -from app import schemas +from app.schemas.music import MusicAlbumInfo as _SchemaMusicAlbumInfo +from app.schemas.music import MusicArtistInfo as _SchemaMusicArtistInfo +from app.schemas.music import MusicRecognitionCacheData as _SchemaMusicRecognitionCacheData +from app.schemas.music import MusicRecognizeRequest as _SchemaMusicRecognizeRequest +from app.schemas.response import Response as _SchemaResponse +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.transfer import MusicInfo as _SchemaMusicInfo from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.recommend import RecommendChain @@ -62,30 +68,30 @@ def _validate_music_source( return normalized_source -def _serialize_music(info: MusicInfo) -> schemas.MusicInfo: +def _serialize_music(info: MusicInfo) -> _SchemaMusicInfo: """将内部音乐信息转换为 REST 响应模型。""" - return schemas.MusicInfo(**info.to_dict()) + return _SchemaMusicInfo(**info.to_dict()) -def _serialize_album(info: MusicAlbumInfo) -> schemas.MusicAlbumInfo: +def _serialize_album(info: MusicAlbumInfo) -> _SchemaMusicAlbumInfo: """将内部专辑信息转换为 REST 响应模型。""" - return schemas.MusicAlbumInfo(**info.to_dict()) + return _SchemaMusicAlbumInfo(**info.to_dict()) -def _serialize_artist(info: MusicArtistInfo) -> schemas.MusicArtistInfo: +def _serialize_artist(info: MusicArtistInfo) -> _SchemaMusicArtistInfo: """将内部艺术家信息转换为 REST 响应模型。""" - return schemas.MusicArtistInfo(**info.to_dict()) + return _SchemaMusicArtistInfo(**info.to_dict()) @router.post( "/recognize", summary="识别音乐元数据详情", - response_model=schemas.MusicInfo, + response_model=_SchemaMusicInfo, ) async def recognize_music( - request: schemas.MusicRecognizeRequest, - _: schemas.TokenPayload = Depends(verify_token), -) -> schemas.MusicInfo: + request: _SchemaMusicRecognizeRequest, + _: _SchemaTokenPayload = Depends(verify_token), +) -> _SchemaMusicInfo: """根据音乐元数据来源和媒体 ID 获取标准详情,与影视识别共用统一入口。""" recognize_kwargs = { "media_source": request.media_source, @@ -105,15 +111,15 @@ async def recognize_music( @router.get( "/cache", summary="查询音乐识别缓存", - response_model=schemas.Response[schemas.MusicRecognitionCacheData], + response_model=_SchemaResponse[_SchemaMusicRecognitionCacheData], ) async def music_recognition_cache( _: User = Depends(get_current_active_superuser_async), -) -> schemas.Response: +) -> _SchemaResponse: """查询可管理的 MusicBrainz 识别缓存。""" cache_items = MusicBrainzChain().cache_items() recognized_count = sum(1 for item in cache_items if item["media_id"]) - return schemas.Response( + return _SchemaResponse( success=True, data={ "count": len(cache_items), @@ -127,34 +133,34 @@ async def music_recognition_cache( @router.delete( "/cache/{cache_key:path}", summary="删除指定音乐识别缓存", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) async def delete_music_recognition_cache( cache_key: str, _: User = Depends(get_current_active_superuser_async), -) -> schemas.Response: +) -> _SchemaResponse: """按缓存键删除单条 MusicBrainz 识别缓存。""" deleted_item = MusicBrainzChain().delete_cache(cache_key) if not deleted_item: - return schemas.Response(success=False, message="音乐识别缓存不存在") - return schemas.Response(success=True, message="音乐识别缓存删除成功") + return _SchemaResponse(success=False, message="音乐识别缓存不存在") + return _SchemaResponse(success=True, message="音乐识别缓存删除成功") @router.delete( - "/cache", summary="清空音乐识别缓存", response_model=schemas.Response[None] + "/cache", summary="清空音乐识别缓存", response_model=_SchemaResponse[None] ) async def clear_music_recognition_cache( _: User = Depends(get_current_active_superuser_async), -) -> schemas.Response: +) -> _SchemaResponse: """清空全部 MusicBrainz 识别缓存。""" MusicBrainzChain().clear_cache() - return schemas.Response(success=True, message="音乐识别缓存清理完成") + return _SchemaResponse(success=True, message="音乐识别缓存清理完成") @router.get( "/explore", summary="探索音乐", - response_model=list[schemas.MusicInfo], + response_model=list[_SchemaMusicInfo], ) async def explore_music( page: PageParam = 1, @@ -172,8 +178,8 @@ async def explore_music( with_cover: bool = False, tags: str = "", douban_sort: DoubanMusicSortParam = "U", - _: schemas.TokenPayload = Depends(verify_token), -) -> list[schemas.MusicInfo]: + _: _SchemaTokenPayload = Depends(verify_token), +) -> list[_SchemaMusicInfo]: """MusicBrainz 返回榜单或新发行,豆瓣音乐固定按官方标签分类浏览。""" media_source = _validate_music_source(media_source, _MUSIC_EXPLORE_SOURCES) chain = RecommendChain() @@ -215,13 +221,13 @@ async def explore_music( @router.get( "/album/{album_id}", summary="查询音乐专辑详情", - response_model=schemas.MusicAlbumInfo, + response_model=_SchemaMusicAlbumInfo, ) async def music_album( album_id: str, media_source: MusicSourceParam = MediaSource.MusicBrainz, - _: schemas.TokenPayload = Depends(verify_token), -) -> schemas.MusicAlbumInfo: + _: _SchemaTokenPayload = Depends(verify_token), +) -> _SchemaMusicAlbumInfo: """按专辑标准 ID 返回专辑详情、曲目列表和发行版本。""" media_source = _validate_music_source(media_source) info = await MediaChain().async_get_music_album( @@ -235,14 +241,14 @@ async def music_album( @router.get( "/album/{album_id}/related", summary="查询关联音乐专辑", - response_model=list[schemas.MusicInfo], + response_model=list[_SchemaMusicInfo], ) async def music_album_related( album_id: str, count: CountParam = 24, media_source: MusicSourceParam = MediaSource.MusicBrainz, - _: schemas.TokenPayload = Depends(verify_token), -) -> list[schemas.MusicInfo]: + _: _SchemaTokenPayload = Depends(verify_token), +) -> list[_SchemaMusicInfo]: """按来源和专辑 ID 返回可继续浏览的关联专辑。""" media_source = _validate_music_source(media_source) results = await MediaChain().async_get_music_album_related( @@ -256,7 +262,7 @@ async def music_album_related( @router.get( "/artist/{artist_id}/albums", summary="查询艺术家的专辑列表", - response_model=list[schemas.MusicInfo], + response_model=list[_SchemaMusicInfo], ) async def music_artist_albums( artist_id: str, @@ -264,8 +270,8 @@ async def music_artist_albums( count: CountParam = 30, album_type: MusicAlbumTypeParam = None, media_source: MusicSourceParam = MediaSource.MusicBrainz, - _: schemas.TokenPayload = Depends(verify_token), -) -> list[schemas.MusicInfo]: + _: _SchemaTokenPayload = Depends(verify_token), +) -> list[_SchemaMusicInfo]: """按艺术家标准 ID 分页返回其专辑、EP 和单曲。""" media_source = _validate_music_source(media_source) results = await MediaChain().async_get_music_artist_albums( @@ -281,14 +287,14 @@ async def music_artist_albums( @router.get( "/artist/{artist_id}/related", summary="查询关联艺术家", - response_model=list[schemas.MusicArtistInfo], + response_model=list[_SchemaMusicArtistInfo], ) async def music_artist_related( artist_id: str, count: CountParam = 24, media_source: MusicSourceParam = MediaSource.MusicBrainz, - _: schemas.TokenPayload = Depends(verify_token), -) -> list[schemas.MusicArtistInfo]: + _: _SchemaTokenPayload = Depends(verify_token), +) -> list[_SchemaMusicArtistInfo]: """按艺术家关系返回可继续浏览的关联艺术家。""" media_source = _validate_music_source(media_source) results = await MediaChain().async_get_music_artist_related( @@ -302,13 +308,13 @@ async def music_artist_related( @router.get( "/artist/{artist_id}", summary="查询音乐艺术家详情", - response_model=schemas.MusicArtistInfo, + response_model=_SchemaMusicArtistInfo, ) async def music_artist( artist_id: str, media_source: MusicSourceParam = MediaSource.MusicBrainz, - _: schemas.TokenPayload = Depends(verify_token), -) -> schemas.MusicArtistInfo: + _: _SchemaTokenPayload = Depends(verify_token), +) -> _SchemaMusicArtistInfo: """按艺术家标准 ID 返回艺术家详情。""" media_source = _validate_music_source(media_source) info = await MediaChain().async_get_music_artist( diff --git a/app/api/endpoints/notification.py b/app/api/endpoints/notification.py index eee00ea5f..d4d453595 100644 --- a/app/api/endpoints/notification.py +++ b/app/api/endpoints/notification.py @@ -2,7 +2,8 @@ from typing import Any, Dict from fastapi import Depends -from app import schemas +from app.schemas.common import ManageRequest as _SchemaManageRequest +from app.schemas.response import Response as _SchemaResponse from app.api.response import ResponseAPIRouter from app.chain.notification import NotificationChain from app.db.models import User @@ -14,10 +15,10 @@ router = ResponseAPIRouter() @router.post( "/manage", summary="通知渠道统一管理", - response_model=schemas.Response[Dict[str, Any]], + response_model=_SchemaResponse[Dict[str, Any]], ) def manage_channel( - request: schemas.ManageRequest, + request: _SchemaManageRequest, _: User = Depends(get_current_active_superuser), ): """ @@ -31,7 +32,7 @@ def manage_channel( action=request.action, **request.params, ) - return schemas.Response( + return _SchemaResponse( success=bool(result.get("success")), message=result.get("message"), data=result.get("data"), diff --git a/app/api/endpoints/openai.py b/app/api/endpoints/openai.py index c6230af43..f8a43b07d 100644 --- a/app/api/endpoints/openai.py +++ b/app/api/endpoints/openai.py @@ -9,7 +9,17 @@ from fastapi import APIRouter, Request, Security from fastapi.responses import JSONResponse, StreamingResponse from fastapi.security import HTTPAuthorizationCredentials -from app import schemas +from app.schemas.openai import OpenAIChatCompletionResponse as _SchemaOpenAIChatCompletionResponse +from app.schemas.openai import OpenAIChatCompletionsRequest as _SchemaOpenAIChatCompletionsRequest +from app.schemas.openai import OpenAIErrorDetail as _SchemaOpenAIErrorDetail +from app.schemas.openai import OpenAIErrorResponse as _SchemaOpenAIErrorResponse +from app.schemas.openai import OpenAIModelInfo as _SchemaOpenAIModelInfo +from app.schemas.openai import OpenAIModelListResponse as _SchemaOpenAIModelListResponse +from app.schemas.openai import OpenAIResponsesOutputMessage as _SchemaOpenAIResponsesOutputMessage +from app.schemas.openai import OpenAIResponsesOutputText as _SchemaOpenAIResponsesOutputText +from app.schemas.openai import OpenAIResponsesRequest as _SchemaOpenAIResponsesRequest +from app.schemas.openai import OpenAIResponsesResponse as _SchemaOpenAIResponsesResponse +from app.schemas.openai import OpenAIUsage as _SchemaOpenAIUsage from app.api.openai_utils import ( build_completion_payload, build_prompt, @@ -26,11 +36,11 @@ from app.application.security.access import openai_bearer_scheme from app.schemas.types import NotificationChannel OPENAI_ERROR_RESPONSES = { - 400: {"model": schemas.OpenAIErrorResponse, "description": "请求格式错误"}, - 401: {"model": schemas.OpenAIErrorResponse, "description": "认证失败"}, - 422: {"model": schemas.OpenAIErrorResponse, "description": "请求参数校验失败"}, - 500: {"model": schemas.OpenAIErrorResponse, "description": "服务内部错误"}, - 503: {"model": schemas.OpenAIErrorResponse, "description": "AI Agent 不可用"}, + 400: {"model": _SchemaOpenAIErrorResponse, "description": "请求格式错误"}, + 401: {"model": _SchemaOpenAIErrorResponse, "description": "认证失败"}, + 422: {"model": _SchemaOpenAIErrorResponse, "description": "请求参数校验失败"}, + 500: {"model": _SchemaOpenAIErrorResponse, "description": "服务内部错误"}, + 503: {"model": _SchemaOpenAIErrorResponse, "description": "AI Agent 不可用"}, } router = APIRouter(responses=OPENAI_ERROR_RESPONSES) @@ -389,8 +399,8 @@ def _error_response( ) -> JSONResponse: return JSONResponse( status_code=status_code, - content=schemas.OpenAIErrorResponse( - error=schemas.OpenAIErrorDetail( + content=_SchemaOpenAIErrorResponse( + error=_SchemaOpenAIErrorDetail( message=message, type=error_type, code=code, @@ -426,7 +436,7 @@ def _check_auth( @router.get( "/models", summary="OpenAI compatible models", - response_model=schemas.OpenAIModelListResponse, + response_model=_SchemaOpenAIModelListResponse, ) async def list_models( credentials: Optional[HTTPAuthorizationCredentials] = Security( @@ -437,15 +447,15 @@ async def list_models( if auth_error: return auth_error now = int(time.time()) - return schemas.OpenAIModelListResponse( - data=[schemas.OpenAIModelInfo(id=MODEL_ID, created=now)] + return _SchemaOpenAIModelListResponse( + data=[_SchemaOpenAIModelInfo(id=MODEL_ID, created=now)] ) @router.post( "/chat/completions", summary="OpenAI compatible chat completions", - response_model=schemas.OpenAIChatCompletionResponse, + response_model=_SchemaOpenAIChatCompletionResponse, responses={ 200: { "description": "OpenAI chat completion 或 SSE 数据流", @@ -456,7 +466,7 @@ async def list_models( }, ) async def chat_completions( - payload: schemas.OpenAIChatCompletionsRequest, + payload: _SchemaOpenAIChatCompletionsRequest, request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Security( openai_bearer_scheme @@ -573,10 +583,10 @@ async def chat_completions( @router.post( "/responses", summary="OpenAI compatible responses", - response_model=schemas.OpenAIResponsesResponse, + response_model=_SchemaOpenAIResponsesResponse, ) async def responses( - payload: schemas.OpenAIResponsesRequest, + payload: _SchemaOpenAIResponsesRequest, credentials: Optional[HTTPAuthorizationCredentials] = Security( openai_bearer_scheme ), @@ -669,14 +679,14 @@ async def responses( created_at = int(time.time()) response_id = f"resp_{uuid.uuid4().hex}" - output_message = schemas.OpenAIResponsesOutputMessage( + output_message = _SchemaOpenAIResponsesOutputMessage( id=f"msg_{uuid.uuid4().hex}", - content=[schemas.OpenAIResponsesOutputText(text=content)], + content=[_SchemaOpenAIResponsesOutputText(text=content)], ) - return schemas.OpenAIResponsesResponse( + return _SchemaOpenAIResponsesResponse( id=response_id, created_at=created_at, model=MODEL_ID, output=[output_message], - usage=schemas.OpenAIUsage(), + usage=_SchemaOpenAIUsage(), ) diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 38784d409..d65bb6a33 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -10,33 +10,49 @@ from fastapi.concurrency import run_in_threadpool from starlette import status from starlette.responses import StreamingResponse -from app import schemas +from app.schemas.common import JsonObject as _SchemaJsonObject +from app.schemas.plugin import Plugin as _SchemaPlugin +from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard +from app.schemas.plugin import PluginDashboardMetaItem as _SchemaPluginDashboardMetaItem +from app.schemas.plugin import PluginFoldersData as _SchemaPluginFoldersData +from app.schemas.plugin import PluginRating as _SchemaPluginRating +from app.schemas.plugin import PluginRatingMap as _SchemaPluginRatingMap +from app.schemas.plugin import PluginRatingRequest as _SchemaPluginRatingRequest +from app.schemas.plugin import PluginReleaseData as _SchemaPluginReleaseData +from app.schemas.plugin import PluginRemoteInfo as _SchemaPluginRemoteInfo +from app.schemas.plugin import PluginSidebarNavItem as _SchemaPluginSidebarNavItem +from app.schemas.response import Response as _SchemaResponse +from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import ResponseAPIRouter from app.application.plugins import ( register_plugin_api, remove_plugin_api, remove_plugin_from_folders, ) +from app.application.plugin.install import PluginInstallCommand +from app.application.plugin.config import PluginConfigCommand from app.application.commands import init_commands from app.application.scheduling import remove_plugin_job, update_plugin_job from app.runtime.cache import async_fresh from app.runtime.config import settings -from app.runtime.events import eventmanager from app.runtime.extensions.plugin_manager import PluginManager from app.application.security.access import ( resource_token_cookie, - verify_apikey, verify_resource_token, verify_token, ) from app.db.models import User from app.db.oper.systemconfig import SystemConfigOper -from app.api.deps import get_current_active_superuser, get_current_active_superuser_async +from app.api.deps import ( + get_current_active_superuser, + get_current_active_superuser_async, + get_plugin_config_command, +) from app.adapters.external.server import MoviePilotServerHelper from app.adapters.external.market import PluginHelper +from app.adapters.system.plugin.package import PluginPackageManager from app.runtime.log import logger -from app.schemas.event import PluginDataResetEventData -from app.schemas.types import ChainEventType, SystemConfigKey +from app.schemas.types import SystemConfigKey router = ResponseAPIRouter() _plugin_release_refresh_tasks: set[asyncio.Task] = set() @@ -47,7 +63,7 @@ async def _get_market_plugin_from_repo( plugin_id: str, repo_url: str, force: bool, -) -> Optional[schemas.Plugin]: +) -> Optional[_SchemaPlugin]: """ 只读取指定插件仓库的市场元数据,避免单插件详情触发全部市场刷新。 """ @@ -115,8 +131,8 @@ def register_plugin(plugin_id: str): def _merge_plugin_market_metadata( - plugin: schemas.Plugin, market_plugin: schemas.Plugin -) -> schemas.Plugin: + plugin: _SchemaPlugin, market_plugin: _SchemaPlugin +) -> _SchemaPlugin: """ 合并插件市场中的远端元数据,供已安装插件按需展示更新说明。 """ @@ -176,7 +192,7 @@ def _verify_plugin_static_file_access( async def _get_plugin_history_detail( plugin_id: str, force: bool = True -) -> Optional[schemas.Plugin]: +) -> Optional[_SchemaPlugin]: """ 按需获取插件远端元数据,避免插件列表加载时批量访问网络。 """ @@ -222,12 +238,12 @@ async def _get_plugin_history_detail( return _merge_plugin_market_metadata(installed_plugin, market_plugin) -@router.get("/", summary="所有插件", response_model=List[schemas.Plugin]) +@router.get("/", summary="所有插件", response_model=List[_SchemaPlugin]) async def all_plugins( _: User = Depends(get_current_active_superuser_async), state: Optional[str] = "all", force: bool = False, -) -> List[schemas.Plugin]: +) -> List[_SchemaPlugin]: """ 查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all """ @@ -289,12 +305,12 @@ async def installed(_: User = Depends(get_current_active_superuser_async)) -> An return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] -@router.get("/history/{plugin_id}", summary="获取插件更新说明", response_model=schemas.Plugin) +@router.get("/history/{plugin_id}", summary="获取插件更新说明", response_model=_SchemaPlugin) async def plugin_history( plugin_id: str, _: User = Depends(get_current_active_superuser_async), force: bool = True, -) -> schemas.Plugin: +) -> _SchemaPlugin: """ 按需获取指定插件的更新说明。 """ @@ -310,7 +326,7 @@ async def plugin_history( @router.get( "/releases/{plugin_id}", summary="获取插件Release版本", - response_model=schemas.PluginReleaseData, + response_model=_SchemaPluginReleaseData, ) async def plugin_releases( plugin_id: str, @@ -373,9 +389,9 @@ async def plugin_releases( @router.get( "/statistic", summary="插件安装统计", - response_model=schemas.JsonObject, + response_model=_SchemaJsonObject, ) -async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def statistic(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 插件安装统计 """ @@ -385,19 +401,19 @@ async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get( "/rating", summary="批量查询插件评分", - response_model=schemas.PluginRatingMap, + response_model=_SchemaPluginRatingMap, ) async def plugin_ratings( plugin_ids: Optional[str] = None, _: User = Depends(get_current_active_superuser_async), -) -> Dict[str, schemas.PluginRating]: +) -> Dict[str, _SchemaPluginRating]: """ 批量查询插件平均分、评分人数和当前安装实例评分。 """ requested_ids = plugin_ids.split(",") if plugin_ids is not None else None ratings = await MoviePilotServerHelper.async_get_plugin_ratings(requested_ids) return { - plugin_id: schemas.PluginRating.model_validate(rating) + plugin_id: _SchemaPluginRating.model_validate(rating) for plugin_id, rating in ratings.items() } @@ -405,29 +421,29 @@ async def plugin_ratings( @router.get( "/rating/{plugin_id}", summary="查询插件评分", - response_model=schemas.PluginRating, + response_model=_SchemaPluginRating, ) async def plugin_rating( plugin_id: str, _: User = Depends(get_current_active_superuser_async), -) -> schemas.PluginRating: +) -> _SchemaPluginRating: """ 查询单个插件平均分、评分人数和当前安装实例评分。 """ rating = await MoviePilotServerHelper.async_get_plugin_rating(plugin_id) - return schemas.PluginRating.model_validate(rating) + return _SchemaPluginRating.model_validate(rating) @router.post( "/rating/{plugin_id}", summary="提交插件评分", - response_model=schemas.Response[schemas.PluginRating], + response_model=_SchemaResponse[_SchemaPluginRating], ) async def rate_plugin( plugin_id: str, - payload: schemas.PluginRatingRequest, + payload: _SchemaPluginRatingRequest, _: User = Depends(get_current_active_superuser_async), -) -> schemas.Response: +) -> _SchemaResponse: """ 为已安装插件新增或更新当前安装实例评分。 """ @@ -443,12 +459,12 @@ async def rate_plugin( payload.rating, ) if rating is None: - return schemas.Response(success=False, message="连接MoviePilot服务器失败") - return schemas.Response(success=True, data=rating) + return _SchemaResponse(success=False, message="连接MoviePilot服务器失败") + return _SchemaResponse(success=True, data=rating) @router.get( - "/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response[None] + "/reload/{plugin_id}", summary="重新加载插件", response_model=_SchemaResponse[None] ) def reload_plugin( plugin_id: str, _: User = Depends(get_current_active_superuser) @@ -460,10 +476,10 @@ def reload_plugin( PluginManager().reload_plugin(plugin_id) # 注册插件服务 register_plugin(plugin_id) - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response[None]) +@router.get("/install/{plugin_id}", summary="安装插件", response_model=_SchemaResponse[None]) async def install( plugin_id: str, repo_url: Optional[str] = "", @@ -474,49 +490,73 @@ async def install( """ 安装插件 """ - # 已安装插件 - install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] - # 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计 plugin_helper = PluginHelper() - if not force and plugin_id in PluginManager().get_plugin_ids(): - if repo_url: - compatible_message = await plugin_helper.async_get_plugin_system_version_check_message( - plugin_id, repo_url - ) - if compatible_message: - return schemas.Response(success=False, message=compatible_message) - await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url) - else: - # 插件不存在或需要强制安装,下载安装并注册插件 - if repo_url: - state, msg = await plugin_helper.async_install( - pid=plugin_id, repo_url=repo_url, release_version=release_version, force_install=force - ) - # 安装失败则直接响应 - if not state: - return schemas.Response(success=False, message=msg) - await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url) - else: - # repo_url 为空时,也直接响应 - return schemas.Response( - success=False, message="没有传入仓库地址,无法正确安装插件,请检查配置" - ) - # 安装插件 - if plugin_id not in install_plugins: - install_plugins.append(plugin_id) - # 保存设置 - await SystemConfigOper().async_set( - SystemConfigKey.UserInstalledPlugins, install_plugins + package_manager = PluginPackageManager(plugin_helper) + + async def save_installed_plugins(plugin_ids: List[str]) -> object: + """保存安装用例确认后的插件列表。""" + return await SystemConfigOper().async_set( + SystemConfigKey.UserInstalledPlugins, + plugin_ids, ) - # 重新加载插件 - await run_in_threadpool(reload_plugin, plugin_id) - return schemas.Response(success=True) + + async def install_package( + target_id: str, + target_repo: str, + target_release: Optional[str], + force_install: bool, + ) -> tuple[bool, str]: + """调用插件包适配器执行异步安装。""" + return await package_manager.async_install( + plugin_id=target_id, + repo_url=target_repo, + release_version=target_release, + force_install=force_install, + ) + + async def reload_runtime(target_id: str) -> object: + """在线程池中重建插件实例并广播重载事件。""" + return await run_in_threadpool(PluginManager().reload_plugin, target_id) + + async def refresh_registrations(target_id: str) -> object: + """在线程池中刷新插件服务、命令和动态路由。""" + return await run_in_threadpool(register_plugin, target_id) + + command = PluginInstallCommand( + installed_plugins_reader=lambda: SystemConfigOper().get( + SystemConfigKey.UserInstalledPlugins + ) or [], + installed_plugins_writer=save_installed_plugins, + plugin_ids_provider=lambda: PluginManager().get_plugin_ids(), + compatibility_checker=plugin_helper.async_get_plugin_system_version_check_message, + package_installer=install_package, + package_checkpointer=package_manager.async_checkpoint, + package_committer=package_manager.async_commit, + package_rollback=package_manager.async_rollback, + install_reporter=lambda target_id, target_repo: ( + MoviePilotServerHelper.async_install_plugin_reg( + plugin_id=target_id, + repo_url=target_repo, + ) + ), + plugin_reloader=reload_runtime, + registration_refresher=refresh_registrations, + ) + result = await command.execute( + plugin_id=plugin_id, + repo_url=repo_url, + release_version=release_version, + force=bool(force), + ) + if not result.success: + return _SchemaResponse(success=False, message=result.message) + return _SchemaResponse(success=True) @router.get( "/remotes", summary="获取插件联邦组件列表", - response_model=List[schemas.PluginRemoteInfo], + response_model=List[_SchemaPluginRemoteInfo], ) async def remotes(token: str) -> Any: """ @@ -530,9 +570,9 @@ async def remotes(token: str) -> Any: @router.get( "/sidebar_nav", summary="获取插件侧栏导航项", - response_model=List[schemas.PluginSidebarNavItem], + response_model=List[_SchemaPluginSidebarNavItem], ) -def plugin_sidebar_nav(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +def plugin_sidebar_nav(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 聚合已启用 Vue 插件声明的侧栏入口(get_sidebar_nav),供前端主界面侧栏展示。 """ @@ -542,7 +582,7 @@ def plugin_sidebar_nav(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get( "/form/{plugin_id}", summary="获取插件表单页面", - response_model=schemas.JsonObject, + response_model=_SchemaJsonObject, ) def plugin_form( plugin_id: str, _: User = Depends(get_current_active_superuser) @@ -578,7 +618,7 @@ def plugin_form( @router.get( "/page/{plugin_id}", summary="获取插件数据页面", - response_model=schemas.JsonObject, + response_model=_SchemaJsonObject, ) def plugin_page( plugin_id: str, _: User = Depends(get_current_active_superuser) @@ -606,7 +646,7 @@ def plugin_page( @router.get( "/dashboard/meta", summary="获取所有插件仪表板元信息", - response_model=List[schemas.PluginDashboardMetaItem], + response_model=List[_SchemaPluginDashboardMetaItem], ) def plugin_dashboard_meta( _: User = Depends(get_current_active_superuser), @@ -623,7 +663,7 @@ def plugin_dashboard_by_key( key: str, user_agent: Annotated[str | None, Header()] = None, _: User = Depends(get_current_active_superuser), -) -> Optional[schemas.PluginDashboard]: +) -> Optional[_SchemaPluginDashboard]: """ 根据插件ID获取插件仪表板 """ @@ -635,7 +675,7 @@ def plugin_dashboard( plugin_id: str, user_agent: Annotated[str | None, Header()] = None, _: User = Depends(get_current_active_superuser), -) -> Optional[schemas.PluginDashboard]: +) -> Optional[_SchemaPluginDashboard]: """ 根据插件ID获取插件仪表板 """ @@ -643,28 +683,18 @@ def plugin_dashboard( @router.get( - "/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response[None] + "/reset/{plugin_id}", summary="重置插件配置及数据", response_model=_SchemaResponse[None] ) def reset_plugin( - plugin_id: str, _: User = Depends(get_current_active_superuser) + plugin_id: str, + _: User = Depends(get_current_active_superuser), + command: PluginConfigCommand = Depends(get_plugin_config_command), ) -> Any: """ 根据插件ID重置插件配置及数据 """ - plugin_manager = PluginManager() - eventmanager.send_event( - ChainEventType.PluginDataReset, - PluginDataResetEventData(plugin_id=plugin_id, reset_config=True, reset_data=True), - ) - # 事件处理器需要运行中插件完成补偿;补偿后先停止插件,避免删除数据时仍有任务读写旧状态。 - plugin_manager.stop(plugin_id) - # 删除配置 - plugin_manager.delete_plugin_config(plugin_id, force=True) - # 删除插件所有数据 - plugin_manager.delete_plugin_data(plugin_id, force=True) - # 重新加载插件 - reload_plugin(plugin_id) - return schemas.Response(success=True) + result = command.reset(plugin_id) + return _SchemaResponse(success=result.success, message=result.message) @router.get( @@ -765,7 +795,7 @@ async def plugin_static_file( @router.get( "/folders", summary="获取插件文件夹配置", - response_model=schemas.PluginFoldersData, + response_model=_SchemaPluginFoldersData, ) async def get_plugin_folders( _: User = Depends(get_current_active_superuser_async), @@ -781,7 +811,7 @@ async def get_plugin_folders( return {} -@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response[None]) +@router.post("/folders", summary="保存插件文件夹配置", response_model=_SchemaResponse[None]) async def save_plugin_folders( folders: dict, _: User = Depends(get_current_active_superuser_async) ) -> Any: @@ -790,14 +820,14 @@ async def save_plugin_folders( """ try: SystemConfigOper().set(SystemConfigKey.PluginFolders, folders) - return schemas.Response(success=True) + return _SchemaResponse(success=True) except Exception as e: logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}") - return schemas.Response(success=False, message=str(e)) + return _SchemaResponse(success=False, message=str(e)) @router.post( - "/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response[None] + "/folders/{folder_name}", summary="创建插件文件夹", response_model=_SchemaResponse[None] ) async def create_plugin_folder( folder_name: str, _: User = Depends(get_current_active_superuser_async) @@ -809,15 +839,15 @@ async def create_plugin_folder( if folder_name not in folders: folders[folder_name] = [] SystemConfigOper().set(SystemConfigKey.PluginFolders, folders) - return schemas.Response( + return _SchemaResponse( success=True, message=f"文件夹 '{folder_name}' 创建成功" ) else: - return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 已存在") + return _SchemaResponse(success=False, message=f"文件夹 '{folder_name}' 已存在") @router.delete( - "/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response[None] + "/folders/{folder_name}", summary="删除插件文件夹", response_model=_SchemaResponse[None] ) async def delete_plugin_folder( folder_name: str, _: User = Depends(get_current_active_superuser_async) @@ -829,17 +859,17 @@ async def delete_plugin_folder( if folder_name in folders: del folders[folder_name] await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders) - return schemas.Response( + return _SchemaResponse( success=True, message=f"文件夹 '{folder_name}' 删除成功" ) else: - return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 不存在") + return _SchemaResponse(success=False, message=f"文件夹 '{folder_name}' 不存在") @router.put( "/folders/{folder_name}/plugins", summary="更新文件夹中的插件", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) async def update_folder_plugins( folder_name: str, @@ -852,13 +882,13 @@ async def update_folder_plugins( folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} folders[folder_name] = plugin_ids await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders) - return schemas.Response( + return _SchemaResponse( success=True, message=f"文件夹 '{folder_name}' 中的插件已更新" ) @router.post( - "/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response[None] + "/clone/{plugin_id}", summary="创建插件分身", response_model=_SchemaResponse[None] ) def clone_plugin( plugin_id: str, clone_data: dict, _: User = Depends(get_current_active_superuser) @@ -881,18 +911,18 @@ def clone_plugin( reload_plugin(message) # 将分身插件添加到原插件所在的文件夹中 _add_clone_to_plugin_folder(plugin_id, message) - return schemas.Response(success=True, message="插件分身创建成功") + return _SchemaResponse(success=True, message="插件分身创建成功") else: - return schemas.Response(success=False, message=message) + return _SchemaResponse(success=False, message=message) except Exception as e: logger.error(f"创建插件分身失败:{str(e)}") - return schemas.Response(success=False, message=f"创建插件分身失败:{str(e)}") + return _SchemaResponse(success=False, message=f"创建插件分身失败:{str(e)}") @router.get( "/{plugin_id}", summary="获取插件配置", - response_model=schemas.JsonObject, + response_model=_SchemaJsonObject, ) async def plugin_config( plugin_id: str, _: User = Depends(get_current_active_superuser_async) @@ -903,24 +933,21 @@ async def plugin_config( return PluginManager().get_plugin_config(plugin_id) -@router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response[None]) +@router.put("/{plugin_id}", summary="更新插件配置", response_model=_SchemaResponse[None]) def set_plugin_config( - plugin_id: str, conf: dict, _: User = Depends(get_current_active_superuser) + plugin_id: str, + conf: dict, + _: User = Depends(get_current_active_superuser), + command: PluginConfigCommand = Depends(get_plugin_config_command), ) -> Any: """ 更新插件配置 """ - plugin_manager = PluginManager() - # 保存配置 - plugin_manager.save_plugin_config(plugin_id, conf) - # 重新生效插件 - plugin_manager.init_plugin(plugin_id, conf) - # 注册插件服务 - register_plugin(plugin_id) - return schemas.Response(success=True) + result = command.update(plugin_id, conf) + return _SchemaResponse(success=result.success, message=result.message) -@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response[None]) +@router.delete("/{plugin_id}", summary="卸载插件", response_model=_SchemaResponse[None]) def uninstall_plugin( plugin_id: str, _: User = Depends(get_current_active_superuser) ) -> Any: @@ -958,7 +985,7 @@ def uninstall_plugin( remove_plugin_from_folders(plugin_id) # 移除插件 plugin_manager.remove_plugin(plugin_id) - return schemas.Response(success=True) + return _SchemaResponse(success=True) def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str): diff --git a/app/api/endpoints/recommend.py b/app/api/endpoints/recommend.py index a3c8d7baf..64ac4d1a6 100644 --- a/app/api/endpoints/recommend.py +++ b/app/api/endpoints/recommend.py @@ -2,13 +2,16 @@ from typing import Any, Awaitable, List, Optional from fastapi import Depends, HTTPException, status -from app import schemas +from app.schemas.event import RecommendMediaSource as _SchemaRecommendMediaSource +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.transfer import MusicInfo as _SchemaMusicInfo +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.recommend import RecommendChain from app.runtime.events import eventmanager from app.application.security.access import verify_token from app.schemas.exception import TMDbException -from app.schemas import RecommendSourceEventData +from app.schemas.event import RecommendSourceEventData from app.schemas.types import ChainEventType router = ResponseAPIRouter() @@ -28,9 +31,9 @@ async def _require_tmdb_result(operation: Awaitable[List[Any]]) -> List[Any]: @router.get( "/source", summary="获取推荐数据源", - response_model=List[schemas.RecommendMediaSource], + response_model=List[_SchemaRecommendMediaSource], ) -def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +def source(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 获取推荐数据源 """ @@ -48,12 +51,12 @@ def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get( "/bangumi_calendar", summary="Bangumi每日放送", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def bangumi_calendar( page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 浏览Bangumi每日放送 @@ -64,12 +67,12 @@ async def bangumi_calendar( @router.get( "/music_weekly", summary="ListenBrainz 本周热门音乐", - response_model=List[schemas.MusicInfo], + response_model=List[_SchemaMusicInfo], ) async def music_weekly( page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """浏览本周全站热门音乐。""" return await RecommendChain().async_music_weekly(page=page, count=count) @@ -78,24 +81,24 @@ async def music_weekly( @router.get( "/music_douban", summary="豆瓣音乐推荐", - response_model=List[schemas.MusicInfo], + response_model=List[_SchemaMusicInfo], ) async def music_douban( page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """浏览豆瓣音乐推荐合集。""" return await RecommendChain().async_music_douban(page=page, count=count) @router.get( - "/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo] + "/douban_showing", summary="豆瓣正在热映", response_model=List[_SchemaMediaInfo] ) async def douban_showing( page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 浏览豆瓣正在热映 @@ -104,14 +107,14 @@ async def douban_showing( @router.get( - "/douban_movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo] + "/douban_movies", summary="豆瓣电影", response_model=List[_SchemaMediaInfo] ) async def douban_movies( sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 浏览豆瓣电影信息 @@ -121,13 +124,13 @@ async def douban_movies( ) -@router.get("/douban_tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo]) +@router.get("/douban_tvs", summary="豆瓣剧集", response_model=List[_SchemaMediaInfo]) async def douban_tvs( sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 浏览豆瓣剧集信息 @@ -140,12 +143,12 @@ async def douban_tvs( @router.get( "/douban_movie_top250", summary="豆瓣电影TOP250", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def douban_movie_top250( page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 浏览豆瓣剧集信息 @@ -156,12 +159,12 @@ async def douban_movie_top250( @router.get( "/douban_tv_weekly_chinese", summary="豆瓣国产剧集周榜", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def douban_tv_weekly_chinese( page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 中国每周剧集口碑榜 @@ -172,12 +175,12 @@ async def douban_tv_weekly_chinese( @router.get( "/douban_tv_weekly_global", summary="豆瓣全球剧集周榜", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def douban_tv_weekly_global( page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 全球每周剧集口碑榜 @@ -188,12 +191,12 @@ async def douban_tv_weekly_global( @router.get( "/douban_tv_animation", summary="豆瓣动画剧集", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def douban_tv_animation( page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 热门动画剧集 @@ -202,12 +205,12 @@ async def douban_tv_animation( @router.get( - "/douban_movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo] + "/douban_movie_hot", summary="豆瓣热门电影", response_model=List[_SchemaMediaInfo] ) async def douban_movie_hot( page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 热门电影 @@ -216,12 +219,12 @@ async def douban_movie_hot( @router.get( - "/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo] + "/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[_SchemaMediaInfo] ) async def douban_tv_hot( page: Optional[int] = 1, count: Optional[int] = 30, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 热门电视剧 @@ -229,7 +232,7 @@ async def douban_tv_hot( return await RecommendChain().async_douban_tv_hot(page=page, count=count) -@router.get("/tmdb_movies", summary="TMDB电影", response_model=List[schemas.MediaInfo]) +@router.get("/tmdb_movies", summary="TMDB电影", response_model=List[_SchemaMediaInfo]) async def tmdb_movies( sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", @@ -240,7 +243,7 @@ async def tmdb_movies( vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 浏览TMDB电影信息 @@ -261,7 +264,7 @@ async def tmdb_movies( ) -@router.get("/tmdb_tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo]) +@router.get("/tmdb_tvs", summary="TMDB剧集", response_model=List[_SchemaMediaInfo]) async def tmdb_tvs( sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", @@ -272,7 +275,7 @@ async def tmdb_tvs( vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 浏览TMDB剧集信息 @@ -294,10 +297,10 @@ async def tmdb_tvs( @router.get( - "/tmdb_trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo] + "/tmdb_trending", summary="TMDB流行趋势", response_model=List[_SchemaMediaInfo] ) async def tmdb_trending( - page: Optional[int] = 1, _: schemas.TokenPayload = Depends(verify_token) + page: Optional[int] = 1, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ TMDB流行趋势 diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index 0fa902abe..d2011f883 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -7,7 +7,13 @@ from uuid import uuid4 from fastapi import Depends, Body, Request from fastapi.responses import StreamingResponse -from app import schemas +from app.schemas.response import Response as _SchemaResponse +from app.schemas.search import SearchLastContextData as _SchemaSearchLastContextData +from app.schemas.search import SearchRecommendStatusData as _SchemaSearchRecommendStatusData +from app.schemas.search import SubtitleInfo as _SchemaSubtitleInfo +from app.schemas.system import TorrentInfo as _SchemaTorrentInfo +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.workflow import Context as _SchemaContext from app.api.response import ResponseAPIRouter from app.chain.search import SearchChain from app.application.security.access import verify_resource_token, verify_token @@ -323,8 +329,8 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di ) -@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context]) -async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get("/last", summary="查询搜索结果", response_model=List[_SchemaContext]) +async def search_latest(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询搜索结果 """ @@ -335,9 +341,9 @@ async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get( "/last/context", summary="查询上次搜索上下文", - response_model=schemas.Response[schemas.SearchLastContextData], + response_model=_SchemaResponse[_SchemaSearchLastContextData], ) -async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def search_latest_context(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询上次搜索结果及其对应的搜索参数。 """ @@ -347,7 +353,7 @@ async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token)) results = await search_chain.async_last_subtitle_search_results() or [] else: results = await search_chain.async_last_search_results() or [] - return schemas.Response( + return _SchemaResponse( success=True, data={ "params": params, @@ -379,7 +385,7 @@ async def search_by_id_stream( season: Optional[str] = None, sites: Optional[str] = None, music_type: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_resource_token), + _: _SchemaTokenPayload = Depends(verify_resource_token), ) -> Any: """ 根据媒体来源和原生 ID 渐进式搜索站点资源,返回格式为 SSE。 @@ -421,7 +427,7 @@ async def search_by_id_stream( @router.get( "/media/{media_id}", summary="精确搜索资源", - response_model=schemas.Response[list[schemas.TorrentInfo]], + response_model=_SchemaResponse[list[_SchemaTorrentInfo]], ) async def search_by_id( media_id: str, @@ -431,7 +437,7 @@ async def search_by_id( season: Optional[str] = None, sites: Optional[str] = None, music_type: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据媒体来源和原生 ID 精确搜索站点资源。 @@ -445,7 +451,7 @@ async def search_by_id( music_type=music_type, ) if not search_params: - return schemas.Response(success=False, message=message) + return _SchemaResponse(success=False, message=message) torrents = await SearchChain().async_search_by_id( **search_params, mtype=media_type, @@ -455,8 +461,8 @@ async def search_by_id( cache_local=True, ) if not torrents: - return schemas.Response(success=False, message="未搜索到任何资源") - return schemas.Response( + return _SchemaResponse(success=False, message="未搜索到任何资源") + return _SchemaResponse( success=True, data=[torrent.to_dict() for torrent in torrents] ) @@ -479,7 +485,7 @@ async def search_by_title_stream( mtype: Optional[str] = None, page: Optional[int] = 0, sites: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_resource_token), + _: _SchemaTokenPayload = Depends(verify_resource_token), ) -> Any: """ 根据名称渐进式模糊搜索站点资源,返回格式为SSE @@ -502,14 +508,14 @@ async def search_by_title_stream( @router.get( "/title", summary="模糊搜索资源", - response_model=schemas.Response[list[schemas.TorrentInfo]], + response_model=_SchemaResponse[list[_SchemaTorrentInfo]], ) async def search_by_title( keyword: Optional[str] = None, mtype: Optional[str] = None, page: Optional[int] = 0, sites: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源 @@ -522,8 +528,8 @@ async def search_by_title( mtype=_parse_media_type(mtype), ) if not torrents: - return schemas.Response(success=False, message="未搜索到任何资源") - return schemas.Response( + return _SchemaResponse(success=False, message="未搜索到任何资源") + return _SchemaResponse( success=True, data=[torrent.to_dict() for torrent in torrents] ) @@ -545,7 +551,7 @@ async def search_subtitle_by_title_stream( keyword: Optional[str] = None, page: Optional[int] = 0, sites: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_resource_token), + _: _SchemaTokenPayload = Depends(verify_resource_token), ) -> Any: """ 根据名称渐进式模糊搜索站点字幕资源,返回格式为SSE。 @@ -567,13 +573,13 @@ async def search_subtitle_by_title_stream( @router.get( "/subtitle/title", summary="模糊搜索字幕", - response_model=schemas.Response[list[schemas.SubtitleInfo]], + response_model=_SchemaResponse[list[_SchemaSubtitleInfo]], ) async def search_subtitle_by_title( keyword: Optional[str] = None, page: Optional[int] = 0, sites: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据名称模糊搜索站点字幕资源,支持分页。 @@ -582,8 +588,8 @@ async def search_subtitle_by_title( title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True ) if not subtitles: - return schemas.Response(success=False, message="未搜索到任何字幕") - return schemas.Response( + return _SchemaResponse(success=False, message="未搜索到任何字幕") + return _SchemaResponse( success=True, data=_serialize_signed_subtitle_results(subtitles) ) @@ -652,7 +658,7 @@ async def search_subtitle_by_id_stream( season: Optional[str] = None, episode: Optional[str] = None, sites: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_resource_token), + _: _SchemaTokenPayload = Depends(verify_resource_token), ) -> Any: """ 根据媒体来源和原生 ID 渐进式精确搜索站点字幕资源,返回格式为 SSE。 @@ -690,7 +696,7 @@ async def search_subtitle_by_id_stream( @router.get( "/subtitle/media/{media_id}", summary="精确搜索字幕", - response_model=schemas.Response[list[schemas.SubtitleInfo]], + response_model=_SchemaResponse[list[_SchemaSubtitleInfo]], ) async def search_subtitle_by_id( media_id: str, @@ -699,7 +705,7 @@ async def search_subtitle_by_id( season: Optional[str] = None, episode: Optional[str] = None, sites: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据媒体来源和原生 ID 精确搜索站点字幕资源。 @@ -713,12 +719,12 @@ async def search_subtitle_by_id( sites=sites, ) if not subtitles: - return schemas.Response(success=False, message=message or "未搜索到任何字幕") + return _SchemaResponse(success=False, message=message or "未搜索到任何字幕") subtitles = await subtitles if not subtitles: - return schemas.Response(success=False, message="未搜索到任何字幕") - return schemas.Response( + return _SchemaResponse(success=False, message="未搜索到任何字幕") + return _SchemaResponse( success=True, data=_serialize_signed_subtitle_results(subtitles) ) @@ -726,7 +732,7 @@ async def search_subtitle_by_id( @router.post( "/recommend", summary="AI推荐资源", - response_model=schemas.Response[schemas.SearchRecommendStatusData], + response_model=_SchemaResponse[_SchemaSearchRecommendStatusData], ) async def recommend_search_results( filtered_indices: Optional[List[int]] = Body( @@ -734,7 +740,7 @@ async def recommend_search_results( ), check_only: bool = Body(False, embed=True, description="仅检查状态,不启动新任务"), force: bool = Body(False, embed=True, description="强制重新推荐,清除旧结果"), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ AI推荐资源 - 轮询接口 @@ -759,7 +765,7 @@ async def recommend_search_results( # 从缓存获取上次搜索结果 results = await SearchChain().async_last_search_results() or [] if not results: - return schemas.Response( + return _SchemaResponse( success=False, message="没有可用的搜索结果", data={"status": "error"} ) @@ -769,12 +775,12 @@ async def recommend_search_results( if force: # 检查功能是否启用 if not recommend_chain.is_ai_recommend_enabled: - return schemas.Response(success=True, data={"status": "disabled"}) + return _SchemaResponse(success=True, data={"status": "disabled"}) logger.info("收到新推荐请求,清除旧结果并启动新任务") recommend_chain.cancel_ai_recommend() recommend_chain.start_recommend_task(filtered_indices, len(results), results) # 直接返回运行中状态 - return schemas.Response(success=True, data={"status": "running"}) + return _SchemaResponse(success=True, data={"status": "running"}) # 如果是仅检查模式,不传递 filtered_indices(避免触发请求变化检测) if check_only: @@ -783,28 +789,28 @@ async def recommend_search_results( # 如果有错误,将错误信息放到message中 if current_status.get("status") == "error": error_msg = current_status.pop("error", "未知错误") - return schemas.Response( + return _SchemaResponse( success=False, message=error_msg, data=current_status ) - return schemas.Response(success=True, data=current_status) + return _SchemaResponse(success=True, data=current_status) # 获取当前状态(会检测请求是否变化) status_data = recommend_chain.get_recommend_status(filtered_indices, len(results)) # 如果功能未启用,直接返回禁用状态 if status_data.get("status") == "disabled": - return schemas.Response(success=True, data=status_data) + return _SchemaResponse(success=True, data=status_data) # 如果是空闲状态,启动新任务 if status_data["status"] == "idle": recommend_chain.start_recommend_task(filtered_indices, len(results), results) # 立即返回运行中状态 - return schemas.Response(success=True, data={"status": "running"}) + return _SchemaResponse(success=True, data={"status": "running"}) # 如果有错误,将错误信息放到message中 if status_data.get("status") == "error": error_msg = status_data.pop("error", "未知错误") - return schemas.Response(success=False, message=error_msg, data=status_data) + return _SchemaResponse(success=False, message=error_msg, data=status_data) # 返回当前状态 - return schemas.Response(success=True, data=status_data) + return _SchemaResponse(success=True, data=status_data) diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index 4987587c4..e734e0939 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -5,8 +5,20 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from starlette.background import BackgroundTasks -from app import schemas +from app.schemas.common import JsonObject as _SchemaJsonObject +from app.schemas.response import Response as _SchemaResponse +from app.schemas.site import SiteAuth as _SchemaSiteAuth +from app.schemas.site import SiteCategory as _SchemaSiteCategory +from app.schemas.site import SiteCookieUpdate as _SchemaSiteCookieUpdate +from app.schemas.site import SiteIconData as _SchemaSiteIconData +from app.schemas.site import SiteMappingData as _SchemaSiteMappingData +from app.schemas.site import SiteStatistic as _SchemaSiteStatistic +from app.schemas.site import SiteUserData as _SchemaSiteUserData +from app.schemas.system import TorrentInfo as _SchemaTorrentInfo +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.workflow import Site as _SchemaSite from app.api.response import ResponseAPIRouter +from app.application.site.mutation import SiteMutationCommand from app.api.endpoints.plugin import register_plugin_api from app.chain.site import SiteChain from app.chain.torrents import TorrentsChain @@ -27,13 +39,13 @@ from app.api.deps import ( get_current_active_manage_user_async, get_current_active_superuser, get_current_active_superuser_async, + get_site_mutation_command, ) from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.runtime.log import logger from app.scheduler import Scheduler from app.schemas.types import SystemConfigKey, EventType, MediaType from app.domain import site as site_rules -from app.foundation import url as url_tools router = ResponseAPIRouter() @@ -74,7 +86,7 @@ def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool: return True -@router.get("/", summary="所有站点", response_model=List[schemas.Site]) +@router.get("/", summary="所有站点", response_model=List[_SchemaSite]) async def read_sites( db: AsyncSession = Depends(get_async_db), _: User = Depends(get_current_active_manage_user_async), @@ -88,7 +100,7 @@ async def read_sites( @router.get( "/media/{media_type}", summary="按媒体类型获取可搜索站点", - response_model=List[schemas.Site], + response_model=List[_SchemaSite], ) async def read_sites_by_media_type( media_type: str, @@ -131,77 +143,35 @@ async def read_sites_by_media_type( ] -@router.post("/", summary="新增站点", response_model=schemas.Response[None]) +@router.post("/", summary="新增站点", response_model=_SchemaResponse[None]) async def add_site( *, - db: AsyncSession = Depends(get_async_db), - site_in: schemas.Site, + site_in: _SchemaSite, + command: SiteMutationCommand = Depends(get_site_mutation_command), _: User = Depends(get_current_active_manage_user_async), ) -> Any: """ 新增站点 """ - if not site_in.url: - return schemas.Response(success=False, message="站点地址不能为空") - if SitesHelper().auth_level < 2: - return schemas.Response( - success=False, message="用户未通过认证,无法使用站点功能!" - ) - domain = site_rules.extract_domain(site_in.url) - site_info = await SitesHelper().async_get_indexer(domain) - if not site_info: - return schemas.Response( - success=False, message="该站点不支持,请检查站点域名是否正确" - ) - if await Site.async_get_by_domain(db, domain): - return schemas.Response(success=False, message=f"{domain} 站点己存在") - # 保存站点信息 - site_in.domain = domain - # 校正地址格式 - _scheme, _netloc = url_tools.split_netloc(site_in.url) - site_in.url = f"{_scheme}://{_netloc}/" - site_in.name = site_info.get("name") - site_in.id = None - site_in.public = 1 if site_info.get("public") else 0 - site = Site(**site_in.model_dump()) - site.create(db) - # 通知站点更新 - await eventmanager.async_send_event(EventType.SiteUpdated, {"domain": domain}) - return schemas.Response(success=True) + result = await command.create(site_in.model_dump()) + return _SchemaResponse(success=result.success, message=result.message) -@router.put("/", summary="更新站点", response_model=schemas.Response[None]) +@router.put("/", summary="更新站点", response_model=_SchemaResponse[None]) async def update_site( *, - db: AsyncSession = Depends(get_async_db), - site_in: schemas.Site, + site_in: _SchemaSite, + command: SiteMutationCommand = Depends(get_site_mutation_command), _: User = Depends(get_current_active_manage_user_async), ) -> Any: """ 更新站点信息 """ - site = await Site.async_get(db, site_in.id) - if not site: - return schemas.Response(success=False, message="站点不存在") - # 校正地址格式 - _scheme, _netloc = url_tools.split_netloc(site_in.url) - site_in.url = f"{_scheme}://{_netloc}/" - site_in.domain = site_rules.extract_domain(site_in.url) - await site.async_update(db, site_in.model_dump()) - # 通知站点更新 - await eventmanager.async_send_event( - EventType.SiteUpdated, - { - "site_id": site_in.id, - "domain": site_in.domain, - "name": site_in.name, - "site_url": site_in.url, - }, - ) - return schemas.Response(success=True) + result = await command.update(site_in.model_dump()) + return _SchemaResponse(success=result.success, message=result.message) -@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response[None]) +@router.get("/cookiecloud", summary="CookieCloud同步", response_model=_SchemaResponse[None]) async def cookie_cloud_sync( background_tasks: BackgroundTasks, _: User = Depends(get_current_active_superuser_async), @@ -210,10 +180,10 @@ async def cookie_cloud_sync( 运行CookieCloud同步站点信息 """ background_tasks.add_task(Scheduler().start, job_id="cookiecloud") - return schemas.Response(success=True, message="CookieCloud同步任务已启动!") + return _SchemaResponse(success=True, message="CookieCloud同步任务已启动!") -@router.get("/reset", summary="重置站点", response_model=schemas.Response[None]) +@router.get("/reset", summary="重置站点", response_model=_SchemaResponse[None]) def reset( db: AsyncSession = Depends(get_db), _: User = Depends(get_current_active_superuser) ) -> Any: @@ -227,25 +197,22 @@ def reset( Scheduler().start("cookiecloud", manual=True) # 插件站点删除 eventmanager.send_event(EventType.SiteDeleted, {"site_id": "*"}) - return schemas.Response(success=True, message="站点已重置!") + return _SchemaResponse(success=True, message="站点已重置!") @router.post( - "/priorities", summary="批量更新站点优先级", response_model=schemas.Response[None] + "/priorities", summary="批量更新站点优先级", response_model=_SchemaResponse[None] ) async def update_sites_priority( priorities: List[dict], - db: AsyncSession = Depends(get_async_db), + command: SiteMutationCommand = Depends(get_site_mutation_command), _: User = Depends(get_current_active_manage_user_async), ) -> Any: """ 批量更新站点优先级 """ - for priority in priorities: - site = await Site.async_get(db, priority.get("id")) - if site: - await site.async_update(db, {"pri": priority.get("pri")}) - return schemas.Response(success=True) + result = await command.update_priorities(priorities) + return _SchemaResponse(success=result.success, message=result.message) def _update_site_cookie( @@ -254,7 +221,7 @@ def _update_site_cookie( password: str, code: Optional[str], db: Session, -) -> schemas.Response: +) -> _SchemaResponse: """ 执行站点 Cookie 与 UA 更新。 @@ -279,15 +246,15 @@ def _update_site_cookie( logger.info(f"站点【{site_info.name}】Cookie&UA更新成功") else: logger.error(f"站点【{site_info.name}】Cookie&UA更新失败:{message}") - return schemas.Response(success=state, message=message) + return _SchemaResponse(success=state, message=message) @router.post( - "/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response[None] + "/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=_SchemaResponse[None] ) def update_cookie_by_body( site_id: int, - site_cookie_update: schemas.SiteCookieUpdate, + site_cookie_update: _SchemaSiteCookieUpdate, db: Session = Depends(get_db), _: User = Depends(get_current_active_manage_user), ) -> Any: @@ -304,7 +271,7 @@ def update_cookie_by_body( @router.get( - "/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response[None] + "/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=_SchemaResponse[None] ) def update_cookie( site_id: int, @@ -329,7 +296,7 @@ def update_cookie( @router.post( "/userdata/{site_id}", summary="更新站点用户数据", - response_model=schemas.Response[schemas.SiteUserData], + response_model=_SchemaResponse[_SchemaSiteUserData], ) def refresh_userdata( site_id: int, @@ -347,17 +314,17 @@ def refresh_userdata( ) indexer = SitesHelper().get_indexer(site.domain) if not indexer: - return schemas.Response( + return _SchemaResponse( success=False, message="站点不支持索引或未通过用户认证!" ) user_data = SiteChain().refresh_userdata(site=indexer) or {} - return schemas.Response(success=True, data=user_data) + return _SchemaResponse(success=True, data=user_data) @router.get( "/userdata/latest", summary="查询所有站点最新用户数据", - response_model=List[schemas.SiteUserData], + response_model=List[_SchemaSiteUserData], ) async def read_userdata_latest( db: AsyncSession = Depends(get_async_db), @@ -375,7 +342,7 @@ async def read_userdata_latest( @router.get( "/userdata/{site_id}", summary="查询某站点用户数据", - response_model=schemas.Response[list[schemas.SiteUserData]], + response_model=_SchemaResponse[list[_SchemaSiteUserData]], ) async def read_userdata( site_id: int, @@ -396,15 +363,15 @@ async def read_userdata( db, domain=site.domain, workdate=workdate ) if not user_datas: - return schemas.Response(success=False, data=[]) - return schemas.Response(success=True, data=[data.to_dict() for data in user_datas]) + return _SchemaResponse(success=False, data=[]) + return _SchemaResponse(success=True, data=[data.to_dict() for data in user_datas]) -@router.get("/test/{site_id}", summary="连接测试", response_model=schemas.Response[None]) +@router.get("/test/{site_id}", summary="连接测试", response_model=_SchemaResponse[None]) def test_site( site_id: int, db: Session = Depends(get_db), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 测试站点是否可用 @@ -416,18 +383,18 @@ def test_site( detail=f"站点 {site_id} 不存在", ) status, message = SiteChain().test(site.domain) - return schemas.Response(success=status, message=message) + return _SchemaResponse(success=status, message=message) @router.get( "/icon/{site_id}", summary="站点图标", - response_model=schemas.Response[schemas.SiteIconData], + response_model=_SchemaResponse[_SchemaSiteIconData], ) async def site_icon( site_id: int, db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 获取站点图标:base64或者url @@ -440,19 +407,19 @@ async def site_icon( ) icon = await SiteIcon.async_get_by_domain(db, site.domain) if not icon: - return schemas.Response(success=False, message="站点图标不存在!") - return schemas.Response( + return _SchemaResponse(success=False, message="站点图标不存在!") + return _SchemaResponse( success=True, data={"icon": icon.base64 if icon.base64 else icon.url} ) @router.get( - "/category/{site_id}", summary="站点分类", response_model=List[schemas.SiteCategory] + "/category/{site_id}", summary="站点分类", response_model=List[_SchemaSiteCategory] ) async def site_category( site_id: int, db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 获取站点分类 @@ -481,7 +448,7 @@ async def site_category( @router.get( - "/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo] + "/resource/{site_id}", summary="站点资源", response_model=List[_SchemaTorrentInfo] ) async def site_resource( site_id: int, @@ -513,11 +480,11 @@ async def site_resource( return [torrent.to_dict() for torrent in torrents] -@router.get("/domain/{site_url}", summary="站点详情", response_model=schemas.Site) +@router.get("/domain/{site_url}", summary="站点详情", response_model=_SchemaSite) async def read_site_by_domain( site_url: str, db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 通过域名获取站点信息 @@ -535,12 +502,12 @@ async def read_site_by_domain( @router.get( "/statistic/{site_url}", summary="特定站点统计信息", - response_model=schemas.SiteStatistic, + response_model=_SchemaSiteStatistic, ) async def read_statistic_by_domain( site_url: str, db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 通过域名获取站点统计信息 @@ -549,15 +516,15 @@ async def read_statistic_by_domain( sitestatistic = await SiteStatistic.async_get_by_domain(db, domain) if sitestatistic: return sitestatistic - return schemas.SiteStatistic(domain=domain) + return _SchemaSiteStatistic(domain=domain) @router.get( - "/statistic", summary="所有站点统计信息", response_model=List[schemas.SiteStatistic] + "/statistic", summary="所有站点统计信息", response_model=List[_SchemaSiteStatistic] ) async def read_statistics( db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 获取所有站点统计信息 @@ -565,10 +532,10 @@ async def read_statistics( return await SiteStatistic.async_list(db) -@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site]) +@router.get("/rss", summary="所有订阅站点", response_model=List[_SchemaSite]) async def read_rss_sites( db: AsyncSession = Depends(get_async_db), - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> List[dict]: """ 获取站点列表 @@ -586,23 +553,23 @@ async def read_rss_sites( return rss_sites -@router.get("/auth", summary="查询认证站点", response_model=schemas.JsonObject) -async def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict: +@router.get("/auth", summary="查询认证站点", response_model=_SchemaJsonObject) +async def read_auth_sites(_: _SchemaTokenPayload = Depends(verify_token)) -> dict: """ 获取可认证站点列表 """ return SitesHelper().get_authsites() -@router.post("/auth", summary="用户站点认证", response_model=schemas.Response[None]) +@router.post("/auth", summary="用户站点认证", response_model=_SchemaResponse[None]) def auth_site( - auth_info: schemas.SiteAuth, _: User = Depends(get_current_active_superuser) + auth_info: _SchemaSiteAuth, _: User = Depends(get_current_active_superuser) ) -> Any: """ 用户站点认证 """ if not auth_info or not auth_info.site or not auth_info.params: - return schemas.Response(success=False, message="请输入认证站点和认证参数") + return _SchemaResponse(success=False, message="请输入认证站点和认证参数") status, msg = SitesHelper().check_user(auth_info.site, auth_info.params) SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.model_dump()) # 认证成功后,重新初始化插件 @@ -610,13 +577,13 @@ def auth_site( Scheduler().init_plugin_jobs() Command().init_commands() register_plugin_api() - return schemas.Response(success=status, message=msg) + return _SchemaResponse(success=status, message=msg) @router.get( "/mapping", summary="获取站点域名到名称的映射", - response_model=schemas.Response[schemas.SiteMappingData], + response_model=_SchemaResponse[_SchemaSiteMappingData], ) async def site_mapping(_: User = Depends(get_current_active_superuser_async)): """ @@ -627,15 +594,15 @@ async def site_mapping(_: User = Depends(get_current_active_superuser_async)): mapping = {} for site in sites: mapping[site.domain] = site.name - return schemas.Response(success=True, data=mapping) + return _SchemaResponse(success=True, data=mapping) except Exception as e: - return schemas.Response(success=False, message=f"获取映射失败:{str(e)}") + return _SchemaResponse(success=False, message=f"获取映射失败:{str(e)}") @router.get( "/supporting", summary="获取支持的站点列表", - response_model=schemas.JsonObject, + response_model=_SchemaJsonObject, ) async def support_sites(_: User = Depends(get_current_active_superuser_async)): """ @@ -644,7 +611,7 @@ async def support_sites(_: User = Depends(get_current_active_superuser_async)): return SitesHelper().get_indexsites() -@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site) +@router.get("/{site_id}", summary="站点详情", response_model=_SchemaSite) async def read_site( site_id: int, db: AsyncSession = Depends(get_async_db), @@ -662,16 +629,14 @@ async def read_site( return site -@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response[None]) +@router.delete("/{site_id}", summary="删除站点", response_model=_SchemaResponse[None]) async def delete_site( site_id: int, - db: AsyncSession = Depends(get_async_db), + command: SiteMutationCommand = Depends(get_site_mutation_command), _: User = Depends(get_current_active_manage_user_async), ) -> Any: """ 删除站点 """ - await Site.async_delete(db, site_id) - # 插件站点删除 - await eventmanager.async_send_event(EventType.SiteDeleted, {"site_id": site_id}) - return schemas.Response(success=True) + result = await command.delete(site_id) + return _SchemaResponse(success=result.success, message=result.message) diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index 1fc35100c..a4f9ace98 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -7,7 +7,9 @@ from typing import Any, Dict, List, Optional from fastapi import Depends, HTTPException from starlette.responses import FileResponse, Response -from app import schemas +from app.schemas.common import ManageRequest as _SchemaManageRequest +from app.schemas.response import Response as _SchemaResponse +from app.schemas.workflow import FileItem as _SchemaFileItem from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.storage import StorageChain @@ -26,10 +28,10 @@ router = ResponseAPIRouter() @router.post( - "/manage", summary="网盘存储统一管理", response_model=schemas.Response[Dict[str, Any]] + "/manage", summary="网盘存储统一管理", response_model=_SchemaResponse[Dict[str, Any]] ) def manage( - request: schemas.ManageRequest, _: User = Depends(get_current_active_superuser) + request: _SchemaManageRequest, _: User = Depends(get_current_active_superuser) ) -> Any: """ 网盘存储统一管理入口 @@ -42,16 +44,16 @@ def manage( action=request.action, **request.params, ) - return schemas.Response( + return _SchemaResponse( success=bool(result.get("success")), message=result.get("message"), data=result.get("data"), ) -@router.post("/list", summary="所有目录和文件", response_model=List[schemas.FileItem]) +@router.post("/list", summary="所有目录和文件", response_model=List[_SchemaFileItem]) def list_files( - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, sort: Optional[str] = "updated_at", keyword: Optional[str] = None, _: User = Depends(get_current_active_manage_user), @@ -76,9 +78,9 @@ def list_files( return file_list -@router.post("/mkdir", summary="创建目录", response_model=schemas.Response[None]) +@router.post("/mkdir", summary="创建目录", response_model=_SchemaResponse[None]) def mkdir( - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, name: str, _: User = Depends(get_current_active_manage_user), ) -> Any: @@ -89,16 +91,16 @@ def mkdir( :param _: token """ if not name: - return schemas.Response(success=False) + return _SchemaResponse(success=False) result = StorageChain().create_folder(fileitem, name) if result: - return schemas.Response(success=True) - return schemas.Response(success=False) + return _SchemaResponse(success=True) + return _SchemaResponse(success=False) -@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response[None]) +@router.post("/delete", summary="删除文件或目录", response_model=_SchemaResponse[None]) def delete( - fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user) + fileitem: _SchemaFileItem, _: User = Depends(get_current_active_manage_user) ) -> Any: """ 删除文件或目录 @@ -107,8 +109,8 @@ def delete( """ result = StorageChain().delete_file(fileitem) if result: - return schemas.Response(success=True) - return schemas.Response(success=False) + return _SchemaResponse(success=True) + return _SchemaResponse(success=False) @router.post( @@ -125,11 +127,11 @@ def delete( } }, }, - 404: {"model": schemas.Response[None], "description": "文件下载失败"}, + 404: {"model": _SchemaResponse[None], "description": "文件下载失败"}, }, ) def download( - fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user) + fileitem: _SchemaFileItem, _: User = Depends(get_current_active_manage_user) ) -> Any: """ 下载文件或目录 @@ -140,7 +142,7 @@ def download( tmp_file = StorageChain().download_file(fileitem) if tmp_file: return FileResponse(path=tmp_file) - return schemas.Response(success=False) + return _SchemaResponse(success=False) @router.post( @@ -158,7 +160,7 @@ def download( }, ) def image( - fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user) + fileitem: _SchemaFileItem, _: User = Depends(get_current_active_manage_user) ) -> Any: """ 下载文件或目录 @@ -172,9 +174,9 @@ def image( return Response(content=tmp_file.read_bytes(), media_type="image/jpeg") -@router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response[None]) +@router.post("/rename", summary="重命名文件或目录", response_model=_SchemaResponse[None]) def rename( - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, new_name: str, recursive: Optional[bool] = False, _: User = Depends(get_current_active_manage_user), @@ -187,14 +189,14 @@ def rename( :param _: token """ if not new_name: - return schemas.Response(success=False, message="新名称为空") + return _SchemaResponse(success=False, message="新名称为空") # 重命名目录内文件 if recursive: transferchain = TransferChain() media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT # 递归修改目录内文件(智能识别命名) - sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem) + sub_files: List[_SchemaFileItem] = StorageChain().list_files(fileitem) if sub_files: # 开始进度 progress = ProgressHelper(ProgressKey.BatchRename) @@ -219,7 +221,7 @@ def rename( ) if not context or not context.media_info: progress.end() - return schemas.Response( + return _SchemaResponse( success=False, message=f"{sub_path.name} 未识别到媒体信息" ) new_path = transferchain.recommend_name( @@ -227,20 +229,20 @@ def rename( ) if not new_path: progress.end() - return schemas.Response( + return _SchemaResponse( success=False, message=f"{sub_path.name} 未识别到新名称" ) - ret: schemas.Response = rename( + ret: _SchemaResponse = rename( fileitem=sub_file, new_name=Path(new_path).name, recursive=False ) if not ret.success: progress.end() - return schemas.Response( + return _SchemaResponse( success=False, message=f"{sub_path.name} 重命名失败!" ) progress.end() # 重命名自己 result = StorageChain().rename_file(fileitem, new_name) if result: - return schemas.Response(success=True) - return schemas.Response(success=False) + return _SchemaResponse(success=True) + return _SchemaResponse(success=False) diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index f914ede6b..4c2104a43 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -5,7 +5,14 @@ from fastapi import Request, BackgroundTasks, Depends, HTTPException, Header from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session -from app import schemas +from app.schemas.common import IdData as _SchemaIdData +from app.schemas.response import Response as _SchemaResponse +from app.schemas.subscribe import SubscrbieInfo as _SchemaSubscrbieInfo +from app.schemas.subscribe import SubscribeShare as _SchemaSubscribeShare +from app.schemas.subscribe import SubscribeShareStatistics as _SchemaSubscribeShareStatistics +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo +from app.schemas.workflow import Subscribe as _SchemaSubscribe from app.api.response import ResponseAPIRouter from app.chain.subscribe import SubscribeChain from app.runtime.config import settings @@ -13,14 +20,30 @@ from app.domain.context import MediaInfo from app.runtime.events import eventmanager from app.domain.metainfo import MetaInfo from app.application.security.access import verify_token, verify_apitoken +from app.application.subscription.delete import ( + DeleteSubscribeCommand, + SubscribeDeletionActor, +) +from app.application.subscription.identity import ( + DeleteSubscriptionsByIdentityCommand, +) +from app.application.subscription.search import ( + SearchSubscriptionsCommand, + SubscribeSearchActor, +) from app.db import get_async_db, get_db from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory from app.db.models.user import User from app.db.oper.systemconfig import SystemConfigOper -from app.api.deps import get_current_active_user, get_current_active_user_async +from app.api.deps import ( + get_current_active_user, + get_current_active_user_async, + get_delete_subscribe_command, + get_delete_subscriptions_by_identity_command, + get_search_subscriptions_command, +) from app.adapters.external.server import MoviePilotServerHelper -from app.runtime.log import logger from app.scheduler import Scheduler from app.schemas.event import SubscribeModifiedEventData from app.schemas.types import ( @@ -159,7 +182,7 @@ async def list_subscribes_by_media_identity( return list(unique_subscribes.values()) -@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe]) +@router.get("/", summary="查询所有订阅", response_model=List[_SchemaSubscribe]) async def read_subscribes( db: AsyncSession = Depends(get_async_db), current_user: User = Depends(get_current_active_user_async), @@ -173,7 +196,7 @@ async def read_subscribes( @router.get( - "/list", summary="查询所有订阅(API_TOKEN)", response_model=List[schemas.Subscribe] + "/list", summary="查询所有订阅(API_TOKEN)", response_model=List[_SchemaSubscribe] ) async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ @@ -185,13 +208,13 @@ async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any: @router.post( "/", summary="新增订阅", - response_model=schemas.Response[schemas.IdData], + response_model=_SchemaResponse[_SchemaIdData], ) async def create_subscribe( *, - subscribe_in: schemas.Subscribe, + subscribe_in: _SchemaSubscribe, current_user: User = Depends(get_current_active_user_async), -) -> schemas.Response: +) -> _SchemaResponse: """ 新增订阅 """ @@ -232,7 +255,7 @@ async def create_subscribe( subscribe_dict["media_source"] = None subscribe_dict["media_id"] = None else: - return schemas.Response( + return _SchemaResponse( success=False, message="新增订阅时必须同时提供有效的 media_source 和 media_id", ) @@ -244,13 +267,13 @@ async def create_subscribe( owner_scope=not current_user.is_superuser, **subscribe_dict, ) - return schemas.Response(success=bool(sid), message=message, data={"id": sid}) + return _SchemaResponse(success=bool(sid), message=message, data={"id": sid}) -@router.put("/", summary="更新订阅", response_model=schemas.Response[None]) +@router.put("/", summary="更新订阅", response_model=_SchemaResponse[None]) async def update_subscribe( *, - subscribe_in: schemas.Subscribe, + subscribe_in: _SchemaSubscribe, db: AsyncSession = Depends(get_async_db), current_user: User = Depends(get_current_active_user_async), ) -> Any: @@ -259,7 +282,7 @@ async def update_subscribe( """ subscribe = await get_accessible_subscribe(db, subscribe_in.id, current_user) if not subscribe: - return schemas.Response(success=False, message="订阅不存在") + return _SchemaResponse(success=False, message="订阅不存在") old_subscribe_dict = subscribe.to_dict() subscribe_dict = subscribe_in.to_public_write_payload(exclude_unset=True) identity_fields = {"media_source", "media_id"}.intersection( @@ -278,7 +301,7 @@ async def update_subscribe( subscribe_dict["media_source"] = None subscribe_dict["media_id"] = None else: - return schemas.Response( + return _SchemaResponse( success=False, message="更新媒体身份时必须同时提供有效的 media_source 和 media_id", ) @@ -316,10 +339,10 @@ async def update_subscribe( scene="update", ).to_dict(), ) - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response[None]) +@router.put("/status/{subid}", summary="更新订阅状态", response_model=_SchemaResponse[None]) async def update_subscribe_status( subid: int, state: str, @@ -331,10 +354,10 @@ async def update_subscribe_status( """ subscribe = await get_accessible_subscribe(db, subid, current_user) if not subscribe: - return schemas.Response(success=False, message="订阅不存在") + return _SchemaResponse(success=False, message="订阅不存在") valid_states = ["R", "P", "S"] if state not in valid_states: - return schemas.Response(success=False, message="无效的订阅状态") + return _SchemaResponse(success=False, message="无效的订阅状态") old_subscribe_dict = subscribe.to_dict() await subscribe.async_update(db, {"state": state}) # 重新获取更新后的订阅数据 @@ -349,10 +372,10 @@ async def update_subscribe_status( scene="status", ).to_dict(), ) - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.get("/media/{media_id}", summary="查询订阅", response_model=schemas.Subscribe) +@router.get("/media/{media_id}", summary="查询订阅", response_model=_SchemaSubscribe) async def subscribe_media_identity( media_id: str, media_source: MediaSource, @@ -372,7 +395,7 @@ async def subscribe_media_identity( return result if result else Subscribe() -@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response[None]) +@router.get("/refresh", summary="刷新订阅", response_model=_SchemaResponse[None]) def refresh_subscribes( current_user: User = Depends(get_current_active_user), ) -> Any: @@ -380,12 +403,12 @@ def refresh_subscribes( 刷新所有订阅 """ if not current_user.is_superuser: - return schemas.Response(success=False, message="订阅不存在") + return _SchemaResponse(success=False, message="订阅不存在") Scheduler().start("subscribe_refresh") - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response[None]) +@router.get("/reset/{subid}", summary="重置订阅", response_model=_SchemaResponse[None]) async def reset_subscribes( subid: int, db: AsyncSession = Depends(get_async_db), @@ -429,11 +452,11 @@ async def reset_subscribes( scene="reset", ).to_dict(), ) - return schemas.Response(success=True) - return schemas.Response(success=False, message="订阅不存在") + return _SchemaResponse(success=True) + return _SchemaResponse(success=False, message="订阅不存在") -@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response[None]) +@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=_SchemaResponse[None]) def check_subscribes( current_user: User = Depends(get_current_active_user), ) -> Any: @@ -441,107 +464,80 @@ def check_subscribes( 刷新订阅 TMDB 信息 """ if not current_user.is_superuser: - return schemas.Response(success=False, message="订阅不存在") + return _SchemaResponse(success=False, message="订阅不存在") Scheduler().start("subscribe_tmdb") - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response[None]) +@router.get("/search", summary="搜索所有订阅", response_model=_SchemaResponse[None]) async def search_subscribes( - background_tasks: BackgroundTasks, - db: AsyncSession = Depends(get_async_db), + command: SearchSubscriptionsCommand = Depends(get_search_subscriptions_command), current_user: User = Depends(get_current_active_user_async), ) -> Any: """ 搜索所有订阅 """ - if current_user.is_superuser: - background_tasks.add_task( - Scheduler().start, - job_id="subscribe_search", - **{"sid": None, "state": "R", "manual": True}, + await command.execute( + SubscribeSearchActor( + username=current_user.name, + is_superuser=current_user.is_superuser, ) - else: - subscribes = await Subscribe.async_list_by_username( - db, current_user.name, state="R" - ) - for subscribe in subscribes: - background_tasks.add_task( - Scheduler().start, - job_id="subscribe_search", - **{"sid": subscribe.id, "state": None, "manual": True}, - ) - return schemas.Response(success=True) + ) + return _SchemaResponse(success=True) @router.get( - "/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response[None] + "/search/{subscribe_id}", summary="搜索订阅", response_model=_SchemaResponse[None] ) async def search_subscribe( subscribe_id: int, - background_tasks: BackgroundTasks, - db: AsyncSession = Depends(get_async_db), + command: SearchSubscriptionsCommand = Depends(get_search_subscriptions_command), current_user: User = Depends(get_current_active_user_async), ) -> Any: """ 根据订阅编号搜索订阅 """ - subscribe = await get_accessible_subscribe(db, subscribe_id, current_user) - if not subscribe: - return schemas.Response(success=False, message="订阅不存在") - background_tasks.add_task( - Scheduler().start, - job_id="subscribe_search", - **{"sid": subscribe_id, "state": None, "manual": True}, + found = await command.execute( + SubscribeSearchActor( + username=current_user.name, + is_superuser=current_user.is_superuser, + ), + subscribe_id=subscribe_id, ) - return schemas.Response(success=True) + if not found: + return _SchemaResponse(success=False, message="订阅不存在") + return _SchemaResponse(success=True) -@router.delete("/media/{media_id}", summary="删除订阅", response_model=schemas.Response[None]) +@router.delete("/media/{media_id}", summary="删除订阅", response_model=_SchemaResponse[None]) async def delete_subscribe_by_media_identity( media_id: str, media_source: MediaSource, season: Optional[int] = None, music_type: Optional[str] = None, - db: AsyncSession = Depends(get_async_db), + command: DeleteSubscriptionsByIdentityCommand = Depends( + get_delete_subscriptions_by_identity_command + ), current_user: User = Depends(get_current_active_user_async), ) -> Any: """ 根据任意媒体数据源 ID 删除订阅。 """ - delete_subscribes = await list_subscribes_by_media_identity( - db, media_source, media_id, season, music_type + await command.execute( + media_source, + media_id, + season, + music_type, + SubscribeDeletionActor( + username=current_user.name, + is_superuser=current_user.is_superuser, + ), ) - delete_events = [] - for subscribe in [ - subscribe - for subscribe in delete_subscribes - if can_access_subscribe(subscribe, current_user) - ]: - subscribe_info = build_subscribe_event_payload(subscribe) - subscribe_id = subscribe_info.get("id") - if not subscribe_id: - continue - delete_events.append((subscribe_id, subscribe_info)) - await db.delete(subscribe) - try: - await db.commit() - except Exception: - await db.rollback() - raise - for subscribe_id, subscribe_info in delete_events: - try: - await eventmanager.async_send_event( - EventType.SubscribeDeleted, - {"subscribe_id": subscribe_id, "subscribe_info": subscribe_info}, - ) - except Exception as err: - logger.error(f"发送订阅删除事件失败:{subscribe_id} - {err}", exc_info=True) - return schemas.Response(success=True) + return _SchemaResponse(success=True) @router.post( - "/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response[None] + "/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=_SchemaResponse[None] ) async def seerr_subscribe( request: Request, @@ -564,7 +560,7 @@ async def seerr_subscribe( ) notification_type = req_json.get("notification_type") if notification_type not in ["MEDIA_APPROVED", "MEDIA_AUTO_APPROVED"]: - return schemas.Response(success=False, message="不支持的通知类型") + return _SchemaResponse(success=False, message="不支持的通知类型") subject = req_json.get("subject") media_type = ( MediaType.MOVIE @@ -573,7 +569,7 @@ async def seerr_subscribe( ) tmdbId = req_json.get("media", {}).get("tmdbId") if not media_type or not tmdbId or not subject: - return schemas.Response(success=False, message="请求参数不正确") + return _SchemaResponse(success=False, message="请求参数不正确") user_name = req_json.get("request", {}).get("requestedBy_username") # 添加订阅 if media_type == MediaType.MOVIE: @@ -610,11 +606,11 @@ async def seerr_subscribe( username=user_name, ) - return schemas.Response(success=True) + return _SchemaResponse(success=True) @router.get( - "/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe] + "/history/{mtype}", summary="查询订阅历史", response_model=List[_SchemaSubscribe] ) async def subscribe_history( mtype: str, @@ -636,7 +632,7 @@ async def subscribe_history( ) result = [] for history in histories: - history_item = schemas.Subscribe.model_validate(history, from_attributes=True) + history_item = _SchemaSubscribe.model_validate(history, from_attributes=True) if history_item.type == MediaType.TV.value: history_item.total_episode = 0 history_item.lack_episode = 0 @@ -645,7 +641,7 @@ async def subscribe_history( @router.delete( - "/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response[None] + "/history/{history_id}", summary="删除订阅历史", response_model=_SchemaResponse[None] ) async def delete_subscribe_history( history_id: int, @@ -658,13 +654,13 @@ async def delete_subscribe_history( history = await SubscribeHistory.async_get(db, history_id) if can_access_subscribe(history, current_user): await SubscribeHistory.async_delete(db, history_id) - return schemas.Response(success=True) + return _SchemaResponse(success=True) @router.get( "/popular", summary="热门订阅(基于用户共享数据)", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def popular_subscribes( stype: str, @@ -675,7 +671,7 @@ async def popular_subscribes( min_rating: Optional[float] = None, max_rating: Optional[float] = None, sort_type: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 查询热门订阅 @@ -721,7 +717,7 @@ async def popular_subscribes( @router.get( - "/user/{username}", summary="用户订阅", response_model=List[schemas.Subscribe] + "/user/{username}", summary="用户订阅", response_model=List[_SchemaSubscribe] ) async def user_subscribes( username: str, @@ -739,7 +735,7 @@ async def user_subscribes( @router.get( "/files/{subscribe_id}", summary="订阅相关文件信息", - response_model=schemas.SubscrbieInfo, + response_model=_SchemaSubscrbieInfo, ) def subscribe_files( subscribe_id: int, @@ -752,12 +748,12 @@ def subscribe_files( subscribe = get_accessible_subscribe_sync(db, subscribe_id, current_user) if subscribe: return SubscribeChain().subscribe_files_info(subscribe) - return schemas.SubscrbieInfo() + return _SchemaSubscrbieInfo() -@router.post("/share", summary="分享订阅", response_model=schemas.Response[None]) +@router.post("/share", summary="分享订阅", response_model=_SchemaResponse[None]) async def subscribe_share( - sub: schemas.SubscribeShare, + sub: _SchemaSubscribeShare, db: AsyncSession = Depends(get_async_db), current_user: User = Depends(get_current_active_user_async), ) -> Any: @@ -766,30 +762,30 @@ async def subscribe_share( """ subscribe = await get_accessible_subscribe(db, sub.subscribe_id, current_user) if not subscribe: - return schemas.Response(success=False, message="订阅不存在") + return _SchemaResponse(success=False, message="订阅不存在") state, errmsg = await MoviePilotServerHelper.async_sub_share( subscribe_id=sub.subscribe_id, share_title=sub.share_title, share_comment=sub.share_comment, share_user=sub.share_user, ) - return schemas.Response(success=state, message=errmsg) + return _SchemaResponse(success=state, message=errmsg) -@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response[None]) +@router.delete("/share/{share_id}", summary="删除分享", response_model=_SchemaResponse[None]) async def subscribe_share_delete( - share_id: int, _: schemas.TokenPayload = Depends(verify_token) + share_id: int, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 删除分享 """ state, errmsg = await MoviePilotServerHelper.async_share_delete(share_id=share_id) - return schemas.Response(success=state, message=errmsg) + return _SchemaResponse(success=state, message=errmsg) -@router.post("/fork", summary="复用订阅", response_model=schemas.Response[None]) +@router.post("/fork", summary="复用订阅", response_model=_SchemaResponse[None]) async def subscribe_fork( - sub: schemas.SubscribeShare, + sub: _SchemaSubscribeShare, current_user: User = Depends(get_current_active_user_async), ) -> Any: """ @@ -798,10 +794,10 @@ async def subscribe_fork( sub_dict = sub.model_dump() sub_dict.pop("id") for key in list(sub_dict.keys()): - if not hasattr(schemas.Subscribe(), key): + if not hasattr(_SchemaSubscribe(), key): sub_dict.pop(key) result = await create_subscribe( - subscribe_in=schemas.Subscribe(**sub_dict), current_user=current_user + subscribe_in=_SchemaSubscribe(**sub_dict), current_user=current_user ) if result.success: await MoviePilotServerHelper.async_sub_fork(share_id=sub.id) @@ -809,16 +805,16 @@ async def subscribe_fork( @router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str]) -async def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def followed_subscribers(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询已Follow的订阅分享人 """ return SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or [] -@router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response[None]) +@router.post("/follow", summary="Follow订阅分享人", response_model=_SchemaResponse[None]) async def follow_subscriber( - share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token) + share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ Follow订阅分享人 @@ -829,14 +825,14 @@ async def follow_subscriber( await SystemConfigOper().async_set( SystemConfigKey.FollowSubscribers, subscribers ) - return schemas.Response(success=True) + return _SchemaResponse(success=True) @router.delete( - "/follow", summary="取消Follow订阅分享人", response_model=schemas.Response[None] + "/follow", summary="取消Follow订阅分享人", response_model=_SchemaResponse[None] ) async def unfollow_subscriber( - share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token) + share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 取消Follow订阅分享人 @@ -847,11 +843,11 @@ async def unfollow_subscriber( await SystemConfigOper().async_set( SystemConfigKey.FollowSubscribers, subscribers ) - return schemas.Response(success=True) + return _SchemaResponse(success=True) @router.get( - "/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare] + "/shares", summary="查询分享的订阅", response_model=List[_SchemaSubscribeShare] ) async def subscribe_shares( name: Optional[str] = None, @@ -861,7 +857,7 @@ async def subscribe_shares( min_rating: Optional[float] = None, max_rating: Optional[float] = None, sort_type: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 查询分享的订阅 @@ -880,10 +876,10 @@ async def subscribe_shares( @router.get( "/share/statistics", summary="查询订阅分享统计", - response_model=List[schemas.SubscribeShareStatistics], + response_model=List[_SchemaSubscribeShareStatistics], ) async def subscribe_share_statistics( - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 查询订阅分享统计 @@ -892,7 +888,7 @@ async def subscribe_share_statistics( return await MoviePilotServerHelper.async_get_subscribe_share_statistics() -@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe) +@router.get("/{subscribe_id}", summary="订阅详情", response_model=_SchemaSubscribe) async def read_subscribe( subscribe_id: int, db: AsyncSession = Depends(get_async_db), @@ -907,36 +903,20 @@ async def read_subscribe( return subscribe if subscribe else Subscribe() -@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response[None]) +@router.delete("/{subscribe_id}", summary="删除订阅", response_model=_SchemaResponse[None]) async def delete_subscribe( subscribe_id: int, - db: AsyncSession = Depends(get_async_db), + command: DeleteSubscribeCommand = Depends(get_delete_subscribe_command), current_user: User = Depends(get_current_active_user_async), ) -> Any: """ 删除订阅信息 """ - subscribe = await get_accessible_subscribe(db, subscribe_id, current_user) - if subscribe: - # 在删除之前获取订阅信息 - subscribe_info = build_subscribe_event_payload(subscribe) - await db.delete(subscribe) - try: - await db.commit() - except Exception: - await db.rollback() - raise - # 发送事件 - await eventmanager.async_send_event( - EventType.SubscribeDeleted, - {"subscribe_id": subscribe_id, "subscribe_info": subscribe_info}, - ) - # 统计订阅 - MoviePilotServerHelper.sub_done_async( - { - "media_source": subscribe_info.get("media_source"), - "media_id": subscribe_info.get("media_id"), - "season": subscribe_info.get("season"), - } - ) - return schemas.Response(success=True) + await command.execute( + subscribe_id, + SubscribeDeletionActor( + username=current_user.name, + is_superuser=current_user.is_superuser, + ), + ) + return _SchemaResponse(success=True) diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index e24aeb53e..760d582de 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -11,13 +11,25 @@ from urllib.parse import urljoin, urlparse import aiofiles import anyio -import pillow_avif # noqa 用于自动注册AVIF支持 +import pillow_avif # noqa: F401 # pylint: disable=unused-import # AVIF 注册副作用 from anyio import Path as AsyncPath from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from fastapi import Body, Depends, HTTPException, Header, Request, Response from fastapi.responses import StreamingResponse -from app import schemas +from app.schemas.common import JsonObject as _SchemaJsonObject +from app.schemas.common import JsonObjectList as _SchemaJsonObjectList +from app.schemas.common import TimeData as _SchemaTimeData +from app.schemas.common import ValueData as _SchemaValueData +from app.schemas.response import Response as _SchemaResponse +from app.schemas.system import NetTestTarget as _SchemaNetTestTarget +from app.schemas.system import PluginMarketSyncData as _SchemaPluginMarketSyncData +from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyncRequest +from app.schemas.system import RuleTestData as _SchemaRuleTestData +from app.schemas.system import SystemEnvironmentUpdateData as _SchemaSystemEnvironmentUpdateData +from app.schemas.system import SystemModuleListData as _SchemaSystemModuleListData +from app.schemas.system import TorrentInfo as _SchemaTorrentInfo +from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.mediaserver import MediaServerChain @@ -46,7 +58,7 @@ from app.adapters.external.server import MoviePilotServerHelper from app.runtime.state import SystemHelper from app.runtime.log import logger from app.scheduler import Scheduler -from app.schemas import ConfigChangeEventData +from app.schemas.event import ConfigChangeEventData from app.schemas.types import SystemConfigKey, EventType from app.foundation.crypto import HashUtils from app.adapters.network.http import RequestUtils, AsyncRequestUtils @@ -394,8 +406,8 @@ def _collect_named_log_files(name: str) -> list[Path]: def _verify_log_resource_superuser( - token_payload: schemas.TokenPayload = Depends(verify_resource_token), -) -> schemas.TokenPayload: + token_payload: _SchemaTokenPayload = Depends(verify_resource_token), +) -> _SchemaTokenPayload: """ 校验日志资源访问权限。 @@ -601,7 +613,7 @@ async def proxy_img( cache: bool = False, use_cookies: bool = False, if_none_match: Annotated[str | None, Header()] = None, - _: schemas.TokenPayload = Depends(verify_resource_token), + _: _SchemaTokenPayload = Depends(verify_resource_token), ) -> Response: """ 图片代理,可选是否使用代理服务器,支持 HTTP 缓存 @@ -642,7 +654,7 @@ async def proxy_img( async def cache_img( url: str, if_none_match: Annotated[str | None, Header()] = None, - _: schemas.TokenPayload = Depends(verify_resource_token), + _: _SchemaTokenPayload = Depends(verify_resource_token), ) -> Response: """ 本地缓存图片文件,支持 HTTP 缓存,如果启用全局图片缓存,则使用磁盘缓存 @@ -656,7 +668,7 @@ async def cache_img( @router.get( "/global", summary="查询非敏感系统设置", - response_model=schemas.Response[schemas.JsonObject], + response_model=_SchemaResponse[_SchemaJsonObject], ) def get_global_setting(token: str): """ @@ -684,13 +696,13 @@ def get_global_setting(token: str): # 仅在后端开发模式下返回该标记,避免生产环境暴露无意义运行态信息 if settings.DEV: info.update({"BACKEND_DEV": True}) - return schemas.Response(success=True, data=info) + return _SchemaResponse(success=True, data=info) @router.get( "/global/user", summary="查询用户相关系统设置", - response_model=schemas.Response[schemas.JsonObject], + response_model=_SchemaResponse[_SchemaJsonObject], ) async def get_user_global_setting(_: User = Depends(get_current_active_user_async)): """ @@ -724,17 +736,17 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn "WORKFLOW_SHARE_MANAGE": share_admin, } ) - return schemas.Response(success=True, data=info) + return _SchemaResponse(success=True, data=info) @router.get( "/env", summary="查询系统配置", - response_model=schemas.Response[schemas.JsonObject], + response_model=_SchemaResponse[_SchemaJsonObject], ) async def get_env_setting( _: User = Depends(get_current_active_superuser_async), -) -> schemas.Response: +) -> _SchemaResponse: """ 查询系统环境变量,包括当前版本号(仅管理员) """ @@ -749,33 +761,33 @@ async def get_env_setting( "RUST_ACCEL_ENABLED": rust_accel.is_enabled(), } ) - return schemas.Response(success=True, data=info) + return _SchemaResponse(success=True, data=info) @router.get( "/usage/statistic", summary="查询安装版本统计报表", - response_model=schemas.Response[schemas.JsonObject], + response_model=_SchemaResponse[_SchemaJsonObject], ) async def usage_statistic(_: User = Depends(get_current_active_user_async)): """ 查询安装版本统计报表 """ - return schemas.Response(success=True, data=await MoviePilotServerHelper.async_get_usage_statistic()) + return _SchemaResponse(success=True, data=await MoviePilotServerHelper.async_get_usage_statistic()) -@router.get("/ping", summary="服务存活检测", response_model=schemas.Response[None]) -async def ping(_: User = Depends(get_current_active_user_async)) -> schemas.Response: +@router.get("/ping", summary="服务存活检测", response_model=_SchemaResponse[None]) +async def ping(_: User = Depends(get_current_active_user_async)) -> _SchemaResponse: """ 检测服务是否可用 """ - return schemas.Response(success=True) + return _SchemaResponse(success=True) @router.post( "/env", summary="更新系统配置", - response_model=schemas.Response[schemas.SystemEnvironmentUpdateData], + response_model=_SchemaResponse[_SchemaSystemEnvironmentUpdateData], ) async def set_env_setting( env: dict, _: User = Depends(get_current_active_superuser_async) @@ -785,7 +797,7 @@ async def set_env_setting( """ validation_error = _validate_llm_server_tool_config(env) if validation_error: - return schemas.Response(success=False, message=validation_error) + return _SchemaResponse(success=False, message=validation_error) result = settings.update_settings(env=env) # 统计成功和失败的结果 @@ -793,7 +805,7 @@ async def set_env_setting( failed_updates = {k: v for k, v in result.items() if v[0] is False} if failed_updates: - return schemas.Response( + return _SchemaResponse( success=False, message=f"{', '.join([v[1] for v in failed_updates.values()])}", data={"success_updates": success_updates, "failed_updates": failed_updates}, @@ -808,7 +820,7 @@ async def set_env_setting( ), ) - return schemas.Response( + return _SchemaResponse( success=True, message="所有配置项更新成功", data={"success_updates": success_updates}, @@ -830,7 +842,7 @@ async def set_env_setting( async def get_progress( request: Request, process_type: str, - _: schemas.TokenPayload = Depends(verify_resource_token), + _: _SchemaTokenPayload = Depends(verify_resource_token), ): """ 实时获取处理进度,返回格式为SSE @@ -855,38 +867,38 @@ async def get_progress( @router.get( "/setting/public/{key}", summary="查询公开系统设置", - response_model=schemas.Response[schemas.ValueData], + response_model=_SchemaResponse[_SchemaValueData], ) async def get_public_setting( key: str, _: User = Depends(get_current_active_user_async) -) -> schemas.Response: +) -> _SchemaResponse: """ 查询普通用户可读取的非敏感系统设置 """ if key in _PUBLIC_SETTINGS_KEYS: - return schemas.Response(success=True, data={"value": getattr(settings, key)}) + return _SchemaResponse(success=True, data={"value": getattr(settings, key)}) if key not in _PUBLIC_SYSTEM_CONFIG_KEYS: raise HTTPException(status_code=404, detail="配置项不存在") value = SystemConfigOper().get(_PUBLIC_SYSTEM_CONFIG_KEYS[key]) - return schemas.Response(success=True, data={"value": value}) + return _SchemaResponse(success=True, data={"value": value}) @router.post( "/setting/PLUGIN_MARKET/sync-wiki", summary="从Wiki同步插件市场仓库", - response_model=schemas.Response[schemas.PluginMarketSyncData], + response_model=_SchemaResponse[_SchemaPluginMarketSyncData], ) async def sync_plugin_market_from_wiki( - request: Optional[schemas.PluginMarketSyncRequest] = Body(default=None), + request: Optional[_SchemaPluginMarketSyncRequest] = Body(default=None), _: User = Depends(get_current_active_superuser_async), -) -> schemas.Response: +) -> _SchemaResponse: """ 从 Wiki 插件文档同步插件市场仓库地址。 """ wiki_url = (request.wiki_url if request else None) or PLUGIN_MARKET_WIKI_URL wiki_url = wiki_url.strip() if not _is_allowed_plugin_market_wiki_url(wiki_url): - return schemas.Response(success=False, message="不支持的 Wiki 同步地址") + return _SchemaResponse(success=False, message="不支持的 Wiki 同步地址") res = await AsyncRequestUtils( ua=settings.USER_AGENT, @@ -896,16 +908,16 @@ async def sync_plugin_market_from_wiki( accept_type="text/plain,*/*", ).get_res(wiki_url) if res is None: - return schemas.Response(success=False, message="无法访问 Wiki 插件仓库清单") + return _SchemaResponse(success=False, message="无法访问 Wiki 插件仓库清单") if res.status_code != 200: - return schemas.Response( + return _SchemaResponse( success=False, message=f"访问 Wiki 插件仓库清单失败,状态码:{res.status_code}", ) wiki_repos = extract_plugin_market_repos_from_wiki(res.text) if not wiki_repos: - return schemas.Response(success=False, message="未在 Wiki 中识别到插件仓库地址") + return _SchemaResponse(success=False, message="未在 Wiki 中识别到插件仓库地址") local_repos = split_plugin_market_repo_urls(settings.PLUGIN_MARKET) local_repo_keys = {repo.lower() for repo in local_repos} @@ -924,7 +936,7 @@ async def sync_plugin_market_from_wiki( elif success is None: success = True - return schemas.Response( + return _SchemaResponse( success=success, message=message, data={ @@ -941,11 +953,11 @@ async def sync_plugin_market_from_wiki( @router.get( "/setting/{key}", summary="查询系统设置", - response_model=schemas.Response[schemas.ValueData], + response_model=_SchemaResponse[_SchemaValueData], ) async def get_setting( key: str, _: User = Depends(get_current_active_superuser_async) -) -> schemas.Response: +) -> _SchemaResponse: """ 查询系统设置(仅管理员) """ @@ -953,10 +965,10 @@ async def get_setting( value = getattr(settings, key) else: value = SystemConfigOper().get(key) - return schemas.Response(success=True, data={"value": value}) + return _SchemaResponse(success=True, data={"value": value}) -@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response[None]) +@router.post("/setting/{key}", summary="更新系统设置", response_model=_SchemaResponse[None]) async def set_setting( key: str, value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None, @@ -975,7 +987,7 @@ async def set_setting( ) elif success is None: success = True - return schemas.Response(success=success, message=message) + return _SchemaResponse(success=success, message=message) elif key in {item.value for item in SystemConfigKey}: if isinstance(value, list): value = list(filter(None, value)) @@ -987,9 +999,9 @@ async def set_setting( etype=EventType.ConfigChanged, data=ConfigChangeEventData(key=key, value=value, change_type="update"), ) - return schemas.Response(success=True) + return _SchemaResponse(success=True) else: - return schemas.Response(success=False, message=f"配置项 '{key}' 不存在") + return _SchemaResponse(success=False, message=f"配置项 '{key}' 不存在") @router.get( @@ -1007,7 +1019,7 @@ async def set_setting( async def get_message( request: Request, role: Optional[str] = "system", - _: schemas.TokenPayload = Depends(verify_resource_token), + _: _SchemaTokenPayload = Depends(verify_resource_token), ): """ 实时获取系统消息,返回格式为SSE @@ -1047,7 +1059,7 @@ async def get_logging( request: Request, length: Optional[int] = 50, logfile: Optional[str] = "moviepilot.log", - _: schemas.TokenPayload = Depends(_verify_log_resource_superuser), + _: _SchemaTokenPayload = Depends(_verify_log_resource_superuser), ): """ 实时获取系统日志 @@ -1174,7 +1186,7 @@ async def get_logging( ) async def download_logging( name: str, - _: schemas.TokenPayload = Depends(_verify_log_resource_superuser), + _: _SchemaTokenPayload = Depends(_verify_log_resource_superuser), ): """ 按日志标识下载主程序或插件滚动日志,返回 zip 文件。 @@ -1185,9 +1197,9 @@ async def download_logging( @router.get( "/versions", summary="查询Github所有Release版本", - response_model=schemas.Response[schemas.JsonObjectList], + response_model=_SchemaResponse[_SchemaJsonObjectList], ) -async def latest_version(_: schemas.TokenPayload = Depends(verify_token)): +async def latest_version(_: _SchemaTokenPayload = Depends(verify_token)): """ 查询Github所有Release版本 """ @@ -1197,26 +1209,26 @@ async def latest_version(_: schemas.TokenPayload = Depends(verify_token)): if version_res is not None and version_res.status_code == 200: ver_json = version_res.json() if ver_json: - return schemas.Response(success=True, data=ver_json) - return schemas.Response(success=False) + return _SchemaResponse(success=True, data=ver_json) + return _SchemaResponse(success=False) @router.get( "/ruletest", summary="过滤规则测试", - response_model=schemas.Response[schemas.RuleTestData], + response_model=_SchemaResponse[_SchemaRuleTestData], ) def ruletest( title: str, rulegroup_name: str, subtitle: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ): """ 过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索 """ metainfo = MetaInfo(title=title, subtitle=subtitle) - torrent = schemas.TorrentInfo( + torrent = _SchemaTorrentInfo( title=title, description=subtitle, ) @@ -1234,7 +1246,7 @@ def ruletest( "matched": False, } if not rulegroup: - return schemas.Response( + return _SchemaResponse( success=False, message=f"过滤规则组 {rulegroup_name} 不存在!", data=result_data, @@ -1247,7 +1259,7 @@ def ruletest( ) result_data["media_info"] = media_info.to_dict() if media_info else None if not media_info: - return schemas.Response( + return _SchemaResponse( success=False, message="未识别到媒体信息!", data=result_data, @@ -1258,7 +1270,7 @@ def ruletest( rule_groups=[rulegroup.name], torrent_list=[torrent], mediainfo=media_info ) if not result: - return schemas.Response( + return _SchemaResponse( success=False, message="不符合过滤规则!", data=result_data, @@ -1270,7 +1282,7 @@ def ruletest( "torrent_info": result[0].model_dump(), } ) - return schemas.Response( + return _SchemaResponse( success=True, data=result_data, ) @@ -1279,16 +1291,16 @@ def ruletest( @router.get( "/nettest/targets", summary="获取网络测试目标", - response_model=schemas.Response[list[schemas.NetTestTarget]], + response_model=_SchemaResponse[list[_SchemaNetTestTarget]], ) -async def nettest_targets(_: schemas.TokenPayload = Depends(verify_token)): +async def nettest_targets(_: _SchemaTokenPayload = Depends(verify_token)): """ 获取网络测试目标。 这里只返回前端渲染所需的最小信息,避免把可请求 URL、内容校验规则和 跳转白名单暴露给客户端。 """ - return schemas.Response( + return _SchemaResponse( success=True, data=[ { @@ -1304,13 +1316,13 @@ async def nettest_targets(_: schemas.TokenPayload = Depends(verify_token)): @router.get( "/nettest", summary="测试网络连通性", - response_model=schemas.Response[schemas.TimeData], + response_model=_SchemaResponse[_SchemaTimeData], ) async def nettest( target_id: Optional[str] = None, url: Optional[str] = None, include: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ): """ 测试内置目标的网络连通性。 @@ -1320,14 +1332,14 @@ async def nettest( """ target = _get_nettest_rule(url=url, target_id=target_id) if not target: - return schemas.Response(success=False, message="测试目标不存在") + return _SchemaResponse(success=False, message="测试目标不存在") # 记录开始的毫秒数 start_time = datetime.now() url = target["url"] invalid_message = _validate_nettest_url(url) if invalid_message: logger.warning(f"拦截不安全的网络测试地址: {url}") - return schemas.Response(success=False, message=invalid_message) + return _SchemaResponse(success=False, message=invalid_message) if include: logger.debug("nettest include 参数已忽略,改为服务端固定校验") @@ -1355,18 +1367,18 @@ async def nettest( if not _is_allowed_nettest_redirect(next_url, target): await _close_nettest_response(result) logger.warning(f"拦截网络测试重定向: {current_url} -> {next_url}") - return schemas.Response(success=False, message="测试目标发生了未授权跳转") + return _SchemaResponse(success=False, message="测试目标发生了未授权跳转") await _close_nettest_response(result) current_url = next_url redirect_count += 1 if redirect_count > 3: - return schemas.Response(success=False, message="测试目标重定向次数过多") + return _SchemaResponse(success=False, message="测试目标重定向次数过多") # 计时结束的毫秒数 end_time = datetime.now() time = round((end_time - start_time).total_seconds() * 1000) # 计算相关秒数 if result is None: - return schemas.Response( + return _SchemaResponse( success=False, message=f"{target.get('proxy_name') or target.get('name')}无法连接", data={"time": time}, @@ -1374,12 +1386,12 @@ async def nettest( elif result.status_code == 200: expected_text = target.get("expected_text") if expected_text and expected_text.lower() not in (result.text or "").lower(): - return schemas.Response( + return _SchemaResponse( success=False, message=target.get("invalid_message") or "无效响应", data={"time": time}, ) - return schemas.Response(success=True, data={"time": time}) + return _SchemaResponse(success=True, data={"time": time}) else: if target.get("proxy_name"): # 加速代理失败 @@ -1392,15 +1404,15 @@ async def nettest( message = "Github Token已失效,请检查配置" elif result.status_code in {403, 429}: message = "触发限流,请配置Github Token" - return schemas.Response(success=False, message=message, data={"time": time}) + return _SchemaResponse(success=False, message=message, data={"time": time}) @router.get( "/modulelist", summary="查询已加载的模块ID列表", - response_model=schemas.Response[schemas.SystemModuleListData], + response_model=_SchemaResponse[_SchemaSystemModuleListData], ) -def modulelist(_: schemas.TokenPayload = Depends(verify_token)): +def modulelist(_: _SchemaTokenPayload = Depends(verify_token)): """ 查询已加载的模块ID列表 """ @@ -1419,32 +1431,32 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)): "name_key": f"system.modules.{module_id}.name", } ) - return schemas.Response(success=True, data={"modules": modules}) + return _SchemaResponse(success=True, data={"modules": modules}) @router.get( - "/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response[None] + "/moduletest/{moduleid}", summary="模块可用性测试", response_model=_SchemaResponse[None] ) -def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)): +def moduletest(moduleid: str, _: _SchemaTokenPayload = Depends(verify_token)): """ 模块可用性测试接口 """ state, errmsg = ModuleManager().test(moduleid) - return schemas.Response(success=state, message=errmsg) + return _SchemaResponse(success=state, message=errmsg) -@router.get("/restart", summary="重启系统", response_model=schemas.Response[None]) +@router.get("/restart", summary="重启系统", response_model=_SchemaResponse[None]) def restart_system(_: User = Depends(get_current_active_superuser)): """ 重启系统(仅管理员) """ if not SystemHelper.can_restart(): - return schemas.Response(success=False, message="当前运行环境不支持重启操作!") + return _SchemaResponse(success=False, message="当前运行环境不支持重启操作!") ret, msg = SystemHelper.restart() - return schemas.Response(success=ret, message=msg) + return _SchemaResponse(success=ret, message=msg) -@router.post("/upgrade", summary="升级并重启系统", response_model=schemas.Response[None]) +@router.post("/upgrade", summary="升级并重启系统", response_model=_SchemaResponse[None]) def upgrade_system( mode: Annotated[str | None, Body()] = None, _: User = Depends(get_current_active_superuser), @@ -1456,38 +1468,38 @@ def upgrade_system( - 当前未开启自动升级时:写入一次性升级标记,本次重启后仅执行一次升级。 """ if not SystemHelper.can_restart(): - return schemas.Response(success=False, message="当前运行环境不支持升级操作!") + return _SchemaResponse(success=False, message="当前运行环境不支持升级操作!") ret, msg = SystemHelper.upgrade(mode=mode or "release") - return schemas.Response(success=ret, message=msg) + return _SchemaResponse(success=ret, message=msg) -@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response[None]) +@router.get("/runscheduler", summary="运行服务", response_model=_SchemaResponse[None]) def run_scheduler(jobid: str, _: User = Depends(get_current_active_superuser)): """ 执行命令(仅管理员) """ if not jobid: - return schemas.Response(success=False, message="命令不能为空!") + return _SchemaResponse(success=False, message="命令不能为空!") if jobid in {"recommend_refresh", "cookiecloud"}: Scheduler().start(jobid, manual=True) else: Scheduler().start(jobid) - return schemas.Response(success=True) + return _SchemaResponse(success=True) @router.get( - "/runscheduler2", summary="运行服务(API_TOKEN)", response_model=schemas.Response[None] + "/runscheduler2", summary="运行服务(API_TOKEN)", response_model=_SchemaResponse[None] ) def run_scheduler2(jobid: str, _: Annotated[str, Depends(verify_apitoken)]): """ 执行命令(API_TOKEN认证) """ if not jobid: - return schemas.Response(success=False, message="命令不能为空!") + return _SchemaResponse(success=False, message="命令不能为空!") if jobid in {"recommend_refresh", "cookiecloud"}: Scheduler().start(jobid, manual=True) else: Scheduler().start(jobid) - return schemas.Response(success=True) + return _SchemaResponse(success=True) diff --git a/app/api/endpoints/tmdb.py b/app/api/endpoints/tmdb.py index f0dcc1f92..5b0caf9db 100644 --- a/app/api/endpoints/tmdb.py +++ b/app/api/endpoints/tmdb.py @@ -2,7 +2,13 @@ from typing import List, Any, Optional from fastapi import Depends -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.response import Response as _SchemaResponse +from app.schemas.tmdb import TmdbRecognitionCacheData as _SchemaTmdbRecognitionCacheData +from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.tmdb import TmdbChain from app.runtime.config import settings @@ -18,15 +24,15 @@ router = ResponseAPIRouter() @router.get( "/cache", summary="查询 TheMovieDb 识别缓存", - response_model=schemas.Response[schemas.TmdbRecognitionCacheData], + response_model=_SchemaResponse[_SchemaTmdbRecognitionCacheData], ) async def tmdb_recognition_cache( _: User = Depends(get_current_active_superuser_async), -) -> schemas.Response: +) -> _SchemaResponse: """查询可管理的 TheMovieDb 识别缓存。""" cache_items = TmdbChain().cache_items() recognized_count = sum(1 for item in cache_items if item["tmdb_id"]) - return schemas.Response( + return _SchemaResponse( success=True, data={ "count": len(cache_items), @@ -44,35 +50,35 @@ async def tmdb_recognition_cache( @router.delete( "/cache/{cache_key:path}", summary="删除指定 TheMovieDb 识别缓存", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) async def delete_tmdb_recognition_cache( cache_key: str, _: User = Depends(get_current_active_superuser_async), -) -> schemas.Response: +) -> _SchemaResponse: """按缓存键删除单条 TheMovieDb 识别缓存。""" deleted_item = TmdbChain().delete_cache(cache_key) if not deleted_item: - return schemas.Response(success=False, message="TheMovieDb 识别缓存不存在") - return schemas.Response(success=True, message="TheMovieDb 识别缓存删除成功") + return _SchemaResponse(success=False, message="TheMovieDb 识别缓存不存在") + return _SchemaResponse(success=True, message="TheMovieDb 识别缓存删除成功") @router.delete( - "/cache", summary="清空 TheMovieDb 识别缓存", response_model=schemas.Response[None] + "/cache", summary="清空 TheMovieDb 识别缓存", response_model=_SchemaResponse[None] ) async def clear_tmdb_recognition_cache( _: User = Depends(get_current_active_superuser_async), -) -> schemas.Response: +) -> _SchemaResponse: """清空全部 TheMovieDb 识别缓存。""" TmdbChain().clear_cache() - return schemas.Response(success=True, message="TheMovieDb 识别缓存清理完成") + return _SchemaResponse(success=True, message="TheMovieDb 识别缓存清理完成") @router.get( - "/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason] + "/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[_SchemaTmdbSeason] ) async def tmdb_seasons( - tmdbid: int, _: schemas.TokenPayload = Depends(verify_token) + tmdbid: int, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 根据TMDBID查询themoviedb所有季信息 @@ -86,10 +92,10 @@ async def tmdb_seasons( @router.get( "/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def tmdb_similar( - tmdbid: int, type_name: str, _: schemas.TokenPayload = Depends(verify_token) + tmdbid: int, type_name: str, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 根据TMDBID查询类似电影/电视剧,type_name: 电影/电视剧 @@ -109,10 +115,10 @@ async def tmdb_similar( @router.get( "/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def tmdb_recommend( - tmdbid: int, type_name: str, _: schemas.TokenPayload = Depends(verify_token) + tmdbid: int, type_name: str, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 根据TMDBID查询推荐电影/电视剧,type_name: 电影/电视剧 @@ -132,13 +138,13 @@ async def tmdb_recommend( @router.get( "/collection/{collection_id}", summary="系列合集详情", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def tmdb_collection( collection_id: int, page: Optional[int] = 1, count: Optional[int] = 20, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据合集ID查询合集详情 @@ -152,13 +158,13 @@ async def tmdb_collection( @router.get( "/credits/{tmdbid}/{type_name}", summary="演员阵容", - response_model=List[schemas.MediaPerson], + response_model=List[_SchemaMediaPerson], ) async def tmdb_credits( tmdbid: int, type_name: str, page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据TMDBID查询演员阵容,type_name: 电影/电视剧 @@ -174,10 +180,10 @@ async def tmdb_credits( @router.get( - "/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson + "/person/{person_id}", summary="人物详情", response_model=_SchemaMediaPerson ) async def tmdb_person( - person_id: int, _: schemas.TokenPayload = Depends(verify_token) + person_id: int, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 根据人物ID查询人物详情 @@ -188,12 +194,12 @@ async def tmdb_person( @router.get( "/person/credits/{person_id}", summary="人物参演作品", - response_model=List[schemas.MediaInfo], + response_model=List[_SchemaMediaInfo], ) async def tmdb_person_credits( person_id: int, page: Optional[int] = 1, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据人物ID查询人物参演作品 @@ -207,13 +213,13 @@ async def tmdb_person_credits( @router.get( "/{tmdbid}/{season}", summary="TMDB季所有集", - response_model=List[schemas.TmdbEpisode], + response_model=List[_SchemaTmdbEpisode], ) async def tmdb_season_episodes( tmdbid: int, season: int, episode_group: Optional[str] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据TMDBID查询某季的所有信信息 diff --git a/app/api/endpoints/torrent.py b/app/api/endpoints/torrent.py index dc47ee870..953bc6060 100644 --- a/app/api/endpoints/torrent.py +++ b/app/api/endpoints/torrent.py @@ -2,7 +2,9 @@ from typing import Optional from fastapi import Depends -from app import schemas +from app.schemas.cache import TorrentCacheData as _SchemaTorrentCacheData +from app.schemas.cache import TorrentReidentifyData as _SchemaTorrentReidentifyData +from app.schemas.response import Response as _SchemaResponse from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.torrents import TorrentsChain @@ -28,7 +30,7 @@ router = ResponseAPIRouter() @router.get( "/cache", summary="获取种子缓存", - response_model=schemas.Response[schemas.TorrentCacheData], + response_model=_SchemaResponse[_SchemaTorrentCacheData], ) async def torrents_cache(_: User = Depends(get_current_active_superuser_async)): """ @@ -87,7 +89,7 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)): } ) - return schemas.Response( + return _SchemaResponse( success=True, data={"count": torrent_count, "sites": len(cache_info), "data": torrent_data}, ) @@ -96,7 +98,7 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)): @router.delete( "/cache/{domain}/{torrent_hash}", summary="删除指定种子缓存", - response_model=schemas.Response[None], + response_model=_SchemaResponse[None], ) async def delete_cache( domain: str, @@ -117,7 +119,7 @@ async def delete_cache( cache_data = await torrents_chain.async_get_torrents() if domain not in cache_data: - return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在") + return _SchemaResponse(success=False, message=f"站点 {domain} 缓存不存在") # 查找并删除指定种子 original_count = len(cache_data[domain]) @@ -131,7 +133,7 @@ async def delete_cache( ] if len(cache_data[domain]) == original_count: - return schemas.Response(success=False, message="未找到指定的种子") + return _SchemaResponse(success=False, message="未找到指定的种子") # 保存更新后的缓存:影视与音乐分别回写各自存储文件 video_cache, music_cache = torrents_chain.split_cache_contexts(cache_data) @@ -139,12 +141,12 @@ async def delete_cache( await torrents_chain.async_save_cache(video_cache, video_file) await torrents_chain.async_save_cache(music_cache, music_file) - return schemas.Response(success=True, message="种子删除成功") + return _SchemaResponse(success=True, message="种子删除成功") except Exception as e: - return schemas.Response(success=False, message=f"删除失败:{str(e)}") + return _SchemaResponse(success=False, message=f"删除失败:{str(e)}") -@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response[None]) +@router.delete("/cache", summary="清理种子缓存", response_model=_SchemaResponse[None]) async def clear_cache(_: User = Depends(get_current_active_superuser_async)): """ 清理所有种子缓存 @@ -153,12 +155,12 @@ async def clear_cache(_: User = Depends(get_current_active_superuser_async)): try: await torrents_chain.async_clear_torrents() - return schemas.Response(success=True, message="种子缓存清理完成") + return _SchemaResponse(success=True, message="种子缓存清理完成") except Exception as e: - return schemas.Response(success=False, message=f"清理失败:{str(e)}") + return _SchemaResponse(success=False, message=f"清理失败:{str(e)}") -@router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response[None]) +@router.post("/cache/refresh", summary="刷新种子缓存", response_model=_SchemaResponse[None]) def refresh_cache(_: User = Depends(get_current_active_superuser)): """ 刷新种子缓存 @@ -174,18 +176,18 @@ def refresh_cache(_: User = Depends(get_current_active_superuser)): total_count = sum(len(torrents) for torrents in result.values()) sites_count = len(result) - return schemas.Response( + return _SchemaResponse( success=True, message=f"缓存刷新完成,共刷新 {sites_count} 个站点,{total_count} 个种子", ) except Exception as e: - return schemas.Response(success=False, message=f"刷新失败:{str(e)}") + return _SchemaResponse(success=False, message=f"刷新失败:{str(e)}") @router.post( "/cache/reidentify/{domain}/{torrent_hash}", summary="重新识别种子", - response_model=schemas.Response[schemas.TorrentReidentifyData], + response_model=_SchemaResponse[_SchemaTorrentReidentifyData], ) async def reidentify_cache( domain: str, @@ -213,7 +215,7 @@ async def reidentify_cache( cache_data = await torrents_chain.async_get_torrents() if domain not in cache_data: - return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在") + return _SchemaResponse(success=False, message=f"站点 {domain} 缓存不存在") # 查找指定种子 target_context = None @@ -228,7 +230,7 @@ async def reidentify_cache( break if not target_context: - return schemas.Response(success=False, message="未找到指定的种子") + return _SchemaResponse(success=False, message="未找到指定的种子") existing_music_type = normalize_music_type( getattr(target_context.media_info, "music_type", None), @@ -239,7 +241,7 @@ async def reidentify_cache( allow_artist=False, ) if music_type is not None and not normalized_music_type: - return schemas.Response( + return _SchemaResponse( success=False, message="音乐实体类型无效,仅支持 recording 或 album", ) @@ -252,7 +254,7 @@ async def reidentify_cache( or normalized_music_type is not None ) if is_music and media_source and not is_music_media_source(media_source): - return schemas.Response( + return _SchemaResponse( success=False, message="音乐重新识别只能使用音乐元数据源", ) @@ -274,7 +276,7 @@ async def reidentify_cache( has_explicit_id = media_source is not None or media_id is not None if has_explicit_id and (not media_source or not media_id): - return schemas.Response( + return _SchemaResponse( success=False, message="媒体来源和媒体 ID 必须同时提供", ) @@ -318,7 +320,7 @@ async def reidentify_cache( await torrents_chain.async_save_cache(video_cache, video_file) await torrents_chain.async_save_cache(music_cache, music_file) - return schemas.Response( + return _SchemaResponse( success=True, message="重新识别完成", data={ @@ -333,4 +335,4 @@ async def reidentify_cache( }, ) except Exception as e: - return schemas.Response(success=False, message=f"重新识别失败:{str(e)}") + return _SchemaResponse(success=False, message=f"重新识别失败:{str(e)}") diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index db8549d78..897e2ae9a 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -4,7 +4,17 @@ from typing import Any, List, Annotated, Optional from fastapi import Depends from sqlalchemy.orm import Session -from app import schemas +from app.schemas.common import NameData as _SchemaNameData +from app.schemas.response import Response as _SchemaResponse +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.transfer import EpisodeFormat as _SchemaEpisodeFormat +from app.schemas.transfer import EpisodeFormatRecommendData as _SchemaEpisodeFormatRecommendData +from app.schemas.transfer import ManualTransferHistoryInfo as _SchemaManualTransferHistoryInfo +from app.schemas.transfer import ManualTransferResultData as _SchemaManualTransferResultData +from app.schemas.transfer import ManualTransferTargetPath as _SchemaManualTransferTargetPath +from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf +from app.schemas.transfer import TransferJob as _SchemaTransferJob +from app.schemas.workflow import FileItem as _SchemaFileItem from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.transfer import TransferChain @@ -13,15 +23,13 @@ from app.application.security.access import verify_token, verify_apitoken from app.db import get_db from app.db.models import User from app.db.models.transferhistory import TransferHistory -from app.api.deps import get_current_active_manage_user, get_current_active_superuser +from app.api.deps import get_current_active_manage_user from app.application.directory import DirectoryHelper from app.runtime.log import logger -from app.schemas import ( - MediaType, - FileItem, - ManualTransferItem, - EpisodeFormatRecommendItem, -) +from app.schemas.types import MediaType +from app.schemas.workflow import FileItem +from app.schemas.transfer import ManualTransferItem +from app.schemas.transfer import EpisodeFormatRecommendItem router = ResponseAPIRouter() @@ -29,10 +37,10 @@ router = ResponseAPIRouter() @router.get( "/name", summary="查询整理后的名称", - response_model=schemas.Response[schemas.NameData], + response_model=_SchemaResponse[_SchemaNameData], ) def query_name( - path: str, filetype: str, _: schemas.TokenPayload = Depends(verify_token) + path: str, filetype: str, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 查询整理后的名称 @@ -45,12 +53,12 @@ def query_name( obtain_images=False, ) if not context or not context.media_info: - return schemas.Response(success=False, message="未识别到媒体信息") + return _SchemaResponse(success=False, message="未识别到媒体信息") new_path = TransferChain().recommend_name( meta=context.meta_info, mediainfo=context.media_info ) if not new_path: - return schemas.Response(success=False, message="未识别到新名称") + return _SchemaResponse(success=False, message="未识别到新名称") if filetype == "dir": media_path = DirectoryHelper.get_media_root_path( rename_format=settings.RENAME_FORMAT(context.media_info.type), @@ -68,11 +76,11 @@ def query_name( new_name = parents[0].name else: new_name = Path(new_path).name - return schemas.Response(success=True, data={"name": new_name}) + return _SchemaResponse(success=True, data={"name": new_name}) -@router.get("/queue", summary="查询整理队列", response_model=List[schemas.TransferJob]) -async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get("/queue", summary="查询整理队列", response_model=List[_SchemaTransferJob]) +async def query_queue(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询整理队列 :param _: Token校验 @@ -81,10 +89,10 @@ async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.delete( - "/queue", summary="从整理队列中删除任务", response_model=schemas.Response[None] + "/queue", summary="从整理队列中删除任务", response_model=_SchemaResponse[None] ) async def remove_queue( - fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token) + fileitem: _SchemaFileItem, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 查询整理队列 @@ -94,7 +102,7 @@ async def remove_queue( TransferChain().remove_from_queue(fileitem) # 取消整理 global_vars.stop_transfer(fileitem.path) - return schemas.Response(success=True) + return _SchemaResponse(success=True) def _resolve_manual_transfer_source_fileitems( @@ -150,15 +158,15 @@ def _deduplicate_fileitems(fileitems: List[FileItem]) -> List[FileItem]: def _build_manual_transfer_target_path( - directory: Optional[schemas.TransferDirectoryConf] = None, -) -> schemas.ManualTransferTargetPath: + directory: Optional[_SchemaTransferDirectoryConf] = None, +) -> _SchemaManualTransferTargetPath: """ 根据目录配置生成手动整理目的路径响应。 """ if not directory or not directory.library_path: - return schemas.ManualTransferTargetPath() + return _SchemaManualTransferTargetPath() - return schemas.ManualTransferTargetPath( + return _SchemaManualTransferTargetPath( target_storage=directory.library_storage or "local", target_path=directory.library_path, transfer_type=directory.transfer_type, @@ -169,7 +177,7 @@ def _build_manual_transfer_target_path( def _get_manual_transfer_target_key( - directory: schemas.TransferDirectoryConf, + directory: _SchemaTransferDirectoryConf, ) -> tuple[Optional[str], Optional[str]]: """ 生成目的目录唯一键。 @@ -183,7 +191,7 @@ def _get_manual_transfer_target_key( @router.post( "/manual/target-path", summary="匹配手动转移目的路径", - response_model=schemas.Response[schemas.ManualTransferTargetPath], + response_model=_SchemaResponse[_SchemaManualTransferTargetPath], ) def match_manual_transfer_target_path( transer_item: ManualTransferItem, @@ -202,9 +210,9 @@ def match_manual_transfer_target_path( db=db, ) if error_message: - return schemas.Response(success=False, message=error_message) + return _SchemaResponse(success=False, message=error_message) - matched_directories: List[schemas.TransferDirectoryConf] = [] + matched_directories: List[_SchemaTransferDirectoryConf] = [] target_storage = transer_item.target_storage or None for src_fileitem in _deduplicate_fileitems(src_fileitems): directory = DirectoryHelper().get_dir( @@ -214,16 +222,16 @@ def match_manual_transfer_target_path( target_storage=target_storage, ) if not directory or not directory.library_path: - return schemas.Response( + return _SchemaResponse( success=True, - data=schemas.ManualTransferTargetPath().model_dump(), + data=_SchemaManualTransferTargetPath().model_dump(), ) matched_directories.append(directory) if not matched_directories: - return schemas.Response( + return _SchemaResponse( success=True, - data=schemas.ManualTransferTargetPath().model_dump(), + data=_SchemaManualTransferTargetPath().model_dump(), ) first_directory = matched_directories[0] @@ -232,12 +240,12 @@ def match_manual_transfer_target_path( _get_manual_transfer_target_key(directory) != first_key for directory in matched_directories[1:] ): - return schemas.Response( + return _SchemaResponse( success=True, - data=schemas.ManualTransferTargetPath().model_dump(), + data=_SchemaManualTransferTargetPath().model_dump(), ) - return schemas.Response( + return _SchemaResponse( success=True, data=_build_manual_transfer_target_path(first_directory).model_dump(), ) @@ -246,7 +254,7 @@ def match_manual_transfer_target_path( @router.post( "/manual/history", summary="查询手动转移成功历史", - response_model=schemas.Response[schemas.ManualTransferHistoryInfo], + response_model=_SchemaResponse[_SchemaManualTransferHistoryInfo], ) def query_manual_transfer_history( transer_item: ManualTransferItem, @@ -265,22 +273,22 @@ def query_manual_transfer_history( db=db, ) if error_message: - return schemas.Response(success=False, message=error_message) + return _SchemaResponse(success=False, message=error_message) histories = TransferChain().get_manual_transfer_histories( _deduplicate_fileitems(src_fileitems) ) - history_info = schemas.ManualTransferHistoryInfo( + history_info = _SchemaManualTransferHistoryInfo( reorganize=bool(histories), history_count=len(histories), ) - return schemas.Response(success=True, data=history_info.model_dump()) + return _SchemaResponse(success=True, data=history_info.model_dump()) @router.post( "/manual", summary="手动转移", - response_model=schemas.Response[schemas.ManualTransferResultData], + response_model=_SchemaResponse[_SchemaManualTransferResultData], ) def manual_transfer( transer_item: ManualTransferItem, @@ -305,7 +313,7 @@ def manual_transfer( # 查询历史记录 history: TransferHistory = TransferHistory.get(db, transer_item.logid) if not history: - return schemas.Response( + return _SchemaResponse( success=False, message=f"整理记录不存在,ID:{transer_item.logid}" ) # 强制转移 @@ -368,7 +376,7 @@ def manual_transfer( elif transer_item.fileitem: src_fileitems = [transer_item.fileitem] else: - return schemas.Response(success=False, message=f"缺少参数") + return _SchemaResponse(success=False, message=f"缺少参数") dedup_fileitems: List[FileItem] = [] seen_paths = set() @@ -384,7 +392,7 @@ def manual_transfer( dedup_fileitems.append(current_fileitem) src_fileitems = dedup_fileitems if not src_fileitems: - return schemas.Response(success=False, message="缺少参数") + return _SchemaResponse(success=False, message="缺少参数") # 类型(“自动/auto/none”按未指定处理) mtype = None @@ -393,7 +401,7 @@ def manual_transfer( try: mtype = MediaType(type_name) except ValueError: - return schemas.Response( + return _SchemaResponse( success=False, message=f"不支持的媒体类型:{type_name}" ) # 自定义格式 @@ -404,7 +412,7 @@ def manual_transfer( or transer_item.episode_detail or transer_item.episode_format ): - epformat = schemas.EpisodeFormat( + epformat = _SchemaEpisodeFormat( format=transer_item.episode_format, detail=transer_item.episode_detail, part=transer_item.episode_part, @@ -520,18 +528,18 @@ def manual_transfer( "items": merged_preview_items, "message": merged_message, } - return schemas.Response( + return _SchemaResponse( success=True, message=merged_message or None, data=preview_data, ) if not all_success: - return schemas.Response( + return _SchemaResponse( success=False, message=_merge_messages(error_messages), ) - return schemas.Response(success=True) + return _SchemaResponse(success=True) src_fileitem = src_fileitems[0] # 开始转移 @@ -565,22 +573,22 @@ def manual_transfer( if isinstance(errormsg, list): errormsg = f"整理完成,{len(errormsg)} 个文件转移失败!" if isinstance(errormsg, dict): - return schemas.Response( + return _SchemaResponse( success=True, message=errormsg.get("message"), data=errormsg, ) - return schemas.Response(success=False, message=errormsg) + return _SchemaResponse(success=False, message=errormsg) # 成功 if transer_item.preview: - return schemas.Response(success=True, data=errormsg or {}) - return schemas.Response(success=True) + return _SchemaResponse(success=True, data=errormsg or {}) + return _SchemaResponse(success=True) @router.post( "/episode-format/recommend", summary="推荐集数定位模板", - response_model=schemas.Response[schemas.EpisodeFormatRecommendData], + response_model=_SchemaResponse[_SchemaEpisodeFormatRecommendData], ) def recommend_episode_format( recommend_item: EpisodeFormatRecommendItem, @@ -599,17 +607,17 @@ def recommend_episode_format( ) if not state: logger.warn(f"推荐集数定位模板失败:{target_path} - {errmsg}") - return schemas.Response(success=False, message=errmsg) + return _SchemaResponse(success=False, message=errmsg) logger.info( f"推荐集数定位模板成功:{target_path} - 规则 {data.get('rule_name') if data else None}" ) - return schemas.Response(success=True, data=data) + return _SchemaResponse(success=True, data=data) -@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response[None]) +@router.get("/now", summary="立即执行下载器文件整理", response_model=_SchemaResponse[None]) def now(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 立即执行下载器文件整理 API_TOKEN认证(?token=xxx) """ TransferChain().process() - return schemas.Response(success=True) + return _SchemaResponse(success=True) diff --git a/app/api/endpoints/user.py b/app/api/endpoints/user.py index 11828e7d7..2c88ec518 100644 --- a/app/api/endpoints/user.py +++ b/app/api/endpoints/user.py @@ -5,7 +5,12 @@ from typing import Annotated, Any, List, Union from fastapi import Body, Depends, HTTPException, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession -from app import schemas +from app.schemas.common import FileNameData as _SchemaFileNameData +from app.schemas.common import ValueData as _SchemaValueData +from app.schemas.response import Response as _SchemaResponse +from app.schemas.user import User as _SchemaUser +from app.schemas.user import UserCreate as _SchemaUserCreate +from app.schemas.user import UserUpdate as _SchemaUserUpdate from app.api.response import ResponseAPIRouter from app.application.security.access import PasswordTooLongError, get_password_hash from app.db import get_async_db @@ -16,7 +21,7 @@ from app.db.oper.userconfig import UserConfigOper router = ResponseAPIRouter() -@router.get("/", summary="所有用户", response_model=List[schemas.User]) +@router.get("/", summary="所有用户", response_model=List[_SchemaUser]) async def list_users( db: AsyncSession = Depends(get_async_db), current_user: User = Depends(get_current_active_superuser_async), @@ -27,11 +32,11 @@ async def list_users( return await current_user.async_list(db) -@router.post("/", summary="新增用户", response_model=schemas.Response[None]) +@router.post("/", summary="新增用户", response_model=_SchemaResponse[None]) async def create_user( *, db: AsyncSession = Depends(get_async_db), - user_in: schemas.UserCreate, + user_in: _SchemaUserCreate, current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ @@ -39,23 +44,23 @@ async def create_user( """ user = await current_user.async_get_by_name(db, name=user_in.name) if user: - return schemas.Response(success=False, message="用户已存在") + return _SchemaResponse(success=False, message="用户已存在") user_info = user_in.model_dump() if user_info.get("password"): try: user_info["hashed_password"] = get_password_hash(user_info["password"]) except PasswordTooLongError as error: - return schemas.Response(success=False, message=str(error)) + return _SchemaResponse(success=False, message=str(error)) user_info.pop("password") user = await User(**user_info).async_create(db) - return schemas.Response(success=True if user else False) + return _SchemaResponse(success=True if user else False) -@router.put("/", summary="更新用户", response_model=schemas.Response[None]) +@router.put("/", summary="更新用户", response_model=_SchemaResponse[None]) async def update_user( *, db: AsyncSession = Depends(get_async_db), - user_in: schemas.UserUpdate, + user_in: _SchemaUserUpdate, current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ @@ -66,31 +71,31 @@ async def update_user( # 正则表达式匹配密码包含字母、数字、特殊字符中的至少两项 pattern = r"^(?![a-zA-Z]+$)(?!\d+$)(?![^\da-zA-Z\s]+$).{6,50}$" if not re.match(pattern, user_info.get("password")): - return schemas.Response( + return _SchemaResponse( success=False, message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位", ) try: user_info["hashed_password"] = get_password_hash(user_info["password"]) except PasswordTooLongError as error: - return schemas.Response(success=False, message=str(error)) + return _SchemaResponse(success=False, message=str(error)) user_info.pop("password") user = await current_user.async_get_by_id(db, user_id=user_info["id"]) user_name = user_info.get("name") if not user_name: - return schemas.Response(success=False, message="用户名不能为空") + return _SchemaResponse(success=False, message="用户名不能为空") # 新用户名去重 users = await current_user.async_list(db) for u in users: if u.name == user_name and u.id != user_info["id"]: - return schemas.Response(success=False, message="用户名已被使用") + return _SchemaResponse(success=False, message="用户名已被使用") if not user: - return schemas.Response(success=False, message="用户不存在") + return _SchemaResponse(success=False, message="用户不存在") await user.async_update(db, user_info) - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.get("/current", summary="当前登录用户信息", response_model=schemas.User) +@router.get("/current", summary="当前登录用户信息", response_model=_SchemaUser) async def read_current_user( current_user: User = Depends(get_current_active_user_async), ) -> Any: @@ -103,14 +108,14 @@ async def read_current_user( @router.post( "/avatar/{user_id}", summary="上传用户头像", - response_model=schemas.Response[schemas.FileNameData], + response_model=_SchemaResponse[_SchemaFileNameData], ) async def upload_avatar( user_id: int, db: AsyncSession = Depends(get_async_db), file: UploadFile = File(...), current_user: User = Depends(get_current_active_user_async), -) -> schemas.Response: +) -> _SchemaResponse: """ 上传用户头像 """ @@ -122,25 +127,25 @@ async def upload_avatar( # 更新到用户表 user = await User.async_get(db, user_id) if not user: - return schemas.Response(success=False, message="用户不存在") + return _SchemaResponse(success=False, message="用户不存在") await user.async_update(db, {"avatar": f"data:image/ico;base64,{file_base64}"}) - return schemas.Response(success=True, data={"filename": file.filename}) + return _SchemaResponse(success=True, data={"filename": file.filename}) @router.get( "/config/{key}", summary="查询用户配置", - response_model=schemas.Response[schemas.ValueData], + response_model=_SchemaResponse[_SchemaValueData], ) def get_config(key: str, current_user: User = Depends(get_current_active_user)): """ 查询用户配置 """ value = UserConfigOper().get(username=current_user.name, key=key) - return schemas.Response(success=True, data={"value": value}) + return _SchemaResponse(success=True, data={"value": value}) -@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response[None]) +@router.post("/config/{key}", summary="更新用户配置", response_model=_SchemaResponse[None]) def set_config( key: str, value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None, @@ -150,10 +155,10 @@ def set_config( 更新用户配置 """ UserConfigOper().set(username=current_user.name, key=key, value=value) - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response[None]) +@router.delete("/id/{user_id}", summary="删除用户", response_model=_SchemaResponse[None]) async def delete_user_by_id( *, db: AsyncSession = Depends(get_async_db), @@ -165,12 +170,12 @@ async def delete_user_by_id( """ user = await current_user.async_get_by_id(db, user_id=user_id) if not user: - return schemas.Response(success=False, message="用户不存在") + return _SchemaResponse(success=False, message="用户不存在") await current_user.async_delete(db, user_id) - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response[None]) +@router.delete("/name/{user_name}", summary="删除用户", response_model=_SchemaResponse[None]) async def delete_user_by_name( *, db: AsyncSession = Depends(get_async_db), @@ -182,12 +187,12 @@ async def delete_user_by_name( """ user = await current_user.async_get_by_name(db, name=user_name) if not user: - return schemas.Response(success=False, message="用户不存在") + return _SchemaResponse(success=False, message="用户不存在") await current_user.async_delete(db, user.id) - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.get("/{username}", summary="用户详情", response_model=schemas.User) +@router.get("/{username}", summary="用户详情", response_model=_SchemaUser) async def read_user_by_name( username: str, current_user: User = Depends(get_current_active_user_async), diff --git a/app/api/endpoints/webhook.py b/app/api/endpoints/webhook.py index acebf7e66..9b83de474 100644 --- a/app/api/endpoints/webhook.py +++ b/app/api/endpoints/webhook.py @@ -2,7 +2,7 @@ from typing import Any, Annotated from fastapi import BackgroundTasks, Request, Depends -from app import schemas +from app.schemas.response import Response as _SchemaResponse from app.api.response import ResponseAPIRouter from app.chain.webhook import WebhookChain from app.application.security.access import verify_apitoken @@ -17,7 +17,7 @@ def start_webhook_chain(body: Any, form: Any, args: Any): WebhookChain().message(body=body, form=form, args=args) -@router.post("/", summary="Webhook消息响应", response_model=schemas.Response[None]) +@router.post("/", summary="Webhook消息响应", response_model=_SchemaResponse[None]) async def webhook_message( background_tasks: BackgroundTasks, request: Request, @@ -30,10 +30,10 @@ async def webhook_message( form = await request.form() args = request.query_params background_tasks.add_task(start_webhook_chain, body, form, args) - return schemas.Response(success=True) + return _SchemaResponse(success=True) -@router.get("/", summary="Webhook消息响应", response_model=schemas.Response[None]) +@router.get("/", summary="Webhook消息响应", response_model=_SchemaResponse[None]) async def webhook_message_get( background_tasks: BackgroundTasks, request: Request, @@ -44,4 +44,4 @@ async def webhook_message_get( """ args = request.query_params background_tasks.add_task(start_webhook_chain, None, None, args) - return schemas.Response(success=True) + return _SchemaResponse(success=True) diff --git a/app/api/endpoints/workflow.py b/app/api/endpoints/workflow.py index 51a6d59a2..0a3f049fb 100644 --- a/app/api/endpoints/workflow.py +++ b/app/api/endpoints/workflow.py @@ -1,34 +1,34 @@ -import json -from datetime import datetime from typing import List, Any, Optional from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Session -from app import schemas +from app.schemas.response import Response as _SchemaResponse +from app.schemas.workflow import NameValueOption as _SchemaNameValueOption +from app.schemas.workflow import PluginWorkflowActionGroup as _SchemaPluginWorkflowActionGroup +from app.schemas.workflow import Workflow as _SchemaWorkflow +from app.schemas.workflow import WorkflowActionDefinition as _SchemaWorkflowActionDefinition +from app.schemas.workflow import WorkflowShare as _SchemaWorkflowShare from app.api.response import ResponseAPIRouter +from app.application.workflow import WorkflowDefinitionCommand, WorkflowMutationCommand from app.chain.workflow import WorkflowChain -from app.runtime.config import global_vars from app.runtime.extensions.plugin_manager import PluginManager from app.workflow import WorkFlowManager -from app.db import get_async_db, get_db -from app.db.models import Workflow, User -from app.db.oper.systemconfig import SystemConfigOper -from app.api.deps import get_current_active_manage_user, get_current_active_manage_user_async +from app.db import get_async_db +from app.db.models import User +from app.api.deps import ( + get_current_active_manage_user, + get_current_active_manage_user_async, + get_workflow_definition_command, + get_workflow_mutation_command, +) from app.db.oper.workflow import WorkflowOper from app.adapters.external.server import MoviePilotServerHelper -from app.scheduler import Scheduler from app.schemas.types import EventType, EVENT_TYPE_NAMES router = ResponseAPIRouter() -WORKFLOW_TRIGGER_TIMER = "timer" -WORKFLOW_TRIGGER_EVENT = "event" -WORKFLOW_TRIGGER_MANUAL = "manual" - - -@router.get("/", summary="所有工作流", response_model=List[schemas.Workflow]) +@router.get("/", summary="所有工作流", response_model=List[_SchemaWorkflow]) async def list_workflows( db: AsyncSession = Depends(get_async_db), _: User = Depends(get_current_active_manage_user_async), @@ -39,32 +39,23 @@ async def list_workflows( return await WorkflowOper(db).async_list() -@router.post("/", summary="创建工作流", response_model=schemas.Response[None]) +@router.post("/", summary="创建工作流", response_model=_SchemaResponse[None]) async def create_workflow( - workflow: schemas.Workflow, - db: AsyncSession = Depends(get_async_db), + workflow: _SchemaWorkflow, + command: WorkflowDefinitionCommand = Depends(get_workflow_definition_command), _: User = Depends(get_current_active_manage_user_async), ) -> Any: """ 创建工作流 """ - if workflow.name and await WorkflowOper(db).async_get_by_name(workflow.name): - return schemas.Response(success=False, message="已存在相同名称的工作流") - if not workflow.add_time: - workflow.add_time = datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S") - if not workflow.state: - workflow.state = "P" - if not workflow.trigger_type: - workflow.trigger_type = "timer" - workflow_obj = Workflow(**workflow.model_dump()) - await workflow_obj.async_create(db) - return schemas.Response(success=True, message="创建工作流成功") + result = await command.create(workflow.model_dump(exclude={"id"})) + return _SchemaResponse(success=result.success, message=result.message) @router.get( "/plugin/actions", summary="查询插件动作", - response_model=List[schemas.PluginWorkflowActionGroup], + response_model=List[_SchemaPluginWorkflowActionGroup], ) def list_plugin_actions( plugin_id: str = None, _: User = Depends(get_current_active_manage_user) @@ -78,7 +69,7 @@ def list_plugin_actions( @router.get( "/actions", summary="所有动作", - response_model=List[schemas.WorkflowActionDefinition], + response_model=List[_SchemaWorkflowActionDefinition], ) async def list_actions(_: User = Depends(get_current_active_manage_user_async)) -> Any: """ @@ -90,7 +81,7 @@ async def list_actions(_: User = Depends(get_current_active_manage_user_async)) @router.get( "/event_types", summary="获取所有事件类型", - response_model=List[schemas.NameValueOption], + response_model=List[_SchemaNameValueOption], ) async def get_event_types(_: User = Depends(get_current_active_manage_user_async)) -> Any: """ @@ -105,15 +96,15 @@ async def get_event_types(_: User = Depends(get_current_active_manage_user_async ] -@router.post("/share", summary="分享工作流", response_model=schemas.Response[None]) +@router.post("/share", summary="分享工作流", response_model=_SchemaResponse[None]) async def workflow_share( - workflow: schemas.WorkflowShare, _: User = Depends(get_current_active_manage_user_async) + workflow: _SchemaWorkflowShare, _: User = Depends(get_current_active_manage_user_async) ) -> Any: """ 分享工作流 """ if not workflow.id or not workflow.share_title or not workflow.share_user: - return schemas.Response( + return _SchemaResponse( success=False, message="请填写工作流ID、分享标题和分享人" ) @@ -123,10 +114,10 @@ async def workflow_share( share_comment=workflow.share_comment or "", share_user=workflow.share_user or "", ) - return schemas.Response(success=state, message=errmsg) + return _SchemaResponse(success=state, message=errmsg) -@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response[None]) +@router.delete("/share/{share_id}", summary="删除分享", response_model=_SchemaResponse[None]) async def workflow_share_delete( share_id: int, _: User = Depends(get_current_active_manage_user_async) ) -> Any: @@ -134,74 +125,24 @@ async def workflow_share_delete( 删除分享 """ state, errmsg = await MoviePilotServerHelper.async_workflow_share_delete_by_id(share_id=share_id) - return schemas.Response(success=state, message=errmsg) + return _SchemaResponse(success=state, message=errmsg) -@router.post("/fork", summary="复用工作流", response_model=schemas.Response[None]) +@router.post("/fork", summary="复用工作流", response_model=_SchemaResponse[None]) async def workflow_fork( - workflow: schemas.WorkflowShare, - db: AsyncSession = Depends(get_async_db), + workflow: _SchemaWorkflowShare, + command: WorkflowDefinitionCommand = Depends(get_workflow_definition_command), _: User = Depends(get_current_active_manage_user_async), ) -> Any: """ 复用工作流 """ - if not workflow.name: - return schemas.Response(success=False, message="工作流名称不能为空") - - # 解析JSON数据,添加错误处理 - try: - actions = json.loads(workflow.actions or "[]") - except json.JSONDecodeError: - return schemas.Response(success=False, message="actions字段JSON格式错误") - - try: - flows = json.loads(workflow.flows or "[]") - except json.JSONDecodeError: - return schemas.Response(success=False, message="flows字段JSON格式错误") - - try: - context = json.loads(workflow.context or "{}") - except json.JSONDecodeError: - return schemas.Response(success=False, message="context字段JSON格式错误") - - try: - event_conditions = json.loads(workflow.event_conditions or "{}") if workflow.event_conditions else {} - except json.JSONDecodeError: - return schemas.Response(success=False, message="event_conditions字段JSON格式错误") - - share_id = workflow.id - # 创建工作流 - workflow_dict = { - "name": workflow.name, - "description": workflow.description, - "timer": workflow.timer, - "trigger_type": workflow.trigger_type or WORKFLOW_TRIGGER_TIMER, - "event_type": workflow.event_type, - "event_conditions": event_conditions, - "actions": actions, - "flows": flows, - "context": context, - "state": "P", # 默认暂停状态 - } - - # 检查名称是否重复 - workflow_oper = WorkflowOper(db) - if await workflow_oper.async_get_by_name(workflow_dict["name"]): - return schemas.Response(success=False, message="已存在相同名称的工作流") - - # 创建新工作流 - workflow_obj = await Workflow(**workflow_dict).async_create(db) - - # 更新复用次数 - if workflow_obj and share_id: - await MoviePilotServerHelper.async_workflow_fork_by_id(share_id=share_id) - - return schemas.Response(success=True, message="复用成功") + result = await command.fork(workflow.model_dump(), share_id=workflow.id) + return _SchemaResponse(success=result.success, message=result.message) @router.get( - "/shares", summary="查询分享的工作流", response_model=List[schemas.WorkflowShare] + "/shares", summary="查询分享的工作流", response_model=List[_SchemaWorkflowShare] ) async def workflow_shares( name: Optional[str] = None, @@ -216,7 +157,7 @@ async def workflow_shares( @router.post( - "/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response[None] + "/{workflow_id}/run", summary="执行工作流", response_model=_SchemaResponse[None] ) def run_workflow( workflow_id: int, @@ -228,96 +169,56 @@ def run_workflow( """ state, errmsg = WorkflowChain().process(workflow_id, from_begin=from_begin) if not state: - return schemas.Response(success=False, message=errmsg) - return schemas.Response(success=True) + return _SchemaResponse(success=False, message=errmsg) + return _SchemaResponse(success=True) @router.post( - "/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response[None] + "/{workflow_id}/start", summary="启用工作流", response_model=_SchemaResponse[None] ) def start_workflow( workflow_id: int, - db: Session = Depends(get_db), + command: WorkflowMutationCommand = Depends(get_workflow_mutation_command), _: User = Depends(get_current_active_manage_user), ) -> Any: """ 启用工作流 """ - workflow = WorkflowOper(db).get(workflow_id) - if not workflow: - return schemas.Response(success=False, message="工作流不存在") - trigger_type = workflow.trigger_type or WORKFLOW_TRIGGER_TIMER - if trigger_type == WORKFLOW_TRIGGER_TIMER and not workflow.timer: - return schemas.Response(success=False, message="定时工作流缺少定时器配置") - if trigger_type not in { - WORKFLOW_TRIGGER_TIMER, - WORKFLOW_TRIGGER_EVENT, - WORKFLOW_TRIGGER_MANUAL, - }: - return schemas.Response(success=False, message="工作流触发类型不支持") - # 先更新状态,事件触发注册会重新读取工作流并跳过暂停状态。 - workflow.update_state(db, workflow_id, "W") - if trigger_type == WORKFLOW_TRIGGER_TIMER: - # 添加定时任务 - Scheduler().update_workflow_job(workflow) - elif trigger_type == WORKFLOW_TRIGGER_EVENT: - # 事件触发:添加到事件触发器 - WorkFlowManager().load_workflow_events(workflow_id) - return schemas.Response(success=True) + result = command.start(workflow_id) + return _SchemaResponse(success=result.success, message=result.message) @router.post( - "/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response[None] + "/{workflow_id}/pause", summary="停用工作流", response_model=_SchemaResponse[None] ) def pause_workflow( workflow_id: int, - db: Session = Depends(get_db), + command: WorkflowMutationCommand = Depends(get_workflow_mutation_command), _: User = Depends(get_current_active_manage_user), ) -> Any: """ 停用工作流 """ - workflow = WorkflowOper(db).get(workflow_id) - if not workflow: - return schemas.Response(success=False, message="工作流不存在") - # 根据触发类型进行不同处理 - if workflow.trigger_type == WORKFLOW_TRIGGER_TIMER: - # 定时触发:移除定时任务 - Scheduler().remove_workflow_job(workflow) - elif workflow.trigger_type == WORKFLOW_TRIGGER_EVENT: - # 事件触发:从事件触发器中移除 - WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type) - # 停止工作流 - global_vars.stop_workflow(workflow_id) - # 更新状态 - workflow.update_state(db, workflow_id, "P") - return schemas.Response(success=True) + result = command.pause(workflow_id) + return _SchemaResponse(success=result.success, message=result.message) @router.post( - "/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response[None] + "/{workflow_id}/reset", summary="重置工作流", response_model=_SchemaResponse[None] ) async def reset_workflow( workflow_id: int, - db: AsyncSession = Depends(get_async_db), + command: WorkflowDefinitionCommand = Depends(get_workflow_definition_command), _: User = Depends(get_current_active_manage_user_async), ) -> Any: """ 重置工作流 """ - workflow = await WorkflowOper(db).async_get(workflow_id) - if not workflow: - return schemas.Response(success=False, message="工作流不存在") - # 停止工作流 - global_vars.stop_workflow(workflow_id) - # 重置工作流 - await Workflow.async_reset(db, workflow_id, reset_count=True) - # 删除缓存 - SystemConfigOper().delete(f"WorkflowCache-{workflow_id}") - return schemas.Response(success=True) + result = await command.reset(workflow_id) + return _SchemaResponse(success=result.success, message=result.message) -@router.get("/{workflow_id}", summary="工作流详情", response_model=schemas.Workflow) +@router.get("/{workflow_id}", summary="工作流详情", response_model=_SchemaWorkflow) async def get_workflow( workflow_id: int, db: AsyncSession = Depends(get_async_db), @@ -329,56 +230,27 @@ async def get_workflow( return await WorkflowOper(db).async_get(workflow_id) -@router.put("/{workflow_id}", summary="更新工作流", response_model=schemas.Response[None]) +@router.put("/{workflow_id}", summary="更新工作流", response_model=_SchemaResponse[None]) def update_workflow( - workflow: schemas.Workflow, - db: Session = Depends(get_db), + workflow: _SchemaWorkflow, + command: WorkflowMutationCommand = Depends(get_workflow_mutation_command), _: User = Depends(get_current_active_manage_user), ) -> Any: """ 更新工作流 """ - if not workflow.id: - return schemas.Response(success=False, message="工作流ID不能为空") - workflow_oper = WorkflowOper(db) - wf = workflow_oper.get(workflow.id) - if not wf: - return schemas.Response(success=False, message="工作流不存在") - if not wf.trigger_type: - workflow.trigger_type = "timer" - wf.update(db, workflow.model_dump()) - # 更新后的工作流对象 - updated_workflow = workflow_oper.get(workflow.id) - scheduler = Scheduler() - scheduler.remove_workflow_job(updated_workflow) - if not updated_workflow.trigger_type or updated_workflow.trigger_type == WORKFLOW_TRIGGER_TIMER: - if updated_workflow.timer: - scheduler.update_workflow_job(updated_workflow) - # 更新事件注册 - WorkFlowManager().update_workflow_event(updated_workflow) - return schemas.Response(success=True, message="更新成功") + result = command.update(workflow.model_dump()) + return _SchemaResponse(success=result.success, message=result.message) -@router.delete("/{workflow_id}", summary="删除工作流", response_model=schemas.Response[None]) +@router.delete("/{workflow_id}", summary="删除工作流", response_model=_SchemaResponse[None]) def delete_workflow( workflow_id: int, - db: Session = Depends(get_db), + command: WorkflowMutationCommand = Depends(get_workflow_mutation_command), _: User = Depends(get_current_active_manage_user), ) -> Any: """ 删除工作流 """ - workflow = WorkflowOper(db).get(workflow_id) - if not workflow: - return schemas.Response(success=False, message="工作流不存在") - if not workflow.trigger_type or workflow.trigger_type == WORKFLOW_TRIGGER_TIMER: - # 定时触发:删除定时任务 - Scheduler().remove_workflow_job(workflow) - elif workflow.trigger_type == WORKFLOW_TRIGGER_EVENT: - # 事件触发:从事件触发器中移除 - WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type) - # 删除工作流 - Workflow.delete(db, workflow_id) - # 删除缓存 - SystemConfigOper().delete(f"WorkflowCache-{workflow_id}") - return schemas.Response(success=True, message="删除成功") + result = command.delete(workflow_id) + return _SchemaResponse(success=result.success, message=result.message) diff --git a/app/api/servarr.py b/app/api/servarr.py index 8c3dadb29..b26008424 100644 --- a/app/api/servarr.py +++ b/app/api/servarr.py @@ -4,7 +4,15 @@ from fastapi import APIRouter, HTTPException, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session -from app import schemas +from app.schemas.response import Response as _SchemaResponse +from app.schemas.servarr import RadarrMovie as _SchemaRadarrMovie +from app.schemas.servarr import ServarrIdResponse as _SchemaServarrIdResponse +from app.schemas.servarr import ServarrLanguageProfile as _SchemaServarrLanguageProfile +from app.schemas.servarr import ServarrQualityProfile as _SchemaServarrQualityProfile +from app.schemas.servarr import ServarrRootFolder as _SchemaServarrRootFolder +from app.schemas.servarr import ServarrSystemStatus as _SchemaServarrSystemStatus +from app.schemas.servarr import ServarrTag as _SchemaServarrTag +from app.schemas.servarr import SonarrSeries as _SchemaSonarrSeries from app.api.response import ERROR_RESPONSES from app.chain.media import MediaChain from app.chain.subscribe import SubscribeChain @@ -14,7 +22,8 @@ from app.domain.metainfo import MetaInfo from app.application.security.access import verify_apikey from app.db import get_db, get_async_db from app.db.models.subscribe import Subscribe -from app.schemas import RadarrMovie, SonarrSeries +from app.schemas.servarr import RadarrMovie +from app.schemas.servarr import SonarrSeries from app.schemas.types import MediaSource, MediaType from version import APP_VERSION @@ -56,15 +65,15 @@ def _resolve_series_media( @arr_router.get( "/system/status", summary="系统状态", - response_model=schemas.ServarrSystemStatus, + response_model=_SchemaServarrSystemStatus, ) async def arr_system_status( _: Annotated[str, Depends(verify_apikey)], -) -> schemas.ServarrSystemStatus: +) -> _SchemaServarrSystemStatus: """ 模拟Radarr、Sonarr系统状态 """ - return schemas.ServarrSystemStatus.model_validate({ + return _SchemaServarrSystemStatus.model_validate({ "appName": "MoviePilot", "instanceName": "moviepilot", "version": APP_VERSION, @@ -116,16 +125,16 @@ async def arr_system_status( @arr_router.get( "/qualityProfile", summary="质量配置", - response_model=List[schemas.ServarrQualityProfile], + response_model=List[_SchemaServarrQualityProfile], ) async def arr_qualityProfile( _: Annotated[str, Depends(verify_apikey)], -) -> List[schemas.ServarrQualityProfile]: +) -> List[_SchemaServarrQualityProfile]: """ 模拟Radarr、Sonarr质量配置 """ return [ - schemas.ServarrQualityProfile.model_validate({ + _SchemaServarrQualityProfile.model_validate({ "id": 1, "name": "默认", "upgradeAllowed": True, @@ -154,16 +163,16 @@ async def arr_qualityProfile( @arr_router.get( "/rootfolder", summary="根目录", - response_model=List[schemas.ServarrRootFolder], + response_model=List[_SchemaServarrRootFolder], ) async def arr_rootfolder( _: Annotated[str, Depends(verify_apikey)], -) -> List[schemas.ServarrRootFolder]: +) -> List[_SchemaServarrRootFolder]: """ 模拟Radarr、Sonarr根目录 """ return [ - schemas.ServarrRootFolder.model_validate({ + _SchemaServarrRootFolder.model_validate({ "id": 1, "path": "/", "accessible": True, @@ -173,29 +182,29 @@ async def arr_rootfolder( ] -@arr_router.get("/tag", summary="标签", response_model=List[schemas.ServarrTag]) +@arr_router.get("/tag", summary="标签", response_model=List[_SchemaServarrTag]) async def arr_tag( _: Annotated[str, Depends(verify_apikey)], -) -> List[schemas.ServarrTag]: +) -> List[_SchemaServarrTag]: """ 模拟Radarr、Sonarr标签 """ - return [schemas.ServarrTag(id=1, label="默认")] + return [_SchemaServarrTag(id=1, label="默认")] @arr_router.get( "/languageprofile", summary="语言", - response_model=List[schemas.ServarrLanguageProfile], + response_model=List[_SchemaServarrLanguageProfile], ) async def arr_languageprofile( _: Annotated[str, Depends(verify_apikey)], -) -> List[schemas.ServarrLanguageProfile]: +) -> List[_SchemaServarrLanguageProfile]: """ 模拟Radarr、Sonarr语言 """ return [ - schemas.ServarrLanguageProfile.model_validate({ + _SchemaServarrLanguageProfile.model_validate({ "id": 1, "name": "默认", "upgradeAllowed": True, @@ -208,11 +217,11 @@ async def arr_languageprofile( @arr_router.get( - "/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie] + "/movie", summary="所有订阅电影", response_model=List[_SchemaRadarrMovie] ) async def arr_movies( _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db) -) -> List[schemas.RadarrMovie]: +) -> List[_SchemaRadarrMovie]: """ 查询Rardar电影 """ @@ -304,11 +313,11 @@ async def arr_movies( @arr_router.get( - "/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie] + "/movie/lookup", summary="查询电影", response_model=List[_SchemaRadarrMovie] ) def arr_movie_lookup( term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db) -) -> List[schemas.RadarrMovie]: +) -> List[_SchemaRadarrMovie]: """ 查询Rardar电影 term: `tmdb:${id}` 存在和不存在均不能返回错误 @@ -362,13 +371,13 @@ def arr_movie_lookup( @arr_router.get( - "/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie + "/movie/{mid}", summary="电影订阅详情", response_model=_SchemaRadarrMovie ) async def arr_movie( mid: int, _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db), -) -> schemas.RadarrMovie: +) -> _SchemaRadarrMovie: """ 查询Rardar电影订阅 """ @@ -390,13 +399,13 @@ async def arr_movie( @arr_router.post( - "/movie", summary="新增电影订阅", response_model=schemas.ServarrIdResponse + "/movie", summary="新增电影订阅", response_model=_SchemaServarrIdResponse ) async def arr_add_movie( _: Annotated[str, Depends(verify_apikey)], movie: RadarrMovie, db: AsyncSession = Depends(get_async_db), -) -> schemas.ServarrIdResponse: +) -> _SchemaServarrIdResponse: """ 新增Rardar电影订阅 """ @@ -405,7 +414,7 @@ async def arr_add_movie( db, MediaSource.TMDB.value, str(movie.tmdbId) ) if subscribes: - return schemas.ServarrIdResponse(id=subscribes[0].id) + return _SchemaServarrIdResponse(id=subscribes[0].id) # 添加订阅 sid, message = await SubscribeChain().async_add( title=movie.title, @@ -416,36 +425,36 @@ async def arr_add_movie( username="Seerr", ) if sid: - return schemas.ServarrIdResponse(id=sid) + return _SchemaServarrIdResponse(id=sid) else: raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}") @arr_router.delete( - "/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response[None] + "/movie/{mid}", summary="删除电影订阅", response_model=_SchemaResponse[None] ) async def arr_remove_movie( mid: int, _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db), -) -> schemas.Response[None]: +) -> _SchemaResponse[None]: """ 删除Rardar电影订阅 """ subscribe = await Subscribe.async_get(db, mid) if subscribe: await subscribe.async_delete(db, mid) - return schemas.Response(success=True) + return _SchemaResponse(success=True) else: raise HTTPException(status_code=404, detail="未找到该电影!") @arr_router.get( - "/series", summary="所有剧集", response_model=List[schemas.SonarrSeries] + "/series", summary="所有剧集", response_model=List[_SchemaSonarrSeries] ) async def arr_series( _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db) -) -> List[schemas.SonarrSeries]: +) -> List[_SchemaSonarrSeries]: """ 查询Sonarr剧集 """ @@ -585,11 +594,11 @@ async def arr_series( @arr_router.get( "/series/lookup", summary="查询剧集", - response_model=List[schemas.SonarrSeries], + response_model=List[_SchemaSonarrSeries], ) def arr_series_lookup( term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db) -) -> List[schemas.SonarrSeries]: +) -> List[_SchemaSonarrSeries]: """ 查询Sonarr剧集 term: `tvdb:${id}` title """ @@ -697,13 +706,13 @@ def arr_series_lookup( @arr_router.get( - "/series/{tid}", summary="剧集详情", response_model=schemas.SonarrSeries + "/series/{tid}", summary="剧集详情", response_model=_SchemaSonarrSeries ) async def arr_serie( tid: int, _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db), -) -> schemas.SonarrSeries: +) -> _SchemaSonarrSeries: """ 查询Sonarr剧集 """ @@ -734,13 +743,13 @@ async def arr_serie( @arr_router.post( - "/series", summary="新增剧集订阅", response_model=schemas.ServarrIdResponse + "/series", summary="新增剧集订阅", response_model=_SchemaServarrIdResponse ) async def arr_add_series( - tv: schemas.SonarrSeries, + tv: _SchemaSonarrSeries, _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db), -) -> schemas.ServarrIdResponse: +) -> _SchemaServarrIdResponse: """ 新增Sonarr剧集订阅 """ @@ -790,7 +799,7 @@ async def arr_add_series( left_seasons.append(season) # 全部已存在订阅 if not left_seasons: - return schemas.ServarrIdResponse(id=1) + return _SchemaServarrIdResponse(id=1) # 剩下的添加订阅 sid = 0 message = "" @@ -806,19 +815,19 @@ async def arr_add_series( ) if sid: - return schemas.ServarrIdResponse(id=sid) + return _SchemaServarrIdResponse(id=sid) else: raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}") @arr_router.put( - "/series", summary="更新剧集订阅", response_model=schemas.ServarrIdResponse + "/series", summary="更新剧集订阅", response_model=_SchemaServarrIdResponse ) async def arr_update_series( - tv: schemas.SonarrSeries, + tv: _SchemaSonarrSeries, _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db), -) -> schemas.ServarrIdResponse: +) -> _SchemaServarrIdResponse: """ 更新Sonarr剧集订阅 """ @@ -826,19 +835,19 @@ async def arr_update_series( @arr_router.delete( - "/series/{tid}", summary="删除剧集订阅", response_model=schemas.Response[None] + "/series/{tid}", summary="删除剧集订阅", response_model=_SchemaResponse[None] ) async def arr_remove_series( tid: int, _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db), -) -> schemas.Response[None]: +) -> _SchemaResponse[None]: """ 删除Sonarr剧集订阅 """ subscribe = await Subscribe.async_get(db, tid) if subscribe: await subscribe.async_delete(db, tid) - return schemas.Response(success=True) + return _SchemaResponse(success=True) else: raise HTTPException(status_code=404, detail="未找到该电视剧!") diff --git a/app/api/servcookie.py b/app/api/servcookie.py index c2c961182..24e206347 100644 --- a/app/api/servcookie.py +++ b/app/api/servcookie.py @@ -9,7 +9,11 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Reque from fastapi.responses import PlainTextResponse from fastapi.routing import APIRoute -from app import schemas +from app.schemas.servcookie import CookieActionResponse as _SchemaCookieActionResponse +from app.schemas.servcookie import CookieData as _SchemaCookieData +from app.schemas.servcookie import CookieDecryptedPayload as _SchemaCookieDecryptedPayload +from app.schemas.servcookie import CookieEncryptedPayload as _SchemaCookieEncryptedPayload +from app.schemas.servcookie import CookiePassword as _SchemaCookiePassword from app.api.response import ERROR_RESPONSES from app.runtime.config import settings from app.runtime.log import logger @@ -114,9 +118,9 @@ async def post_root() -> PlainTextResponse: @cookie_router.post( "/update", dependencies=[Depends(verify_update_auth)], - response_model=schemas.CookieActionResponse, + response_model=_SchemaCookieActionResponse, ) -async def update_cookie(req: schemas.CookieData) -> schemas.CookieActionResponse: +async def update_cookie(req: _SchemaCookieData) -> _SchemaCookieActionResponse: """ 上传Cookie数据 """ @@ -127,12 +131,12 @@ async def update_cookie(req: schemas.CookieData) -> schemas.CookieActionResponse async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file: read_content = await file.read() if read_content == content: - return schemas.CookieActionResponse(action="done") + return _SchemaCookieActionResponse(action="done") else: - return schemas.CookieActionResponse(action="error") + return _SchemaCookieActionResponse(action="error") -async def load_encrypt_data(uuid: str) -> schemas.CookieEncryptedPayload: +async def load_encrypt_data(uuid: str) -> _SchemaCookieEncryptedPayload: """ 加载本地加密原始数据 """ @@ -146,12 +150,12 @@ async def load_encrypt_data(uuid: str) -> schemas.CookieEncryptedPayload: async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file: read_content = await file.read() data = json.loads(read_content.encode("utf-8")) - return schemas.CookieEncryptedPayload.model_validate(data) + return _SchemaCookieEncryptedPayload.model_validate(data) def get_decrypted_cookie_data( uuid: str, password: str, encrypted: str -) -> Optional[schemas.CookieDecryptedPayload]: +) -> Optional[_SchemaCookieDecryptedPayload]: """ 加载本地加密数据并解密为Cookie """ @@ -163,7 +167,7 @@ def get_decrypted_cookie_data( decrypted_data = CryptoJsUtils.decrypt(encrypted, aes_key).decode("utf-8") decrypted_data = json.loads(decrypted_data) if "cookie_data" in decrypted_data: - return schemas.CookieDecryptedPayload.model_validate(decrypted_data) + return _SchemaCookieDecryptedPayload.model_validate(decrypted_data) except Exception as e: logger.error(f"解密Cookie数据失败:{str(e)}") return None @@ -171,30 +175,30 @@ def get_decrypted_cookie_data( return None -@cookie_router.get("/get/{uuid}", response_model=schemas.CookieEncryptedPayload) +@cookie_router.get("/get/{uuid}", response_model=_SchemaCookieEncryptedPayload) async def get_cookie( uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")], -) -> schemas.CookieEncryptedPayload: +) -> _SchemaCookieEncryptedPayload: """ GET 下载加密数据 """ - return schemas.CookieEncryptedPayload.model_validate( + return _SchemaCookieEncryptedPayload.model_validate( await load_encrypt_data(uuid) ) @cookie_router.post( "/get/{uuid}", - response_model=schemas.CookieEncryptedPayload | schemas.CookieDecryptedPayload | None, + response_model=_SchemaCookieEncryptedPayload | _SchemaCookieDecryptedPayload | None, ) async def post_cookie( uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")], - request: Optional[schemas.CookiePassword] = Body(None), -) -> schemas.CookieEncryptedPayload | schemas.CookieDecryptedPayload | None: + request: Optional[_SchemaCookiePassword] = Body(None), +) -> _SchemaCookieEncryptedPayload | _SchemaCookieDecryptedPayload | None: """ POST 下载加密数据 """ - data = schemas.CookieEncryptedPayload.model_validate( + data = _SchemaCookieEncryptedPayload.model_validate( await load_encrypt_data(uuid) ) if request is not None: diff --git a/app/application/chain/__init__.py b/app/application/chain/__init__.py new file mode 100644 index 000000000..2a82c36fb --- /dev/null +++ b/app/application/chain/__init__.py @@ -0,0 +1 @@ +"""Chain 运行时依赖组合。""" diff --git a/app/application/chain/context.py b/app/application/chain/context.py new file mode 100644 index 000000000..583961b4c --- /dev/null +++ b/app/application/chain/context.py @@ -0,0 +1,64 @@ +"""Chain 兼容门面所需运行时依赖的显式上下文。""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Optional + +from app.application.messaging.message import MessageHelper, MessageQueueManager +from app.db.oper.message import MessageOper +from app.runtime.cache import AsyncFileCache, FileCache +from app.runtime.events import EventManager +from app.runtime.extensions.module_manager import ModuleManager +from app.runtime.extensions.plugin_manager import PluginManager + + +MessageQueueFactory = Callable[[Callable[..., Any]], Any] +ChainRuntimeContextProvider = Callable[[], "ChainRuntimeContext"] + + +@dataclass(frozen=True, slots=True) +class ChainRuntimeContext: + """集中声明 Chain 调度、事件、消息和缓存所需的最小运行时对象。""" + + module_manager: Any + plugin_manager: Any + event_manager: Any + message_oper: Any + message_helper: Any + file_cache: Any + async_file_cache: Any + message_queue_factory: MessageQueueFactory + + +def build_default_chain_runtime_context() -> ChainRuntimeContext: + """按旧构造规则创建上下文,同时复用各管理器既有单例身份。""" + return ChainRuntimeContext( + module_manager=ModuleManager(), + plugin_manager=PluginManager(), + event_manager=EventManager(), + message_oper=MessageOper(), + message_helper=MessageHelper(), + file_cache=FileCache(), + async_file_cache=AsyncFileCache(), + message_queue_factory=lambda callback: MessageQueueManager( + send_callback=callback + ), + ) + + +_context_provider: ChainRuntimeContextProvider = build_default_chain_runtime_context + + +def configure_chain_runtime_context_provider( + provider: Optional[ChainRuntimeContextProvider], +) -> None: + """由组合根替换 Chain 上下文来源;传入空值恢复兼容默认值。""" + global _context_provider + _context_provider = provider or build_default_chain_runtime_context + + +def get_chain_runtime_context() -> ChainRuntimeContext: + """返回当前组合根提供的 Chain 运行上下文。""" + return _context_provider() diff --git a/app/application/directory.py b/app/application/directory.py index 4167b6abc..3a5207040 100644 --- a/app/application/directory.py +++ b/app/application/directory.py @@ -2,7 +2,8 @@ import re from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath from typing import List, Optional, Tuple -from app import schemas +from app.schemas.file import FileURI as _SchemaFileURI +from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf from app.domain.context import MediaInfo from app.db.oper.systemconfig import SystemConfigOper from app.runtime.log import logger @@ -20,22 +21,22 @@ class DirectoryHelper: """ @staticmethod - def get_dirs() -> List[schemas.TransferDirectoryConf]: + def get_dirs() -> List[_SchemaTransferDirectoryConf]: """ 获取所有下载目录 """ dir_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Directories) if not dir_confs: return [] - return [schemas.TransferDirectoryConf(**d) for d in dir_confs] + return [_SchemaTransferDirectoryConf(**d) for d in dir_confs] - def get_download_dirs(self) -> List[schemas.TransferDirectoryConf]: + def get_download_dirs(self) -> List[_SchemaTransferDirectoryConf]: """ 获取所有下载目录 """ return sorted([d for d in self.get_dirs() if d.download_path], key=lambda x: x.priority) - def get_local_download_dirs(self) -> List[schemas.TransferDirectoryConf]: + def get_local_download_dirs(self) -> List[_SchemaTransferDirectoryConf]: """ 获取所有本地的可下载目录 """ @@ -45,7 +46,7 @@ class DirectoryHelper: self, media: Optional[MediaInfo], save_path: str, - ) -> Optional[schemas.TransferDirectoryConf]: + ) -> Optional[_SchemaTransferDirectoryConf]: """ 按媒体信息和精确保存根路径匹配下载目录配置。 @@ -78,13 +79,13 @@ class DirectoryHelper: return dir_info return None - def get_library_dirs(self) -> List[schemas.TransferDirectoryConf]: + def get_library_dirs(self) -> List[_SchemaTransferDirectoryConf]: """ 获取所有媒体库目录 """ return sorted([d for d in self.get_dirs() if d.library_path], key=lambda x: x.priority) - def get_local_library_dirs(self) -> List[schemas.TransferDirectoryConf]: + def get_local_library_dirs(self) -> List[_SchemaTransferDirectoryConf]: """ 获取所有本地的媒体库目录 """ @@ -93,7 +94,7 @@ class DirectoryHelper: def get_dir(self, media: Optional[MediaInfo], include_unsorted: Optional[bool] = False, storage: Optional[str] = None, src_path: Path = None, target_storage: Optional[str] = None, dest_path: Path = None - ) -> Optional[schemas.TransferDirectoryConf]: + ) -> Optional[_SchemaTransferDirectoryConf]: """ 根据媒体信息获取下载目录、媒体库目录配置 :param media: 媒体信息 @@ -113,7 +114,7 @@ class DirectoryHelper: dirs_to_consider = matching_dirs if matching_dirs else dirs # 已匹配的目录 - matched_dirs: List[schemas.TransferDirectoryConf] = [] + matched_dirs: List[_SchemaTransferDirectoryConf] = [] # 按照配置顺序查找 for d in dirs_to_consider: # 没有启用整理的目录 @@ -297,10 +298,10 @@ def _download_path_uri(storage: str, path: PurePath) -> str: path_value = path.as_posix() if storage == "local": return path_value - return schemas.FileURI(storage=storage, path=path_value).uri + return _SchemaFileURI(storage=storage, path=path_value).uri -def _normalize_download_root(dir_info: schemas.TransferDirectoryConf) -> Optional[Tuple[str, str, PurePath]]: +def _normalize_download_root(dir_info: _SchemaTransferDirectoryConf) -> Optional[Tuple[str, str, PurePath]]: """ 读取下载目录配置中的根路径;无效配置不参与用户 save_path allowlist。 """ diff --git a/app/application/download/__init__.py b/app/application/download/__init__.py new file mode 100644 index 000000000..cd198fc92 --- /dev/null +++ b/app/application/download/__init__.py @@ -0,0 +1 @@ +"""下载应用服务。""" diff --git a/app/application/download/tasks.py b/app/application/download/tasks.py new file mode 100644 index 000000000..71d1bf302 --- /dev/null +++ b/app/application/download/tasks.py @@ -0,0 +1,77 @@ +"""下载任务查询与控制应用服务。""" + +from typing import Callable, List, Optional + +from app.schemas.transfer import DownloaderTorrent +from app.schemas.types import TorrentStatus + + +class DownloadTaskService: + """通过下载器和历史端口查询、启停及删除下载任务。""" + + def __init__( + self, + list_torrents: Callable[..., List[DownloaderTorrent]], + get_history_by_hashes: Callable[[list[str]], dict], + start_torrents: Callable[..., bool], + stop_torrents: Callable[..., bool], + remove_torrents: Callable[..., bool], + ) -> None: + """注入下载器操作和历史读取端口。""" + self._list_torrents = list_torrents + self._get_history_by_hashes = get_history_by_hashes + self._start_torrents = start_torrents + self._stop_torrents = stop_torrents + self._remove_torrents = remove_torrents + + def downloading(self, name: Optional[str] = None) -> List[DownloaderTorrent]: + """查询下载中任务,并附加对应下载历史的媒体与用户信息。""" + torrents = self._list_torrents( + downloader=name, + status=TorrentStatus.DOWNLOADING, + ) + if not torrents: + return [] + history_map = self._get_history_by_hashes( + [torrent.hash for torrent in torrents if torrent.hash] + ) + for torrent in torrents: + history = history_map.get(torrent.hash) + if not history: + continue + torrent.media = { + "media_source": history.media_source, + "media_id": history.media_id, + "type": history.type, + "title": history.title, + "season": history.seasons, + "episode": history.episodes, + "image": history.poster, + "poster": history.poster, + "backdrop": history.image, + } + torrent.site_name = history.torrent_site + torrent.userid = history.userid + torrent.username = history.username + return torrents + + def set_downloading( + self, + hash_str: str, + operation: str, + name: Optional[str] = None, + ) -> bool: + """按 start/stop 操作控制单个下载任务。""" + if operation == "start": + return self._start_torrents(hashs=[hash_str], downloader=name) + if operation == "stop": + return self._stop_torrents(hashs=[hash_str], downloader=name) + return False + + def remove_downloading( + self, + hash_str: str, + name: Optional[str] = None, + ) -> bool: + """删除单个下载任务。""" + return self._remove_torrents(hashs=[hash_str], downloader=name) diff --git a/app/application/downloader.py b/app/application/downloader.py index ea76e5ce0..5fb31ab3a 100644 --- a/app/application/downloader.py +++ b/app/application/downloader.py @@ -1,7 +1,8 @@ from typing import Optional from app.runtime.extensions.service_registry import ServiceBaseHelper -from app.schemas import DownloaderConf, ServiceInfo +from app.schemas.system import DownloaderConf +from app.schemas.system import ServiceInfo from app.schemas.types import SystemConfigKey, ModuleType diff --git a/app/application/formatting.py b/app/application/formatting.py index 9634e9102..029d75fe5 100644 --- a/app/application/formatting.py +++ b/app/application/formatting.py @@ -11,7 +11,8 @@ from app.runtime.config import settings from app.domain.metainfo import MetaInfoPath from app.domain.meta.metabase import MetaBase from app.runtime.log import logger -from app.schemas import EpisodeFormatRule, FileItem +from app.schemas.transfer import EpisodeFormatRule +from app.schemas.workflow import FileItem @dataclass(frozen=True) diff --git a/app/application/history.py b/app/application/history.py index 28e6ba6bb..b66fa68e8 100644 --- a/app/application/history.py +++ b/app/application/history.py @@ -1,4 +1,6 @@ -from typing import Any, Dict, Optional, Union +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, Optional, Protocol, Union from app.domain.context import MediaInfo, MusicInfo from app.schemas.media import resolve_media_identity @@ -9,7 +11,8 @@ from app.runtime.config import settings from app.db.models.transferhistory import TransferHistory from app.db.oper.transferhistory import TransferHistoryOper from app.runtime.log import logger -from app.schemas import FileItem, TransferInfo +from app.schemas.workflow import FileItem +from app.schemas.transfer import TransferInfo from app.schemas.types import MUSIC_ENTITY_RECORDING # 失败重试次数的合法区间。下界为 1:一次瞬时故障(网络抖动、TMDB 瞬断、移动失败) @@ -25,6 +28,160 @@ FAILED_RETRY_TTL = 24 * 3600 _failed_retry_counts = TTLCache(region="transfer_failed_retry", maxsize=5000, ttl=FAILED_RETRY_TTL) +@dataclass(frozen=True, slots=True) +class HistoryMutationResult: + """描述历史记录维护操作是否成功及兼容提示。""" + + success: bool + message: str = "" + + +class DownloadHistoryMutationRepository(Protocol): + """下载历史删除用例需要的最小持久化端口。""" + + def stage_delete_history(self, history_id: int) -> None: + """暂存下载历史删除。""" + ... + + +class TransferHistoryMutationRepository(Protocol): + """整理历史删除与清理用例需要的最小持久化端口。""" + + def get(self, history_id: int) -> Optional[Any]: + """读取整理历史。""" + ... + + def stage_delete(self, history_id: int) -> None: + """暂存整理历史删除。""" + ... + + def stage_truncate(self) -> None: + """暂存全部整理历史删除。""" + ... + + +class DownloadFileMutationRepository(Protocol): + """整理历史删除时关联下载文件状态更新端口。""" + + def stage_delete_file_by_fullpath(self, fullpath: str) -> None: + """暂存下载文件删除状态。""" + ... + + +class HistoryUnitOfWork(Protocol): + """同步历史维护用例使用的事务端口。""" + + def commit(self) -> None: + """提交当前事务。""" + ... + + def rollback(self) -> None: + """回滚当前事务。""" + ... + + +class DownloadHistoryMutationCommand: + """统一提交下载历史删除,避免 API 直接持有数据库事务。""" + + def __init__( + self, + *, + repository: DownloadHistoryMutationRepository, + unit_of_work: HistoryUnitOfWork, + ) -> None: + """保存下载历史持久化和事务端口。""" + self._repository = repository + self._unit_of_work = unit_of_work + + def delete(self, history_id: int) -> HistoryMutationResult: + """暂存并提交单条下载历史删除。""" + self._repository.stage_delete_history(history_id) + self._commit() + return HistoryMutationResult(True) + + def _commit(self) -> None: + """提交事务,失败时回滚。""" + try: + self._unit_of_work.commit() + except Exception: + self._unit_of_work.rollback() + raise + + +class TransferHistoryMutationCommand: + """协调整理历史、关联文件状态和外部存储删除。""" + + def __init__( + self, + *, + repository: TransferHistoryMutationRepository, + download_repository: DownloadFileMutationRepository, + unit_of_work: HistoryUnitOfWork, + file_item_factory: Callable[[dict], Any], + delete_media_file: Callable[[Any], bool], + publish_download_file_deleted: Callable[[dict], None], + clear_failures: Callable[[Optional[str], Optional[str]], None], + ) -> None: + """保存历史事务、存储删除、事件和失败状态清理端口。""" + self._repository = repository + self._download_repository = download_repository + self._unit_of_work = unit_of_work + self._file_item_factory = file_item_factory + self._delete_media_file = delete_media_file + self._publish_download_file_deleted = publish_download_file_deleted + self._clear_failures = clear_failures + + def delete( + self, + history_id: int, + *, + delete_source: bool = False, + delete_destination: bool = False, + ) -> HistoryMutationResult: + """删除整理记录,并保持源文件失败时不提交数据库变更。""" + history = self._repository.get(history_id) + if not history: + return HistoryMutationResult(False, "记录不存在") + + if delete_destination and history.dest_fileitem: + destination = self._file_item_factory(history.dest_fileitem) + self._delete_media_file(destination) + + source_deleted = False + if delete_source and history.src_fileitem: + source = self._file_item_factory(history.src_fileitem) + if not self._delete_media_file(source): + return HistoryMutationResult(False, f"{source.path} 删除失败") + self._download_repository.stage_delete_file_by_fullpath( + Path(source.path).as_posix() + ) + source_deleted = True + + self._repository.stage_delete(history_id) + self._commit() + if source_deleted: + self._publish_download_file_deleted({ + "src": history.src, + "hash": history.download_hash, + }) + self._clear_failures(history.src, history.src_storage) + return HistoryMutationResult(True) + + def truncate(self) -> HistoryMutationResult: + """在单一事务中清空全部整理历史。""" + self._repository.stage_truncate() + self._commit() + return HistoryMutationResult(True) + + def _commit(self) -> None: + """提交历史事务,失败时回滚且不发布事件或清缓存。""" + try: + self._unit_of_work.commit() + except Exception: + self._unit_of_work.rollback() + raise + + class HistoryGateAction: """ 整理历史查重闸的判定结果。 diff --git a/app/application/maintenance.py b/app/application/maintenance.py new file mode 100644 index 000000000..66e6135e5 --- /dev/null +++ b/app/application/maintenance.py @@ -0,0 +1,343 @@ +"""应用级数据维护用例。 + +本模块拥有保留期、批次循环、进度和部分失败汇总语义。具体数据库表如何删除由 +``CleanupRepository`` 端口提供,调度器只负责触发用例。 +""" + +import json +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Callable, ContextManager, Dict, Optional, Protocol + +from app.db.maintenance import DatabaseCleanupRepository +from app.db.session import SessionFactory +from app.runtime.config import settings +from app.runtime.log import logger + + +CleanupProgress = Callable[..., None] + + +@dataclass(frozen=True, slots=True) +class CleanupPolicy: + """描述一次数据维护运行使用的总开关和各表保留期。""" + + enabled: bool + message_days: int + download_history_days: int + site_userdata_days: int + transfer_history_days: int + download_failure_days: int + + +@dataclass(frozen=True, slots=True) +class CleanupPlan: + """描述单张表的保留期、截止点和批量删除动作。""" + + name: str + retention_days: int + cutoff: str + delete_batch: Callable[[Any], int] + + +class CleanupRepository(Protocol): + """数据维护用例需要的最小持久化端口。""" + + def session(self) -> ContextManager[Any]: + """返回一次维护运行共用的数据库会话上下文。""" + ... + + def delete_messages(self, db: Any, cutoff: str, limit: int) -> int: + """删除早于截止时间的消息。""" + ... + + def delete_download_history(self, db: Any, cutoff: str, limit: int) -> int: + """删除早于截止时间的下载历史。""" + ... + + def delete_download_orphans(self, db: Any, limit: int) -> int: + """删除已经失去父下载历史的文件记录。""" + ... + + def delete_site_userdata(self, db: Any, cutoff: str, limit: int) -> int: + """删除早于截止日期的站点用户数据快照。""" + ... + + def delete_transfer_history(self, db: Any, cutoff: str, limit: int) -> int: + """删除早于截止时间的整理历史。""" + ... + + def delete_download_failures(self, db: Any, cutoff: str, limit: int) -> int: + """删除已经过期的下载失败冷却记录。""" + ... + + +class DataCleanupService: + """按配置执行分批数据清理并生成兼容报告。""" + + DEFAULT_BATCH_SIZE = 500 + + def __init__( + self, + *, + repository: CleanupRepository, + policy_reader: Callable[[], CleanupPolicy], + clock: Callable[[], datetime] = datetime.now, + ) -> None: + """保存持久化端口、动态配置读取器和可测试时钟。""" + self._repository = repository + self._policy_reader = policy_reader + self._clock = clock + + def execute( + self, + batch_size: Optional[int] = None, + progress_callback: Optional[CleanupProgress] = None, + ) -> Dict[str, Any]: + """执行全部清理计划,保持旧调度入口的报告和异常语义。""" + started_at = self._clock() + normalized_batch_size = batch_size or self.DEFAULT_BATCH_SIZE + if normalized_batch_size <= 0: + normalized_batch_size = self.DEFAULT_BATCH_SIZE + policy = self._policy_reader() + report: Dict[str, Any] = { + "started_at": started_at.strftime("%Y-%m-%d %H:%M:%S"), + "batch_size": normalized_batch_size, + "enabled": policy.enabled, + "tables": {}, + "total_deleted": 0, + } + if not policy.enabled: + report["skipped_reason"] = "disabled" + logger.info("数据表清理总开关未开启,跳过执行") + return report + + plans = self._build_plans( + policy=policy, + started_at=started_at, + batch_size=normalized_batch_size, + ) + if progress_callback: + progress_callback(value=0, text="开始清理数据表 ...") + + errors: list[str] = [] + with self._repository.session() as db: + for plan_index, plan in enumerate(plans): + self._execute_plan( + db=db, + plan=plan, + plan_index=plan_index, + total_plans=len(plans), + report=report, + errors=errors, + progress_callback=progress_callback, + ) + + if errors: + report["errors"] = errors + logger.error( + f"数据表清理部分失败:{json.dumps(report, ensure_ascii=False)}" + ) + raise RuntimeError(";".join(errors)) + + logger.info(f"数据表清理完成:{json.dumps(report, ensure_ascii=False)}") + return report + + def _execute_plan( + self, + *, + db: Any, + plan: CleanupPlan, + plan_index: int, + total_plans: int, + report: Dict[str, Any], + errors: list[str], + progress_callback: Optional[CleanupProgress], + ) -> None: + """执行单表计划并把成功、跳过或失败状态写入总报告。""" + if plan.retention_days <= 0: + report["tables"][plan.name] = { + "deleted": 0, + "batches": 0, + "cutoff": None, + "retention_days": plan.retention_days, + "skipped": True, + "reason": "retention_days<=0", + } + if progress_callback: + progress_callback( + value=(plan_index + 1) / total_plans * 100, + text=f"数据表 {plan.name} 跳过清理", + ) + return + + try: + if progress_callback: + progress_callback( + value=plan_index / total_plans * 100, + text=f"正在清理数据表 {plan.name} ...", + ) + table_report = self._cleanup_in_batches( + db=db, + table_name=plan.name, + delete_batch=plan.delete_batch, + ) + table_report["cutoff"] = plan.cutoff + table_report["retention_days"] = plan.retention_days + report["tables"][plan.name] = table_report + report["total_deleted"] += table_report["deleted"] + except Exception as err: + errors.append(f"{plan.name}: {str(err)}") + logger.error(f"数据表 {plan.name} 清理失败:{str(err)}") + report["tables"][plan.name] = { + "deleted": 0, + "batches": 0, + "cutoff": plan.cutoff, + "retention_days": plan.retention_days, + "error": str(err), + } + finally: + if progress_callback: + progress_callback( + value=(plan_index + 1) / total_plans * 100, + text=f"数据表 {plan.name} 清理处理完成", + ) + + def _build_plans( + self, + *, + policy: CleanupPolicy, + started_at: datetime, + batch_size: int, + ) -> list[CleanupPlan]: + """把一次动态配置快照转换为固定顺序的清理计划。""" + message_cutoff = self._cutoff(started_at, policy.message_days, "%Y-%m-%d") + download_history_cutoff = self._cutoff( + started_at, + policy.download_history_days, + "%Y-%m-%d", + ) + site_userdata_cutoff = self._cutoff( + started_at, + policy.site_userdata_days, + "%Y-%m-%d", + ) + transfer_history_cutoff = self._cutoff( + started_at, + policy.transfer_history_days, + "%Y-%m-%d", + ) + download_failure_cutoff = self._cutoff( + started_at, + policy.download_failure_days, + "%Y-%m-%d %H:%M:%S", + ) + return [ + CleanupPlan( + "message", + policy.message_days, + message_cutoff, + lambda db: self._repository.delete_messages( + db, message_cutoff, batch_size + ), + ), + CleanupPlan( + "downloadhistory", + policy.download_history_days, + download_history_cutoff, + lambda db: self._repository.delete_download_history( + db, download_history_cutoff, batch_size + ), + ), + CleanupPlan( + "downloadfiles", + policy.download_history_days, + "follow-parent-history", + lambda db: self._repository.delete_download_orphans(db, batch_size), + ), + CleanupPlan( + "siteuserdata", + policy.site_userdata_days, + site_userdata_cutoff, + lambda db: self._repository.delete_site_userdata( + db, site_userdata_cutoff, batch_size + ), + ), + CleanupPlan( + "transferhistory", + policy.transfer_history_days, + transfer_history_cutoff, + lambda db: self._repository.delete_transfer_history( + db, transfer_history_cutoff, batch_size + ), + ), + CleanupPlan( + "downloadfailure", + policy.download_failure_days, + download_failure_cutoff, + lambda db: self._repository.delete_download_failures( + db, download_failure_cutoff, batch_size + ), + ), + ] + + @staticmethod + def _cleanup_in_batches( + *, + db: Any, + table_name: str, + delete_batch: Callable[[Any], int], + ) -> Dict[str, int]: + """循环执行单表分批删除,直到持久化端口返回零。""" + total_deleted = 0 + batches = 0 + while True: + deleted = delete_batch(db) or 0 + if deleted <= 0: + break + batches += 1 + total_deleted += deleted + logger.info( + f"数据表 {table_name} 清理第 {batches} 批完成,删除 {deleted} 条记录" + ) + return {"deleted": total_deleted, "batches": batches} + + @staticmethod + def _cutoff(started_at: datetime, retention_days: int, pattern: str) -> str: + """按兼容格式计算一个清理截止时间。""" + return (started_at - timedelta(days=retention_days)).strftime(pattern) + + +def read_cleanup_policy() -> CleanupPolicy: + """读取并规范化当前数据清理配置,单次运行期间保持快照一致。""" + return CleanupPolicy( + enabled=bool(settings.DATA_CLEANUP_ENABLE), + message_days=_normalize_days(settings.DATA_CLEANUP_MESSAGE_DAYS), + download_history_days=_normalize_days( + settings.DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS + ), + site_userdata_days=_normalize_days(settings.DATA_CLEANUP_SITE_USERDATA_DAYS), + transfer_history_days=_normalize_days( + settings.DATA_CLEANUP_TRANSFER_HISTORY_DAYS + ), + download_failure_days=_normalize_days( + settings.DATA_CLEANUP_DOWNLOAD_FAILURE_DAYS + ), + ) + + +def build_cleanup_service() -> DataCleanupService: + """在应用边界组装默认数据库适配器,供兼容调度门面触发。""" + return DataCleanupService( + repository=DatabaseCleanupRepository(session_factory=SessionFactory), + policy_reader=read_cleanup_policy, + ) + + +def _normalize_days(retention_days: Any) -> int: + """把配置保留期规范为非负整数,非法值按关闭单表清理处理。""" + try: + normalized_days = int(retention_days or 0) + except (TypeError, ValueError): + return 0 + return max(normalized_days, 0) diff --git a/app/application/mediaserver.py b/app/application/mediaserver.py index 634ee20c6..fbaadca19 100644 --- a/app/application/mediaserver.py +++ b/app/application/mediaserver.py @@ -2,11 +2,12 @@ import re from collections.abc import Iterable, Mapping from typing import Any, Optional -from app import schemas +from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem from app.domain.context import MusicInfo from app.schemas.media import normalize_media_source, resolve_media_identity from app.runtime.extensions.service_registry import ServiceBaseHelper -from app.schemas import MediaServerConf, ServiceInfo +from app.schemas.system import MediaServerConf +from app.schemas.system import ServiceInfo from app.schemas.types import ( MUSIC_ENTITY_ALBUM, MediaSource, @@ -68,7 +69,7 @@ class MediaServerIdentityHelper: @classmethod def is_compatible( cls, - item: schemas.MediaServerItem, + item: _SchemaMediaServerItem, media_source: Optional[MediaSource | str], media_id: Optional[str], ) -> bool: @@ -196,7 +197,7 @@ class MusicMediaServerHelper: def item_matches( cls, mediainfo: MusicInfo, - item: schemas.MediaServerItem, + item: _SchemaMediaServerItem, ) -> bool: """校验媒体库条目是否精确对应单曲,或完整覆盖目标专辑。""" note = item.note if isinstance(item.note, Mapping) else {} @@ -237,8 +238,8 @@ class MusicMediaServerHelper: def find_match( cls, mediainfo: MusicInfo, - items: Optional[Iterable[schemas.MediaServerItem]], - ) -> Optional[schemas.MediaServerItem]: + items: Optional[Iterable[_SchemaMediaServerItem]], + ) -> Optional[_SchemaMediaServerItem]: """返回首个满足单曲精确匹配或整专完整性要求的媒体库条目。""" return next( (item for item in items or [] if item and cls.item_matches(mediainfo, item)), diff --git a/app/application/messaging/interaction.py b/app/application/messaging/interaction.py index 5db9ffb56..f0bdb2674 100644 --- a/app/application/messaging/interaction.py +++ b/app/application/messaging/interaction.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from threading import Lock from typing import Any, Dict, List, Optional, Protocol, Sequence, Tuple, Union -from app.schemas import Message +from app.schemas.message import Message from app.schemas.notification import ChannelCapabilityManager from app.schemas.types import NotificationChannel diff --git a/app/application/messaging/plugin.py b/app/application/messaging/plugin.py index c5aca19cd..b1a7d451d 100644 --- a/app/application/messaging/plugin.py +++ b/app/application/messaging/plugin.py @@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union from app.application.messaging.interaction import InteractionContext, MessageGateway from app.runtime.events import EventManager -from app.schemas import Message +from app.schemas.message import Message from app.schemas.types import EventType, NotificationChannel diff --git a/app/application/messaging/session.py b/app/application/messaging/session.py new file mode 100644 index 000000000..7a33ccbe2 --- /dev/null +++ b/app/application/messaging/session.py @@ -0,0 +1,91 @@ +"""消息入口的用户会话状态用例。""" + +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Callable, MutableMapping, Optional, Union + + +UserId = Union[str, int] +SessionEntry = tuple[str, datetime] +ExpiredSessionHandler = Callable[[str, UserId], None] +Clock = Callable[[], datetime] +SessionIdFactory = Callable[[UserId, datetime], str] + + +@dataclass(frozen=True, slots=True) +class SessionResolution: + """描述用户会话解析结果及是否复用了旧会话。""" + + session_id: str + reused: bool + inactive_minutes: float = 0.0 + + +class MessageSessionService: + """管理消息用户到 Agent 会话的绑定、复用和过期清理。""" + + def __init__( + self, + *, + sessions: MutableMapping[UserId, SessionEntry], + timeout_minutes: int, + expired_handler: ExpiredSessionHandler, + clock: Clock = datetime.now, + session_id_factory: Optional[SessionIdFactory] = None, + ) -> None: + """保存共享会话映射和由 Chain 提供的 Agent 清理端口。""" + self._sessions = sessions + self._timeout = timedelta(minutes=timeout_minutes) + self._expired_handler = expired_handler + self._clock = clock + self._session_id_factory = session_id_factory or self._default_session_id + + @staticmethod + def _default_session_id(user_id: UserId, now: datetime) -> str: + """按历史格式生成新的用户会话 ID。""" + return f"user_{user_id}_{int(now.timestamp())}" + + def cleanup(self, now: Optional[datetime] = None) -> None: + """移除超时绑定,并通知拥有者释放对应 Agent 会话。""" + current_time = now or self._clock() + for user_id, (session_id, last_time) in list(self._sessions.items()): + if current_time - last_time <= self._timeout: + continue + self._sessions.pop(user_id, None) + self._expired_handler(session_id, user_id) + + def resolve(self, user_id: UserId) -> SessionResolution: + """复用有效绑定或为用户创建新会话。""" + current_time = self._clock() + self.cleanup(current_time) + current = self._sessions.get(user_id) + if current: + session_id, last_time = current + inactive = current_time - last_time + if inactive <= self._timeout: + self._sessions[user_id] = (session_id, current_time) + return SessionResolution( + session_id=session_id, + reused=True, + inactive_minutes=inactive.total_seconds() / 60, + ) + + session_id = self._session_id_factory(user_id, current_time) + self._sessions[user_id] = (session_id, current_time) + return SessionResolution(session_id=session_id, reused=False) + + def bind(self, user_id: UserId, session_id: str) -> None: + """绑定指定会话,并在替换时释放旧会话。""" + current = self._sessions.get(user_id) + if current and current[0] != session_id: + self._expired_handler(current[0], user_id) + self._sessions[user_id] = (session_id, self._clock()) + + def clear(self, user_id: UserId) -> Optional[str]: + """清除用户绑定并返回被移除的会话 ID。""" + current = self._sessions.pop(user_id, None) + return current[0] if current else None + + def get(self, user_id: UserId) -> Optional[SessionEntry]: + """读取用户当前会话绑定,不改变最后活动时间。""" + return self._sessions.get(user_id) diff --git a/app/application/messaging/site.py b/app/application/messaging/site.py index 5558c9e59..bf4747e71 100644 --- a/app/application/messaging/site.py +++ b/app/application/messaging/site.py @@ -15,7 +15,7 @@ from app.application.messaging.interaction import ( update_or_post_message, ) from app.runtime.log import logger -from app.schemas import Message +from app.schemas.message import Message from app.schemas.types import NotificationChannel diff --git a/app/application/messaging/skill.py b/app/application/messaging/skill.py index 0824b987e..8b515d0a6 100644 --- a/app/application/messaging/skill.py +++ b/app/application/messaging/skill.py @@ -13,7 +13,7 @@ from app.application.messaging.interaction import ( supports_interaction_buttons, update_or_post_message, ) -from app.schemas import Message +from app.schemas.message import Message from app.schemas.types import NotificationChannel diff --git a/app/application/messaging/subscribe.py b/app/application/messaging/subscribe.py index 53e1b7e42..9de1d9fa2 100644 --- a/app/application/messaging/subscribe.py +++ b/app/application/messaging/subscribe.py @@ -14,7 +14,7 @@ from app.application.messaging.interaction import ( ) from app.db.models.subscribe import Subscribe from app.db.oper.subscribe import SubscribeOper -from app.schemas import Message +from app.schemas.message import Message from app.schemas.types import NotificationChannel, MediaType diff --git a/app/application/music/__init__.py b/app/application/music/__init__.py new file mode 100644 index 000000000..6b846ffcf --- /dev/null +++ b/app/application/music/__init__.py @@ -0,0 +1 @@ +"""音乐应用服务。""" diff --git a/app/application/music/catalog.py b/app/application/music/catalog.py new file mode 100644 index 000000000..c048594e0 --- /dev/null +++ b/app/application/music/catalog.py @@ -0,0 +1,128 @@ +"""多来源音乐目录搜索应用服务。""" + +import asyncio +from typing import Any, Callable, Iterable, Optional + +from app.domain.context import MusicInfo +from app.domain.meta.metamusic import MetaMusic +from app.schemas.media import normalize_media_source +from app.schemas.types import MediaSource, MediaSourceSelection + + +class MusicCatalogService: + """编排音乐来源选择、容错搜索和候选归一化。""" + + def __init__( + self, + source_resolver: Callable[[MediaSource], Any], + warning: Callable[[str], None], + primary_source: MediaSource = MediaSource.MusicBrainz, + ) -> None: + """注入来源解析器、告警输出和默认音乐来源。""" + self._source_resolver = source_resolver + self._warning = warning + self._primary_source = primary_source + + def search_sources( + self, + media_source: Optional[MediaSourceSelection], + ) -> list[MediaSource]: + """解析有序音乐来源,保留合法插件扩展来源并去重。""" + if not media_source: + return [self._primary_source] + raw_sources = ( + (media_source,) + if isinstance(media_source, MediaSource) + else media_source + ) + sources = [] + for raw_source in raw_sources: + source = normalize_media_source(raw_source) + if source and source not in sources: + sources.append(source) + return sources + + @staticmethod + def normalize_candidates( + candidates: Optional[Iterable[MusicInfo | dict[str, Any]]], + limit: Optional[int] = None, + ) -> list[MusicInfo]: + """标准化并按来源身份或元数据去重音乐候选。""" + results = [] + identities = set() + for candidate in candidates or []: + info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate) + if info.media_source and info.media_id: + identity = ( + "id", + str(info.media_source).casefold(), + str(info.music_type).casefold(), + str(info.media_id).casefold(), + ) + else: + identity = ( + "metadata", + str(info.music_type).casefold(), + MetaMusic.compact_text(info.title), + MetaMusic.compact_text(info.artist), + MetaMusic.compact_text(info.album), + ) + if identity in identities: + continue + identities.add(identity) + results.append(info) + if limit and len(results) >= limit: + break + return results + + def search( + self, + query: str, + limit: int = 20, + media_source: Optional[MediaSourceSelection] = None, + ) -> list[MusicInfo]: + """顺序搜索一个或多个音乐来源,隔离单一来源失败。""" + meta = MetaMusic.parse_query(query) + candidates = [] + for source in self.search_sources(media_source): + chain = self._source_resolver(source) + if not chain: + continue + try: + candidates.extend(chain.search_music(meta, limit=limit)) + except Exception as error: + self._warning(f"音乐来源 {source} 搜索失败:{str(error)}") + return self.normalize_candidates(candidates, limit=limit) + + async def async_search( + self, + query: str, + limit: int = 20, + media_source: Optional[MediaSourceSelection] = None, + ) -> list[MusicInfo]: + """并行搜索一个或多个音乐来源,隔离单一来源失败。""" + meta = MetaMusic.parse_query(query) + searches = [] + for source in self.search_sources(media_source): + chain = self._source_resolver(source) + if chain: + searches.append(self._async_search_source(chain, source, meta, limit)) + source_results = await asyncio.gather(*searches) if searches else [] + return self.normalize_candidates( + [candidate for results in source_results for candidate in results], + limit=limit, + ) + + async def _async_search_source( + self, + chain: Any, + source: MediaSource, + meta: MetaMusic, + limit: int, + ) -> list[MusicInfo]: + """异步搜索单个来源,并把异常降级为空候选。""" + try: + return await chain.async_search_music(meta, limit=limit) + except Exception as error: + self._warning(f"音乐来源 {source} 搜索失败:{str(error)}") + return [] diff --git a/app/application/notification.py b/app/application/notification.py index c0c73a06e..2517f9a29 100644 --- a/app/application/notification.py +++ b/app/application/notification.py @@ -1,7 +1,8 @@ from typing import Optional from app.runtime.extensions.service_registry import ServiceBaseHelper -from app.schemas import NotificationConf, ServiceInfo +from app.schemas.system import NotificationConf +from app.schemas.system import ServiceInfo from app.schemas.types import ModuleType, SystemConfigKey diff --git a/app/application/plugin/__init__.py b/app/application/plugin/__init__.py new file mode 100644 index 000000000..dece9412d --- /dev/null +++ b/app/application/plugin/__init__.py @@ -0,0 +1 @@ +"""插件应用端口与用例。""" diff --git a/app/application/plugin/catalog.py b/app/application/plugin/catalog.py new file mode 100644 index 000000000..c2315aec0 --- /dev/null +++ b/app/application/plugin/catalog.py @@ -0,0 +1,275 @@ +"""插件市场目录应用服务。""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +from collections.abc import Awaitable, Callable +from typing import Any, Optional + + +MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]] +AsyncMarketLoader = Callable[ + [str, Optional[str], bool], + Awaitable[Optional[dict[str, dict]]], +] +PluginMapper = Callable[[str, dict, str, list[str], int, Optional[str]], Any] +ProgressCallback = Callable[..., Any] + + +class PluginCatalogService: + """负责插件市场索引映射、并发收集、代际合并和来源去重。""" + + def __init__( + self, + *, + market_loader: MarketLoader, + async_market_loader: AsyncMarketLoader, + installed_plugins_provider: Callable[[], list[str]], + plugin_mapper: PluginMapper, + is_local_repo: Callable[[Optional[str]], bool], + version_compare: Callable[[str, str, str], bool], + warning: Callable[[str], Any], + error: Callable[[str], Any], + ) -> None: + """保存市场读取、插件映射和版本比较端口。""" + self._market_loader = market_loader + self._async_market_loader = async_market_loader + self._installed_plugins_provider = installed_plugins_provider + self._plugin_mapper = plugin_mapper + self._is_local_repo = is_local_repo + self._version_compare = version_compare + self._warning = warning + self._error = error + + def load( + self, + market: str, + package_version: Optional[str] = None, + force: bool = False, + ) -> list[Any]: + """同步读取并映射指定市场和插件代际。""" + if not market: + return [] + online_plugins = self._market_loader(market, package_version, force) + if online_plugins is None: + self._warning( + f"获取{package_version if package_version else ''}插件库失败:" + f"{market},请检查 GitHub 网络连接" + ) + return [] + return self._map_plugins(online_plugins, market, package_version) + + async def async_load( + self, + market: str, + package_version: Optional[str] = None, + force: bool = False, + ) -> list[Any]: + """异步读取并映射指定市场和插件代际。""" + if not market: + return [] + online_plugins = await self._async_market_loader( + market, + package_version, + force, + ) + if online_plugins is None: + self._warning( + f"获取{package_version if package_version else ''}插件库失败:" + f"{market},请检查 GitHub 网络连接" + ) + return [] + return self._map_plugins(online_plugins, market, package_version) + + def collect( + self, + *, + markets: list[str], + compatible_flags: list[str], + force: bool, + loader: Callable[[str, Optional[str], bool], list[Any]], + ) -> list[Any]: + """并发读取多个市场和代际,并按稳定优先级合并。""" + with concurrent.futures.ThreadPoolExecutor() as executor: + futures_meta: dict[ + concurrent.futures.Future, + tuple[int, bool, int], + ] = {} + for market_index, market in enumerate(markets): + base_future = executor.submit(loader, market, None, force) + futures_meta[base_future] = (market_index, False, 0) + for flag_priority, flag in enumerate(compatible_flags): + higher_future = executor.submit(loader, market, flag, force) + futures_meta[higher_future] = ( + market_index, + True, + flag_priority, + ) + + collected = [] + for future in concurrent.futures.as_completed(futures_meta): + plugins = future.result() + market_index, is_higher, flag_priority = futures_meta[future] + collected.append(( + market_index, + is_higher, + flag_priority, + plugins or [], + )) + + collected.sort(key=lambda item: (item[0], 0 if item[1] else 1, item[2])) + higher_plugins = [] + base_plugins = [] + for _market_index, is_higher, _flag_priority, plugins in collected: + (higher_plugins if is_higher else base_plugins).extend(plugins) + return self.merge(higher_plugins, base_plugins, markets) + + async def async_collect( + self, + *, + markets: list[str], + compatible_flags: list[str], + force: bool, + loader: Callable[ + [str, Optional[str], bool], + Awaitable[list[Any]], + ], + progress_callback: Optional[ProgressCallback] = None, + ) -> list[Any]: + """异步读取多个市场和代际,并持续报告稳定进度。""" + async def fetch( + market: str, + package_version: Optional[str], + result_version: str, + task_index: int, + ) -> tuple[int, str, list[Any]]: + """读取一个市场代际并保留创建时的稳定任务序号。""" + plugins = await loader(market, package_version, force) + return task_index, result_version, plugins or [] + + tasks = [] + for market in markets: + tasks.append(asyncio.create_task( + fetch(market, None, "base_version", len(tasks)) + )) + for flag in compatible_flags: + tasks.append(asyncio.create_task( + fetch(market, flag, "higher_version", len(tasks)) + )) + + higher_plugins = [] + base_plugins = [] + if tasks: + total_tasks = len(tasks) + finished_tasks = 0 + task_results = {} + if progress_callback: + progress_callback( + value=0, + text=f"开始刷新插件市场,共 {total_tasks} 个请求 ...", + data={"total": total_tasks, "finished": 0}, + ) + for completed_task in asyncio.as_completed(tasks): + try: + task_index, version, plugins = await completed_task + task_results[task_index] = (version, plugins) + except Exception as err: + self._error(f"获取插件市场数据失败:{str(err)}") + finished_tasks += 1 + if progress_callback: + progress_callback( + value=finished_tasks / total_tasks * 100, + text=( + f"插件市场请求({finished_tasks}/{total_tasks})" + "处理完成" + ), + data={"total": total_tasks, "finished": finished_tasks}, + ) + for task_index in sorted(task_results): + version, plugins = task_results[task_index] + (higher_plugins if version == "higher_version" else base_plugins).extend( + plugins + ) + + result = self.merge(higher_plugins, base_plugins, markets) + if progress_callback: + progress_callback(value=100, text="插件市场缓存刷新完成") + return result + + def merge( + self, + higher_plugins: list[Any], + base_plugins: list[Any], + markets: list[str], + ) -> list[Any]: + """按代际、来源顺序和版本合并插件目录。""" + all_plugins = list(higher_plugins) + higher_keys = { + f"{plugin.id}{plugin.plugin_version}" + for plugin in higher_plugins + } + all_plugins.extend( + plugin + for plugin in base_plugins + if f"{plugin.id}{plugin.plugin_version}" not in higher_keys + ) + + def repo_order(plugin: Any) -> int: + """本地来源排在远程市场之后,远程来源保持配置顺序。""" + if self._is_local_repo(plugin.repo_url): + return len(markets) + 1 + if plugin.repo_url in markets: + return markets.index(plugin.repo_url) + return len(markets) + + deduplicated = {} + for plugin in sorted(all_plugins, key=repo_order): + key = f"{plugin.id}{plugin.plugin_version}" + exists = deduplicated.get(key) + if not exists or ( + self._is_local_repo(exists.repo_url) + and not self._is_local_repo(plugin.repo_url) + ): + deduplicated[key] = plugin + + result_by_id = {} + for plugin in sorted(deduplicated.values(), key=repo_order): + exists = result_by_id.get(plugin.id) + if not exists \ + or self._version_compare( + plugin.plugin_version, + ">", + exists.plugin_version, + ) \ + or ( + plugin.plugin_version == exists.plugin_version + and self._is_local_repo(exists.repo_url) + and not self._is_local_repo(plugin.repo_url) + ): + result_by_id[plugin.id] = plugin + return list(result_by_id.values()) + + def _map_plugins( + self, + online_plugins: dict[str, dict], + market: str, + package_version: Optional[str], + ) -> list[Any]: + """把一个市场索引映射为宿主插件 DTO。""" + installed_plugins = self._installed_plugins_provider() + result = [] + add_time = len(online_plugins) + for plugin_id, plugin_info in online_plugins.items(): + plugin = self._plugin_mapper( + plugin_id, + plugin_info, + market, + installed_plugins, + add_time, + package_version, + ) + if plugin: + result.append(plugin) + add_time -= 1 + return result diff --git a/app/application/plugin/config.py b/app/application/plugin/config.py new file mode 100644 index 000000000..8c4e94d81 --- /dev/null +++ b/app/application/plugin/config.py @@ -0,0 +1,59 @@ +"""插件配置保存、重置和运行态重建应用用例。""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, slots=True) +class PluginConfigResult: + """描述插件配置写操作是否成功及提示信息。""" + + success: bool + message: str = "" + + +class PluginConfigCommand: + """协调插件配置持久化、实例初始化和运行时注册刷新。""" + + def __init__( + self, + *, + save_config: Callable[[str, dict, bool], bool], + initialize: Callable[[str, dict], Any], + stop: Callable[[str], Any], + delete_config: Callable[[str, bool], bool], + delete_data: Callable[[str, bool], bool], + reload_runtime: Callable[[str], Any], + publish_reset: Callable[[str], Any], + refresh_registrations: Callable[[str], Any], + ) -> None: + """保存插件管理 Facade 和运行时注册刷新端口。""" + self._save_config = save_config + self._initialize = initialize + self._stop = stop + self._delete_config = delete_config + self._delete_data = delete_data + self._reload_runtime = reload_runtime + self._publish_reset = publish_reset + self._refresh_registrations = refresh_registrations + + def update(self, plugin_id: str, config: dict) -> PluginConfigResult: + """保存配置并按既有顺序重新初始化实例及运行时注册。""" + if not self._save_config(plugin_id, config, False): + return PluginConfigResult(False, "插件配置保存失败") + self._initialize(plugin_id, config) + self._refresh_registrations(plugin_id) + return PluginConfigResult(True) + + def reset(self, plugin_id: str) -> PluginConfigResult: + """通知插件补偿后停止实例、删除配置数据并重建运行态。""" + self._publish_reset(plugin_id) + self._stop(plugin_id) + self._delete_config(plugin_id, True) + self._delete_data(plugin_id, True) + self._reload_runtime(plugin_id) + self._refresh_registrations(plugin_id) + return PluginConfigResult(True) diff --git a/app/application/plugin/install.py b/app/application/plugin/install.py new file mode 100644 index 000000000..9966e8a2c --- /dev/null +++ b/app/application/plugin/install.py @@ -0,0 +1,384 @@ +"""插件安装应用用例。""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any, Optional + + +InstalledPluginsReader = Callable[[], list[str]] +InstalledPluginsWriter = Callable[[list[str]], Awaitable[object]] +PluginIdsProvider = Callable[[], list[str]] +CompatibilityChecker = Callable[[str, str], Awaitable[Optional[str]]] +PackageInstaller = Callable[ + [str, str, Optional[str], bool], + Awaitable[tuple[bool, str]], +] +PackageCheckpointer = Callable[[str], Awaitable[Any]] +PackageCheckpointAction = Callable[[Any], Awaitable[object]] +InstallReporter = Callable[[str, Optional[str]], Awaitable[object]] +PluginReloader = Callable[[str], Awaitable[object]] +PluginRegistrationRefresher = Callable[[str], Awaitable[object]] + + +@dataclass(frozen=True, slots=True) +class PluginInstallRollback: + """描述失败安装中各类可补偿副作用的恢复结果。""" + + file_attempted: bool = False + file_restored: bool = False + installed_list_attempted: bool = False + installed_list_restored: bool = False + runtime_attempted: bool = False + runtime_restored: bool = False + registrations_attempted: bool = False + registrations_restored: bool = False + dependency_supported: bool = False + errors: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class PluginInstallResult: + """描述插件安装结果、失败阶段和可观察补偿状态。""" + + success: bool + message: str = "" + refreshed_only: bool = False + package_installed: bool = False + installed_list_persisted: bool = False + runtime_reloaded: bool = False + registrations_refreshed: bool = False + reported: bool = False + report_error: str = "" + failure_stage: Optional[str] = None + checkpoint_cleanup_error: str = "" + rollback: PluginInstallRollback = field(default_factory=PluginInstallRollback) + + +class PluginInstallCommand: + """协调插件检查、包事务、持久化、运行态刷新和安装上报。""" + + def __init__( + self, + *, + installed_plugins_reader: InstalledPluginsReader, + installed_plugins_writer: InstalledPluginsWriter, + plugin_ids_provider: PluginIdsProvider, + compatibility_checker: CompatibilityChecker, + package_installer: PackageInstaller, + package_checkpointer: PackageCheckpointer, + package_committer: PackageCheckpointAction, + package_rollback: PackageCheckpointAction, + install_reporter: InstallReporter, + plugin_reloader: PluginReloader, + registration_refresher: PluginRegistrationRefresher, + ) -> None: + """保存安装用例所需端口,不绑定数据库、网络或运行时实现。""" + self._installed_plugins_reader = installed_plugins_reader + self._installed_plugins_writer = installed_plugins_writer + self._plugin_ids_provider = plugin_ids_provider + self._compatibility_checker = compatibility_checker + self._package_installer = package_installer + self._package_checkpointer = package_checkpointer + self._package_committer = package_committer + self._package_rollback = package_rollback + self._install_reporter = install_reporter + self._plugin_reloader = plugin_reloader + self._registration_refresher = registration_refresher + + async def execute( + self, + *, + plugin_id: str, + repo_url: Optional[str], + release_version: Optional[str] = None, + force: bool = False, + ) -> PluginInstallResult: + """执行插件安装,并在关键阶段失败时恢复可补偿状态。""" + installed_plugins = list(self._installed_plugins_reader() or []) + refreshed_only = not force and plugin_id in self._plugin_ids_provider() + if refreshed_only: + return await self._refresh_existing( + plugin_id=plugin_id, + repo_url=repo_url, + ) + if not repo_url: + return PluginInstallResult( + success=False, + message="没有传入仓库地址,无法正确安装插件,请检查配置", + failure_stage="validation", + ) + + try: + checkpoint = await self._package_checkpointer(plugin_id) + except Exception as err: + return PluginInstallResult( + success=False, + message=f"创建插件安装快照失败:{err}", + failure_stage="package_checkpoint", + ) + + try: + state, message = await self._package_installer( + plugin_id, + repo_url, + release_version, + force, + ) + except Exception as err: + return await self._failure( + plugin_id=plugin_id, + original_plugins=installed_plugins, + checkpoint=checkpoint, + stage="package_install", + message=str(err), + package_installed=False, + ) + if not state: + return await self._failure( + plugin_id=plugin_id, + original_plugins=installed_plugins, + checkpoint=checkpoint, + stage="package_install", + message=message, + package_installed=False, + ) + + installed_list_persisted = False + if plugin_id not in installed_plugins: + updated_plugins = [*installed_plugins, plugin_id] + try: + await self._installed_plugins_writer(updated_plugins) + installed_list_persisted = True + except Exception as err: + return await self._failure( + plugin_id=plugin_id, + original_plugins=installed_plugins, + checkpoint=checkpoint, + stage="installed_list_persistence", + message=str(err), + package_installed=True, + ) + + try: + await self._plugin_reloader(plugin_id) + except Exception as err: + return await self._failure( + plugin_id=plugin_id, + original_plugins=installed_plugins, + checkpoint=checkpoint, + stage="runtime_reload", + message=str(err), + package_installed=True, + installed_list_persisted=installed_list_persisted, + runtime_touched=True, + ) + + try: + await self._registration_refresher(plugin_id) + except Exception as err: + return await self._failure( + plugin_id=plugin_id, + original_plugins=installed_plugins, + checkpoint=checkpoint, + stage="registration_refresh", + message=str(err), + package_installed=True, + installed_list_persisted=installed_list_persisted, + runtime_touched=True, + registrations_touched=True, + ) + + checkpoint_cleanup_error = "" + try: + await self._package_committer(checkpoint) + except Exception as err: + checkpoint_cleanup_error = str(err) + + reported = False + report_error = "" + try: + report_result = await self._install_reporter(plugin_id, repo_url) + reported = report_result is not False + if not reported: + report_error = "安装上报未确认" + except Exception as err: + report_error = str(err) + + result_message = message or "插件安装成功" + if checkpoint_cleanup_error: + result_message = f"{result_message};临时安装快照清理失败" + if report_error: + result_message = f"{result_message};安装上报失败,不影响本地安装" + return PluginInstallResult( + success=True, + message=result_message, + package_installed=True, + installed_list_persisted=installed_list_persisted, + runtime_reloaded=True, + registrations_refreshed=True, + reported=reported, + report_error=report_error, + checkpoint_cleanup_error=checkpoint_cleanup_error, + ) + + async def _refresh_existing( + self, + *, + plugin_id: str, + repo_url: Optional[str], + ) -> PluginInstallResult: + """刷新已存在插件,不触碰包文件和已安装列表。""" + if repo_url: + compatible_message = await self._compatibility_checker( + plugin_id, + repo_url, + ) + if compatible_message: + return PluginInstallResult( + success=False, + message=compatible_message, + refreshed_only=True, + failure_stage="compatibility", + ) + failure_stage = "runtime_reload" + try: + await self._plugin_reloader(plugin_id) + failure_stage = "registration_refresh" + await self._registration_refresher(plugin_id) + except Exception as err: + rollback_errors = [] + runtime_restored = False + registrations_restored = False + try: + await self._plugin_reloader(plugin_id) + runtime_restored = True + except Exception as rollback_err: + rollback_errors.append(f"运行态恢复失败:{rollback_err}") + if runtime_restored: + try: + await self._registration_refresher(plugin_id) + registrations_restored = True + except Exception as rollback_err: + rollback_errors.append(f"路由和服务注册恢复失败:{rollback_err}") + return PluginInstallResult( + success=False, + message=f"刷新插件运行态失败:{err}", + refreshed_only=True, + failure_stage=failure_stage, + rollback=PluginInstallRollback( + runtime_attempted=True, + runtime_restored=runtime_restored, + registrations_attempted=True, + registrations_restored=registrations_restored, + errors=tuple(rollback_errors), + ), + ) + + reported = False + report_error = "" + try: + report_result = await self._install_reporter(plugin_id, repo_url) + reported = report_result is not False + if not reported: + report_error = "安装上报未确认" + except Exception as err: + report_error = str(err) + return PluginInstallResult( + success=True, + message=( + "插件已存在,已刷新加载" + if not report_error + else "插件已存在,已刷新加载;安装上报失败,不影响本地刷新" + ), + refreshed_only=True, + runtime_reloaded=True, + registrations_refreshed=True, + reported=reported, + report_error=report_error, + ) + + async def _failure( + self, + *, + plugin_id: str, + original_plugins: list[str], + checkpoint: Any, + stage: str, + message: str, + package_installed: bool, + installed_list_persisted: bool = False, + runtime_touched: bool = False, + registrations_touched: bool = False, + ) -> PluginInstallResult: + """按持久化、文件、运行态顺序补偿失败安装并记录结果。""" + errors = [] + installed_list_restored = False + if installed_list_persisted: + try: + await self._installed_plugins_writer(list(original_plugins)) + installed_list_restored = True + except Exception as err: + errors.append(f"已安装列表恢复失败:{err}") + + file_restored = False + try: + await self._package_rollback(checkpoint) + file_restored = True + except Exception as err: + errors.append(f"插件文件恢复失败:{err}") + + runtime_restored = False + registrations_restored = False + if runtime_touched: + try: + await self._plugin_reloader(plugin_id) + runtime_restored = True + except Exception as err: + errors.append(f"插件运行态恢复失败:{err}") + if runtime_restored: + try: + await self._registration_refresher(plugin_id) + registrations_restored = True + except Exception as err: + errors.append(f"插件路由和服务注册恢复失败:{err}") + + rollback = PluginInstallRollback( + file_attempted=True, + file_restored=file_restored, + installed_list_attempted=installed_list_persisted, + installed_list_restored=installed_list_restored, + runtime_attempted=runtime_touched, + runtime_restored=runtime_restored, + registrations_attempted=runtime_touched or registrations_touched, + registrations_restored=registrations_restored, + dependency_supported=False, + errors=tuple(errors), + ) + rollback_message = [] + rollback_message.append("插件文件已恢复" if file_restored else "插件文件恢复失败") + if installed_list_persisted: + rollback_message.append( + "已安装列表已恢复" + if installed_list_restored + else "已安装列表恢复失败" + ) + if runtime_touched: + rollback_message.append( + "旧运行态已恢复" if runtime_restored else "旧运行态恢复失败" + ) + rollback_message.append( + "旧路由和服务注册已恢复" + if registrations_restored + else "旧路由和服务注册恢复失败" + ) + rollback_message.append("Python依赖变更不支持自动回滚") + return PluginInstallResult( + success=False, + message=f"{message};{';'.join(rollback_message)}", + package_installed=package_installed, + installed_list_persisted=installed_list_persisted, + failure_stage=stage, + rollback=rollback, + ) diff --git a/app/application/plugin/routes.py b/app/application/plugin/routes.py new file mode 100644 index 000000000..a02b08e6f --- /dev/null +++ b/app/application/plugin/routes.py @@ -0,0 +1,15 @@ +"""动态插件路由应用端口。""" + +from typing import Optional, Protocol + + +class DynamicRouteRegistry(Protocol): + """插件生命周期操作动态 HTTP 路由所需的最小端口。""" + + def update(self, plugin_id: Optional[str], action: str) -> None: + """新增或移除指定插件的动态路由。""" + ... + + def remove(self, plugin_id: str) -> bool: + """移除指定插件的全部动态路由。""" + ... diff --git a/app/application/plugins.py b/app/application/plugins.py index 178118e21..1b98dcc1f 100644 --- a/app/application/plugins.py +++ b/app/application/plugins.py @@ -11,8 +11,9 @@ FastAPI 实例由组合根(factory 创建应用后)注入,端点与 Agent from typing import Optional -from fastapi import Depends, FastAPI +from fastapi import FastAPI +from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry from app.application.security.access import verify_apikey, verify_token from app.db.oper.systemconfig import SystemConfigOper from app.runtime.config import settings @@ -45,7 +46,21 @@ def get_api_app() -> FastAPI: return _api_app -def register_plugin_api(plugin_id: Optional[str] = None): +def _route_registry() -> FastAPIDynamicRouteRegistry: + """组装绑定当前 FastAPI 应用与插件管理器的动态路由适配器。""" + return FastAPIDynamicRouteRegistry( + app=get_api_app(), + plugin_ids=lambda: PluginManager().get_running_plugin_ids(), + plugin_apis=lambda plugin_id: PluginManager().get_plugin_apis(plugin_id), + verify_token=verify_token, + verify_apikey=verify_apikey, + prefix=PLUGIN_PREFIX, + protected_routes=PROTECTED_ROUTES, + log=logger, + ) + + +def register_plugin_api(plugin_id: Optional[str] = None) -> None: """ 动态注册插件 API :param plugin_id: 插件 ID,如果为 None,则注册所有插件 @@ -53,7 +68,7 @@ def register_plugin_api(plugin_id: Optional[str] = None): _update_plugin_api_routes(plugin_id, action="add") -def remove_plugin_api(plugin_id: str): +def remove_plugin_api(plugin_id: str) -> None: """ 动态移除单个插件的 API :param plugin_id: 插件 ID @@ -61,55 +76,14 @@ def remove_plugin_api(plugin_id: str): _update_plugin_api_routes(plugin_id, action="remove") -def _update_plugin_api_routes(plugin_id: Optional[str], action: str): +def _update_plugin_api_routes(plugin_id: Optional[str], action: str) -> None: """ 插件 API 路由注册和移除 :param plugin_id: 插件 ID,如果 action 为 "add" 且 plugin_id 为 None,则处理所有插件 如果 action 为 "remove",plugin_id 必须是有效的插件 ID :param action: "add" 或 "remove",决定是添加还是移除路由 """ - if action not in {"add", "remove"}: - raise ValueError("Action must be 'add' or 'remove'") - - app = get_api_app() - is_modified = False - existing_paths = {route.path: route for route in app.routes} - - plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids() - for plugin_id in plugin_ids: - routes_removed = _remove_routes(plugin_id) - if routes_removed: - is_modified = True - - if action != "add": - continue - # 获取插件的 API 路由信息 - plugin_apis = PluginManager().get_plugin_apis(plugin_id) - for api in plugin_apis: - api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}" - try: - api["path"] = api_path - allow_anonymous = api.pop("allow_anonymous", False) - auth_mode = api.pop("auth", "apikey") - dependencies = api.setdefault("dependencies", []) - if not allow_anonymous: - if ( - auth_mode == "bear" - and Depends(verify_token) not in dependencies - ): - dependencies.append(Depends(verify_token)) - elif Depends(verify_apikey) not in dependencies: - dependencies.append(Depends(verify_apikey)) - app.add_api_route(**api, tags=["plugin"]) - is_modified = True - logger.debug(f"Added plugin route: {api_path}") - except Exception as e: - logger.error(f"Error adding plugin route {api_path}: {str(e)}") - - if is_modified: - _clean_protected_routes(existing_paths) - app.openapi_schema = None - app.setup() + _route_registry().update(plugin_id, action) def _remove_routes(plugin_id: str) -> bool: @@ -118,37 +92,15 @@ def _remove_routes(plugin_id: str) -> bool: :param plugin_id: 插件 ID :return: 是否有路由被移除 """ - if not plugin_id: - return False - app = get_api_app() - prefix = f"{PLUGIN_PREFIX}/{plugin_id}/" - routes_to_remove = [ - route for route in app.routes if route.path.startswith(prefix) - ] - removed = False - for route in routes_to_remove: - try: - app.routes.remove(route) - removed = True - logger.debug(f"Removed plugin route: {route.path}") - except Exception as e: - logger.error(f"Error removing plugin route {route.path}: {str(e)}") - return removed + return _route_registry().remove(plugin_id) -def _clean_protected_routes(existing_paths: dict): +def _clean_protected_routes(existing_paths: dict) -> None: """ 清理受保护的路由,防止在插件操作中被删除或重复添加 :param existing_paths: 当前应用的路由路径映射 """ - app = get_api_app() - for protected_route in PROTECTED_ROUTES: - try: - existing_route = existing_paths.get(protected_route) - if existing_route: - app.routes.remove(existing_route) - except Exception as e: - logger.error(f"Error removing protected route {protected_route}: {str(e)}") + _route_registry().clean(existing_paths) def remove_plugin_from_folders(plugin_id: str): diff --git a/app/application/rss.py b/app/application/rss.py index 3c01d0556..c5de5471f 100644 --- a/app/application/rss.py +++ b/app/application/rss.py @@ -1,4 +1,3 @@ -import re import traceback from typing import List, Tuple, Union, Optional from urllib.parse import urljoin, urlparse diff --git a/app/application/rules.py b/app/application/rules.py index e51f4c6a8..87ac9ac30 100644 --- a/app/application/rules.py +++ b/app/application/rules.py @@ -11,7 +11,8 @@ from pyparsing import Forward, Literal, Word, alphas, infix_notation, opAssoc, a from app.adapters.system import rust as rust_accel from app.db.oper.systemconfig import SystemConfigOper from app.domain.context import MediaInfo -from app.schemas import CustomRule, FilterRuleGroup +from app.schemas.rule import CustomRule +from app.schemas.system import FilterRuleGroup from app.schemas.types import SystemConfigKey diff --git a/app/application/search/__init__.py b/app/application/search/__init__.py new file mode 100644 index 000000000..a01408d28 --- /dev/null +++ b/app/application/search/__init__.py @@ -0,0 +1 @@ +"""搜索应用服务。""" diff --git a/app/application/search/state.py b/app/application/search/state.py new file mode 100644 index 000000000..277dac050 --- /dev/null +++ b/app/application/search/state.py @@ -0,0 +1,135 @@ +"""搜索参数与结果缓存的应用服务。""" + +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from app.schemas.media import parse_media_key, resolve_media_identity +from app.schemas.types import MediaSource, MediaType + + +def stringify_sites(sites: Optional[List[int]]) -> str: + """将站点 ID 列表转换为前端可复用的逗号分隔值。""" + return ",".join(str(site) for site in sites) if sites else "" + + +def normalize_search_params( + params: Optional[Dict[str, Any]], +) -> Optional[Dict[str, str]]: + """把搜索缓存归一为前端重新搜索使用的稳定字段。""" + if not isinstance(params, dict): + return None + + media_source, media_id = resolve_media_identity( + media_source=params.get("media_source"), + media_id=params.get("media_id"), + ) + keyword = str(params.get("keyword") or "") + if not media_source and keyword: + media_source, media_id = parse_media_key(keyword) + if media_source and media_id: + keyword = "" + + normalized = { + "keyword": keyword, + "media_source": str(media_source) if media_source else "", + "media_id": media_id or "", + "type": str(params.get("type") or ""), + "area": str(params.get("area") or ""), + "title": str(params.get("title") or ""), + "year": str(params.get("year") or ""), + "season": str(params["season"]) if params.get("season") is not None else "", + "episode": str(params.get("episode") or ""), + "sites": str(params.get("sites") or ""), + "result_type": str(params.get("result_type") or "torrent"), + } + if params.get("music_type"): + normalized["music_type"] = str(params["music_type"]) + return normalized if normalized["keyword"] or media_id else None + + +class SearchStateService: + """通过注入的缓存端口保存和读取搜索状态。""" + + def __init__( + self, + save_cache: Callable[[Any, str], None], + load_cache: Callable[[str], Any], + async_save_cache: Callable[[Any, str], Awaitable[None]], + async_load_cache: Callable[[str], Awaitable[Any]], + params_key: str, + result_key: str, + subtitle_result_key: str, + ) -> None: + """保存缓存端口和兼容缓存键。""" + self._save_cache = save_cache + self._load_cache = load_cache + self._async_save_cache = async_save_cache + self._async_load_cache = async_load_cache + self._params_key = params_key + self._result_key = result_key + self._subtitle_result_key = subtitle_result_key + + @staticmethod + def build_params( + *, + keyword: Optional[str] = None, + media_source: Optional[MediaSource] = None, + media_id: Optional[str] = None, + mtype: Optional[MediaType] = None, + area: Optional[str] = "title", + title: Optional[str] = None, + year: Optional[str] = None, + season: Optional[int] = None, + episode: Optional[int] = None, + sites: Optional[List[int]] = None, + music_type: Optional[str] = None, + result_type: Optional[str] = "torrent", + ) -> Optional[Dict[str, str]]: + """把公开搜索参数构造成可持久化的兼容字典。""" + return normalize_search_params( + { + "keyword": keyword, + "media_source": media_source, + "media_id": media_id, + "type": mtype.value if isinstance(mtype, MediaType) else mtype, + "area": area, + "title": title, + "year": year, + "season": season, + "episode": episode, + "sites": stringify_sites(sites), + "music_type": music_type, + "result_type": result_type or "torrent", + } + ) + + def save_params(self, **kwargs: Any) -> None: + """同步保存最后一次有效搜索参数。""" + params = self.build_params(**kwargs) + if params: + self._save_cache(params, self._params_key) + + async def async_save_params(self, **kwargs: Any) -> None: + """异步保存最后一次有效搜索参数。""" + params = self.build_params(**kwargs) + if params: + await self._async_save_cache(params, self._params_key) + + def load_params(self) -> Optional[Dict[str, str]]: + """同步读取并归一化最后一次搜索参数。""" + return normalize_search_params(self._load_cache(self._params_key)) + + async def async_load_params(self) -> Optional[Dict[str, str]]: + """异步读取并归一化最后一次搜索参数。""" + return normalize_search_params(await self._async_load_cache(self._params_key)) + + def load_results(self) -> Any: + """同步读取最后一次资源搜索结果。""" + return self._load_cache(self._result_key) + + async def async_load_results(self) -> Any: + """异步读取最后一次资源搜索结果。""" + return await self._async_load_cache(self._result_key) + + async def async_load_subtitle_results(self) -> Any: + """异步读取最后一次字幕搜索结果。""" + return await self._async_load_cache(self._subtitle_result_key) diff --git a/app/application/security/access.py b/app/application/security/access.py index be72f1ce0..bf3f905ca 100644 --- a/app/application/security/access.py +++ b/app/application/security/access.py @@ -15,7 +15,7 @@ from Crypto.Util.Padding import pad from cryptography.fernet import Fernet from fastapi import HTTPException, status, Security, Request, Response from fastapi.security import OAuth2PasswordBearer, APIKeyHeader, APIKeyQuery, APIKeyCookie, HTTPBearer -from app import schemas +from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.runtime.cache import cached from app.runtime.config import settings from app.runtime.log import logger @@ -23,7 +23,7 @@ from app.runtime.log import logger BCRYPT_PASSWORD_MAX_BYTES = 72 BCRYPT_ROUNDS = 12 ALGORITHM = "HS256" -SuperuserTokenPayloadProvider = Callable[[], schemas.TokenPayload] +SuperuserTokenPayloadProvider = Callable[[], _SchemaTokenPayload] _superuser_token_payload_provider: Optional[SuperuserTokenPayloadProvider] = None @@ -102,7 +102,7 @@ def __get_api_key( @cached(maxsize=1, ttl=600) -def __create_superuser_token_payload() -> schemas.TokenPayload: +def __create_superuser_token_payload() -> _SchemaTokenPayload: """ 创建管理员用户的TokenPayload @@ -164,7 +164,7 @@ def create_access_token( def set_or_refresh_resource_token_cookie( - request: Request, response: Response, payload: schemas.TokenPayload + request: Request, response: Response, payload: _SchemaTokenPayload ) -> None: """ 设置资源令牌 Cookie @@ -229,7 +229,7 @@ def set_or_refresh_resource_token_cookie( ) -def __verify_token(token: str, purpose: Optional[str] = "authentication") -> schemas.TokenPayload: +def __verify_token(token: str, purpose: Optional[str] = "authentication") -> _SchemaTokenPayload: """ 使用 JWT Token 进行身份认证并解析 Token 的内容 :param token: JWT 令牌 @@ -253,12 +253,12 @@ def __verify_token(token: str, purpose: Optional[str] = "authentication") -> sch token, secret_key, algorithms=[ALGORITHM] ) - token_payload = schemas.TokenPayload(**payload) + token_payload = _SchemaTokenPayload(**payload) if token_payload.purpose != purpose: raise jwt.InvalidTokenError("令牌用途不匹配") - return schemas.TokenPayload(**payload) + return _SchemaTokenPayload(**payload) except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -272,7 +272,7 @@ def verify_token( jwt_token: Annotated[str | None, Security(oauth2_scheme_manual_error)], api_key: Annotated[str | None, Security(__get_api_key)], api_token: Annotated[str | None, Security(__get_api_token)], -) -> schemas.TokenPayload: +) -> _SchemaTokenPayload: """ 验证 JWT 令牌并自动处理 resource_token 写入 @@ -310,7 +310,7 @@ def verify_token( def verify_resource_token( resource_token: Annotated[str, Security(resource_token_cookie)] -) -> schemas.TokenPayload: +) -> _SchemaTokenPayload: """ 验证资源访问令牌(从 Cookie 中获取) :param resource_token: 从 Cookie 中获取的资源访问令牌 diff --git a/app/application/security/auth.py b/app/application/security/auth.py index 240e3cd8a..4cbed36b8 100644 --- a/app/application/security/auth.py +++ b/app/application/security/auth.py @@ -6,7 +6,8 @@ from typing import Any, Optional from fastapi import HTTPException, status -from app import schemas +from app.schemas.token import Token as _SchemaToken +from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.application.security import access as security from app.runtime.config import settings from app.db.models.user import User @@ -118,7 +119,7 @@ def consume_plugin_auth_ticket(ticket: str) -> Optional[dict[str, Any]]: return AuthTicketStore().consume(ticket) -def build_superuser_token_payload() -> schemas.TokenPayload: +def build_superuser_token_payload() -> _SchemaTokenPayload: """从持久化用户和站点认证状态构造超级用户令牌载荷。""" user = UserOper().get_by_name(settings.SUPERUSER) if not user or not user.is_superuser: @@ -126,7 +127,7 @@ def build_superuser_token_payload() -> schemas.TokenPayload: status_code=status.HTTP_401_UNAUTHORIZED, detail="用户权限不足", ) - return schemas.TokenPayload( + return _SchemaTokenPayload( sub=user.id, username=user.name, super_user=user.is_superuser, @@ -135,7 +136,7 @@ def build_superuser_token_payload() -> schemas.TokenPayload: ) -def build_token_response(user: User) -> schemas.Token: +def build_token_response(user: User) -> _SchemaToken: """ 使用系统统一逻辑构造登录 Token 响应。 @@ -147,7 +148,7 @@ def build_token_response(user: User) -> schemas.Token: not SystemConfigOper().get(SystemConfigKey.SetupWizardState) and not settings.ADVANCED_MODE ) - return schemas.Token( + return _SchemaToken( access_token=security.create_access_token( userid=user.id, username=user.name, diff --git a/app/application/server/__init__.py b/app/application/server/__init__.py new file mode 100644 index 000000000..fed1e6f9b --- /dev/null +++ b/app/application/server/__init__.py @@ -0,0 +1 @@ +"""MoviePilot 中心服务应用用例。""" diff --git a/app/application/server/report.py b/app/application/server/report.py new file mode 100644 index 000000000..eded84acc --- /dev/null +++ b/app/application/server/report.py @@ -0,0 +1,134 @@ +"""中心服务存量上报用例。""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any, Optional + +from app.schemas.media import resolve_media_identity + + +class ServerReportService: + """协调本地订阅、插件清单和中心服务统计上报。""" + + SUBSCRIBE_FIELDS = frozenset({ + "name", "year", "type", "media_source", "media_id", "music_type", + "total_tracks", "genre_ids", "season", "poster", "backdrop", "vote", + "description", + }) + + def __init__( + self, + *, + config_reader: Callable[[Any], Any], + config_writer: Callable[[Any, Any], Any], + installed_plugins_provider: Callable[[], list[str]], + subscribes_provider: Callable[[], list[Any]], + plugin_report_sender: Callable[[list[dict]], Any], + async_plugin_report_sender: Callable[[list[dict]], Awaitable[Any]], + subscribe_report_sender: Callable[[list[dict]], Any], + repo_url_sanitizer: Callable[[Optional[str]], Optional[str]], + ) -> None: + """保存本地读取端口和只负责 I/O 的中心服务发送端口。""" + self._config_reader = config_reader + self._config_writer = config_writer + self._installed_plugins_provider = installed_plugins_provider + self._subscribes_provider = subscribes_provider + self._plugin_report_sender = plugin_report_sender + self._async_plugin_report_sender = async_plugin_report_sender + self._subscribe_report_sender = subscribe_report_sender + self._repo_url_sanitizer = repo_url_sanitizer + + def init_report( + self, + *, + enabled: bool, + state_key: Any, + reporter: Callable[[], bool], + ) -> None: + """首次成功上报后写入对应的完成标记。""" + if enabled and not self._config_reader(state_key) and reporter(): + self._config_writer(state_key, "1") + + def build_subscribe_payload(self, item: Optional[dict]) -> Optional[dict]: + """构造中心服务订阅统计载荷并移除本地运行字段。""" + if not isinstance(item, dict): + return None + media_source, media_id = resolve_media_identity(media=item) + if not media_source or not media_id: + return None + payload = { + key: value + for key, value in item.items() + if key in self.SUBSCRIBE_FIELDS + } + payload["media_source"] = str(media_source) + payload["media_id"] = media_id + return payload + + def build_plugin_payload( + self, + items: Optional[list[tuple[str, Optional[str]]]] = None, + ) -> list[dict[str, Any]]: + """构造插件安装统计载荷并脱敏本地仓库路径。""" + if items: + return [ + { + "plugin_id": plugin_id, + "repo_url": self._repo_url_sanitizer(repo_url), + } + for plugin_id, repo_url in items + if plugin_id + ] + return [ + {"plugin_id": plugin_id, "repo_url": None} + for plugin_id in self._installed_plugins_provider() + if plugin_id + ] + + def report_subscribes(self, *, enabled: bool) -> bool: + """上报当前全部有效订阅的公开统计字段。""" + if not enabled: + return False + subscribes = self._subscribes_provider() + if not subscribes: + return True + payloads = [ + payload + for subscribe in subscribes + if (payload := self.build_subscribe_payload(subscribe.to_dict())) + ] + if not payloads: + return True + response = self._subscribe_report_sender(payloads) + return bool(response is not None and response.status_code == 200) + + def report_plugins( + self, + *, + enabled: bool, + items: Optional[list[tuple[str, Optional[str]]]] = None, + ) -> bool: + """同步上报当前插件安装清单。""" + if not enabled: + return False + payload = self.build_plugin_payload(items) + if not payload: + return False + response = self._plugin_report_sender(payload) + return bool(response is not None and response.status_code == 200) + + async def async_report_plugins( + self, + *, + enabled: bool, + items: Optional[list[tuple[str, Optional[str]]]] = None, + ) -> bool: + """异步上报当前插件安装清单。""" + if not enabled: + return False + payload = self.build_plugin_payload(items) + if not payload: + return False + response = await self._async_plugin_report_sender(payload) + return bool(response is not None and response.status_code == 200) diff --git a/app/application/server/share.py b/app/application/server/share.py new file mode 100644 index 000000000..d1f468a57 --- /dev/null +++ b/app/application/server/share.py @@ -0,0 +1,201 @@ +"""中心服务订阅和工作流分享用例。""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from typing import Any, Optional + +from app.schemas.media import resolve_media_identity + + +class ServerSharingService: + """协调本地订阅、工作流读取与中心服务分享传输。""" + + SUBSCRIBE_FIELDS = frozenset({ + "share_title", "share_comment", "share_user", "share_uid", "name", + "year", "type", "keyword", "media_source", "media_id", "music_type", + "total_tracks", "season", "poster", "backdrop", "vote", "description", + "genre_ids", "include", "exclude", "quality", "resolution", "effect", + "total_episode", "custom_words", "media_category", "episode_group", + "date", + }) + + def __init__( + self, + *, + subscribe_provider: Callable[[int], Any], + async_subscribe_provider: Callable[[int], Awaitable[Any]], + workflow_provider: Callable[[int], Any], + async_workflow_provider: Callable[[int], Awaitable[Any]], + user_uuid_provider: Callable[[], str], + subscribe_sender: Callable[[dict], Any], + async_subscribe_sender: Callable[[dict], Awaitable[Any]], + workflow_sender: Callable[[dict], Any], + async_workflow_sender: Callable[[dict], Awaitable[Any]], + response_handler: Callable[[Any, Callable[[], None]], tuple[bool, str]], + subscribe_cache_clearer: Callable[[], None], + workflow_cache_clearer: Callable[[], None], + ) -> None: + """保存本地数据端口、中心服务传输端口和缓存失效端口。""" + self._subscribe_provider = subscribe_provider + self._async_subscribe_provider = async_subscribe_provider + self._workflow_provider = workflow_provider + self._async_workflow_provider = async_workflow_provider + self._user_uuid_provider = user_uuid_provider + self._subscribe_sender = subscribe_sender + self._async_subscribe_sender = async_subscribe_sender + self._workflow_sender = workflow_sender + self._async_workflow_sender = async_workflow_sender + self._response_handler = response_handler + self._subscribe_cache_clearer = subscribe_cache_clearer + self._workflow_cache_clearer = workflow_cache_clearer + + def build_subscribe_payload(self, item: Optional[dict]) -> Optional[dict]: + """构造订阅分享载荷并隔离本地字段和旧专用 ID。""" + if not isinstance(item, dict): + return None + media_source, media_id = resolve_media_identity(media=item) + if not media_source or not media_id: + return None + payload = { + key: value + for key, value in item.items() + if key in self.SUBSCRIBE_FIELDS + } + payload["media_source"] = str(media_source) + payload["media_id"] = media_id + return payload + + @staticmethod + def prepare_workflow(workflow: Any) -> dict: + """移除本地字段并把动作和流程编码为中心服务兼容格式。""" + workflow_dict = workflow.to_dict() + workflow_dict.pop("id", None) + workflow_dict.pop("context", None) + workflow_dict["actions"] = json.dumps(workflow_dict["actions"] or []) + workflow_dict["flows"] = json.dumps(workflow_dict["flows"] or []) + return workflow_dict + + @staticmethod + def validate_workflow(workflow: Any) -> tuple[bool, str]: + """验证工作流存在且同时包含动作与流程。""" + if not workflow: + return False, "工作流不存在" + if not workflow.actions or not workflow.flows: + return False, "请分享有动作和流程的工作流" + return True, "" + + def share_subscribe( + self, + *, + enabled: bool, + subscribe_id: int, + share_title: str, + share_comment: str, + share_user: str, + ) -> tuple[bool, str]: + """同步读取并分享指定订阅。""" + if not enabled: + return False, "当前没有开启订阅数据共享功能" + subscribe = self._subscribe_provider(subscribe_id) + if not subscribe: + return False, "订阅不存在" + payload = self.build_subscribe_payload({ + "share_title": share_title, + "share_comment": share_comment, + "share_user": share_user, + "share_uid": self._user_uuid_provider(), + **subscribe.to_dict(), + }) + if not payload: + return False, "订阅媒体身份不完整" + return self._response_handler( + self._subscribe_sender(payload), + self._subscribe_cache_clearer, + ) + + async def async_share_subscribe( + self, + *, + enabled: bool, + subscribe_id: int, + share_title: str, + share_comment: str, + share_user: str, + ) -> tuple[bool, str]: + """异步读取并分享指定订阅。""" + if not enabled: + return False, "当前没有开启订阅数据共享功能" + subscribe = await self._async_subscribe_provider(subscribe_id) + if not subscribe: + return False, "订阅不存在" + payload = self.build_subscribe_payload({ + "share_title": share_title, + "share_comment": share_comment, + "share_user": share_user, + "share_uid": self._user_uuid_provider(), + **subscribe.to_dict(), + }) + if not payload: + return False, "订阅媒体身份不完整" + return self._response_handler( + await self._async_subscribe_sender(payload), + self._subscribe_cache_clearer, + ) + + def share_workflow( + self, + *, + enabled: bool, + workflow_id: int, + share_title: str, + share_comment: str, + share_user: str, + ) -> tuple[bool, str]: + """同步读取并分享指定工作流。""" + if not enabled: + return False, "当前没有开启工作流数据共享功能" + workflow = self._workflow_provider(workflow_id) + valid, message = self.validate_workflow(workflow) + if not valid: + return False, message + payload = { + "share_title": share_title, + "share_comment": share_comment, + "share_user": share_user, + "share_uid": self._user_uuid_provider(), + **self.prepare_workflow(workflow), + } + return self._response_handler( + self._workflow_sender(payload), + self._workflow_cache_clearer, + ) + + async def async_share_workflow( + self, + *, + enabled: bool, + workflow_id: int, + share_title: str, + share_comment: str, + share_user: str, + ) -> tuple[bool, str]: + """异步读取并分享指定工作流。""" + if not enabled: + return False, "当前没有开启工作流数据共享功能" + workflow = await self._async_workflow_provider(workflow_id) + valid, message = self.validate_workflow(workflow) + if not valid: + return False, message + payload = { + "share_title": share_title, + "share_comment": share_comment, + "share_user": share_user, + "share_uid": self._user_uuid_provider(), + **self.prepare_workflow(workflow), + } + return self._response_handler( + await self._async_workflow_sender(payload), + self._workflow_cache_clearer, + ) diff --git a/app/application/site/mutation.py b/app/application/site/mutation.py new file mode 100644 index 000000000..3a527c454 --- /dev/null +++ b/app/application/site/mutation.py @@ -0,0 +1,142 @@ +"""站点写操作应用用例。""" + +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Mapping, Optional, Protocol + +from app.application.subscription.delete import AsyncUnitOfWork + + +@dataclass(frozen=True, slots=True) +class SiteMutationResult: + """描述站点写操作是否成功及兼容提示信息。""" + + success: bool + message: str = "" + + +class SiteMutationRepository(Protocol): + """站点写用例需要的最小持久化端口。""" + + async def get_by_id(self, site_id: int) -> Optional[Any]: + """读取指定站点。""" + ... + + async def get_by_domain(self, domain: str) -> Optional[Any]: + """按域名读取站点。""" + ... + + async def stage_create(self, payload: Mapping[str, Any]) -> None: + """暂存新增站点。""" + ... + + async def stage_update(self, site_id: int, payload: Mapping[str, Any]) -> bool: + """暂存站点更新并返回目标是否存在。""" + ... + + async def stage_delete(self, site_id: int) -> None: + """暂存站点删除。""" + ... + + async def stage_priorities(self, priorities: list[dict]) -> None: + """暂存一组站点优先级变更。""" + ... + + +SiteIndexerLoader = Callable[[str], Awaitable[Optional[dict]]] +SiteEventPublisher = Callable[[dict], Awaitable[None]] +DomainExtractor = Callable[[str], str] +UrlNormalizer = Callable[[str], str] + + +class SiteMutationCommand: + """统一执行站点新增、更新、优先级和删除事务。""" + + def __init__( + self, + *, + repository: SiteMutationRepository, + unit_of_work: AsyncUnitOfWork, + auth_level_provider: Callable[[], int], + indexer_loader: SiteIndexerLoader, + domain_extractor: DomainExtractor, + url_normalizer: UrlNormalizer, + publish_updated: SiteEventPublisher, + publish_deleted: SiteEventPublisher, + ) -> None: + """保存站点验证、持久化、事务和提交后事件端口。""" + self._repository = repository + self._unit_of_work = unit_of_work + self._auth_level_provider = auth_level_provider + self._indexer_loader = indexer_loader + self._domain_extractor = domain_extractor + self._url_normalizer = url_normalizer + self._publish_updated = publish_updated + self._publish_deleted = publish_deleted + + async def create(self, payload: Mapping[str, Any]) -> SiteMutationResult: + """校验并新增站点,提交成功后发布站点更新事件。""" + values = dict(payload) + raw_url = values.get("url") + if not raw_url: + return SiteMutationResult(False, "站点地址不能为空") + if self._auth_level_provider() < 2: + return SiteMutationResult(False, "用户未通过认证,无法使用站点功能!") + + domain = self._domain_extractor(raw_url) + site_info = await self._indexer_loader(domain) + if not site_info: + return SiteMutationResult(False, "该站点不支持,请检查站点域名是否正确") + if await self._repository.get_by_domain(domain): + return SiteMutationResult(False, f"{domain} 站点己存在") + + values.update({ + "id": None, + "domain": domain, + "url": self._url_normalizer(raw_url), + "name": site_info.get("name"), + "public": 1 if site_info.get("public") else 0, + }) + await self._repository.stage_create(values) + await self._commit() + await self._publish_updated({"domain": domain}) + return SiteMutationResult(True) + + async def update(self, payload: Mapping[str, Any]) -> SiteMutationResult: + """更新站点并在提交后发布完整的兼容事件载荷。""" + values = dict(payload) + site_id = values.get("id") + if not site_id or not await self._repository.get_by_id(site_id): + return SiteMutationResult(False, "站点不存在") + + values["url"] = self._url_normalizer(values.get("url") or "") + values["domain"] = self._domain_extractor(values["url"]) + await self._repository.stage_update(site_id, values) + await self._commit() + await self._publish_updated({ + "site_id": site_id, + "domain": values["domain"], + "name": values.get("name"), + "site_url": values["url"], + }) + return SiteMutationResult(True) + + async def update_priorities(self, priorities: list[dict]) -> SiteMutationResult: + """在同一事务中更新全部站点优先级。""" + await self._repository.stage_priorities(priorities) + await self._commit() + return SiteMutationResult(True) + + async def delete(self, site_id: int) -> SiteMutationResult: + """删除站点,并确保删除事件只在提交成功后发送。""" + await self._repository.stage_delete(site_id) + await self._commit() + await self._publish_deleted({"site_id": site_id}) + return SiteMutationResult(True) + + async def _commit(self) -> None: + """提交当前站点事务,失败时回滚并保留原始异常。""" + try: + await self._unit_of_work.commit() + except Exception: + await self._unit_of_work.rollback() + raise diff --git a/app/application/storage.py b/app/application/storage.py index 6d868525e..dd7a5979a 100644 --- a/app/application/storage.py +++ b/app/application/storage.py @@ -1,6 +1,6 @@ from typing import List, Optional -from app import schemas +from app.schemas.system import StorageConf as _SchemaStorageConf from app.db.oper.systemconfig import SystemConfigOper from app.schemas.types import SystemConfigKey @@ -11,16 +11,16 @@ class StorageHelper: """ @staticmethod - def get_storagies() -> List[schemas.StorageConf]: + def get_storagies() -> List[_SchemaStorageConf]: """ 获取所有存储设置 """ storage_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Storages) if not storage_confs: return [] - return [schemas.StorageConf(**s) for s in storage_confs] + return [_SchemaStorageConf(**s) for s in storage_confs] - def get_storage(self, storage: str) -> Optional[schemas.StorageConf]: + def get_storage(self, storage: str) -> Optional[_SchemaStorageConf]: """ 获取指定存储配置 """ @@ -37,7 +37,7 @@ class StorageHelper: storagies = self.get_storagies() if not storagies: storagies = [ - schemas.StorageConf( + _SchemaStorageConf( type=storage, config=conf ) @@ -56,14 +56,14 @@ class StorageHelper: storagies = self.get_storagies() if not storagies: storagies = [ - schemas.StorageConf( + _SchemaStorageConf( type=storage, name=name, config=conf ) ] else: - storagies.append(schemas.StorageConf( + storagies.append(_SchemaStorageConf( type=storage, name=name, config=conf diff --git a/app/application/subscription/__init__.py b/app/application/subscription/__init__.py new file mode 100644 index 000000000..bd55093c3 --- /dev/null +++ b/app/application/subscription/__init__.py @@ -0,0 +1 @@ +"""订阅应用契约与写用例。""" diff --git a/app/application/subscription/contract.py b/app/application/subscription/contract.py new file mode 100644 index 000000000..d88e7fa28 --- /dev/null +++ b/app/application/subscription/contract.py @@ -0,0 +1,67 @@ +"""订阅编排共享的媒体元数据与身份契约。""" + +from typing import Optional, Protocol, Union + +from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic +from app.domain.metainfo import MetaInfo +from app.schemas.media import build_media_key, resolve_media_identity +from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType + + +class SubscribeSnapshot(Protocol): + """构造订阅媒体契约所需的最小只读字段集合。""" + + name: str + type: str + year: Optional[str] + season: Optional[int] + media_source: object + media_id: object + music_type: Optional[str] + total_tracks: Optional[int] + + +def build_subscribe_meta(subscribe: SubscribeSnapshot) -> MetaBase: + """按订阅快照构造主程序链路共用的媒体元数据。""" + if subscribe.type == MediaType.MUSIC.value: + is_album = getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM + return MetaMusic( + title=subscribe.name, + album=subscribe.name if is_album else None, + year=subscribe.year, + total_tracks=( + getattr(subscribe, "total_tracks", None) if is_album else None + ), + media_source=subscribe.media_source, + media_id=( + str(subscribe.media_id) + if subscribe.media_id is not None + else None + ), + ) + meta = MetaInfo(subscribe.name) + meta.year = subscribe.year + meta.begin_season = subscribe.season + meta.type = MediaType(subscribe.type) + meta.media_source = subscribe.media_source + meta.media_id = subscribe.media_id + return meta + + +def subscribe_media_key( + subscribe: SubscribeSnapshot, +) -> Union[str, int, None]: + """返回订阅缺失集映射使用的稳定媒体键。""" + media_source, media_id = resolve_media_identity(media=subscribe) + return build_media_key(media_source, media_id) or media_id + + +def subscribe_media_keys(subscribe: SubscribeSnapshot) -> list[Union[str, int]]: + """返回缺失集缓存可识别的规范媒体键与旧纯 ID 键。""" + media_source, media_id = resolve_media_identity(media=subscribe) + candidates = [ + build_media_key(media_source, media_id), + media_id, + ] + return [candidate for candidate in candidates if candidate not in (None, "")] diff --git a/app/application/subscription/delete.py b/app/application/subscription/delete.py new file mode 100644 index 000000000..932ebb1e0 --- /dev/null +++ b/app/application/subscription/delete.py @@ -0,0 +1,117 @@ +"""订阅删除应用用例及其依赖端口。""" + +from dataclasses import dataclass +from typing import Awaitable, Callable, Mapping, Protocol + + +@dataclass(frozen=True) +class SubscribeDeletionActor: + """执行订阅删除的用户身份。""" + + username: str + is_superuser: bool + + +@dataclass(frozen=True) +class SubscribeDeletionCandidate: + """删除前读取出的订阅快照,不向应用层暴露 ORM 对象。""" + + subscribe_id: int + username: str | None + event_payload: Mapping[str, object] + + +class SubscribeDeletionRepository(Protocol): + """订阅删除用例需要的最小数据访问端口。""" + + async def get_candidate( + self, + subscribe_id: int, + ) -> SubscribeDeletionCandidate | None: + """读取订阅及删除事件所需的稳定快照。""" + ... + + async def stage_delete(self, subscribe_id: int) -> None: + """把已读取的订阅登记为待删除,但不自行提交事务。""" + ... + + +class AsyncUnitOfWork(Protocol): + """订阅写用例使用的异步事务端口。""" + + async def commit(self) -> None: + """提交当前事务。""" + ... + + async def rollback(self) -> None: + """回滚当前事务。""" + ... + + +SubscribeDeletedPublisher = Callable[ + [int, Mapping[str, object]], + Awaitable[None], +] +SubscribeDeletedReporter = Callable[[Mapping[str, object]], object] + + +class DeleteSubscribeCommand: + """按权限删除订阅,并在提交成功后依次发送事件和统计上报。""" + + def __init__( + self, + repository: SubscribeDeletionRepository, + unit_of_work: AsyncUnitOfWork, + publish_deleted: SubscribeDeletedPublisher, + report_deleted: SubscribeDeletedReporter, + ) -> None: + """注入数据访问、事务与提交后副作用端口。""" + self._repository = repository + self._unit_of_work = unit_of_work + self._publish_deleted = publish_deleted + self._report_deleted = report_deleted + + async def execute( + self, + subscribe_id: int, + actor: SubscribeDeletionActor, + ) -> bool: + """ + 删除当前用户可访问的订阅。 + + 返回 False 表示订阅不存在或无权访问;该结果由 API 映射为历史兼容的成功响应。 + 提交后的事件与上报保持原有顺序,任一副作用失败都会继续向调用方抛出。 + """ + candidate = await self._repository.get_candidate(subscribe_id) + if not self._can_delete(candidate, actor): + return False + + await self._repository.stage_delete(subscribe_id) + try: + await self._unit_of_work.commit() + except Exception: + await self._unit_of_work.rollback() + raise + + event_payload = dict(candidate.event_payload) + await self._publish_deleted(subscribe_id, event_payload) + self._report_deleted( + { + "media_source": event_payload.get("media_source"), + "media_id": event_payload.get("media_id"), + "season": event_payload.get("season"), + } + ) + return True + + @staticmethod + def _can_delete( + candidate: SubscribeDeletionCandidate | None, + actor: SubscribeDeletionActor, + ) -> bool: + """判断用户是否拥有目标订阅的删除权限。""" + if candidate is None: + return False + if actor.is_superuser: + return True + return bool(candidate.username) and candidate.username == actor.username diff --git a/app/application/subscription/identity.py b/app/application/subscription/identity.py new file mode 100644 index 000000000..2f25f0c43 --- /dev/null +++ b/app/application/subscription/identity.py @@ -0,0 +1,98 @@ +"""按媒体身份批量删除订阅的应用用例。""" + +from typing import Callable, Protocol + +from app.application.subscription.delete import ( + AsyncUnitOfWork, + SubscribeDeletedPublisher, + SubscribeDeletionActor, + SubscribeDeletionCandidate, +) +from app.schemas.types import MediaSource + + +class SubscribeIdentityDeletionRepository(Protocol): + """按媒体身份删除订阅所需的数据访问端口。""" + + async def list_candidates_by_identity( + self, + media_source: MediaSource, + media_id: str, + season: int | None, + music_type: str | None, + ) -> list[SubscribeDeletionCandidate]: + """读取匹配媒体身份的去重订阅快照。""" + ... + + async def delete(self, subscribe_id: int) -> None: + """把指定订阅登记为待删除。""" + ... + + +SubscribeDeletionEventErrorHandler = Callable[[int, Exception], None] + + +class DeleteSubscriptionsByIdentityCommand: + """按媒体身份删除当前用户可访问的全部订阅。""" + + def __init__( + self, + repository: SubscribeIdentityDeletionRepository, + unit_of_work: AsyncUnitOfWork, + publish_deleted: SubscribeDeletedPublisher, + handle_event_error: SubscribeDeletionEventErrorHandler, + ) -> None: + """注入数据访问、事务、事件和事件错误处理端口。""" + self._repository = repository + self._unit_of_work = unit_of_work + self._publish_deleted = publish_deleted + self._handle_event_error = handle_event_error + + async def execute( + self, + media_source: MediaSource, + media_id: str, + season: int | None, + music_type: str | None, + actor: SubscribeDeletionActor, + ) -> int: + """删除匹配订阅,并在提交后逐条发送兼容事件。""" + candidates = await self._repository.list_candidates_by_identity( + media_source, + media_id, + season, + music_type, + ) + deletions = [ + candidate + for candidate in candidates + if self._can_delete(candidate, actor) + ] + for candidate in deletions: + await self._repository.stage_delete(candidate.subscribe_id) + + try: + await self._unit_of_work.commit() + except Exception: + await self._unit_of_work.rollback() + raise + + for candidate in deletions: + try: + await self._publish_deleted( + candidate.subscribe_id, + dict(candidate.event_payload), + ) + except Exception as error: + self._handle_event_error(candidate.subscribe_id, error) + return len(deletions) + + @staticmethod + def _can_delete( + candidate: SubscribeDeletionCandidate, + actor: SubscribeDeletionActor, + ) -> bool: + """判断用户是否拥有候选订阅的删除权限。""" + if actor.is_superuser: + return True + return bool(candidate.username) and candidate.username == actor.username diff --git a/app/application/subscription/query.py b/app/application/subscription/query.py new file mode 100644 index 000000000..bcd03dbeb --- /dev/null +++ b/app/application/subscription/query.py @@ -0,0 +1,76 @@ +"""订阅存在性、来源定位和类型状态查询应用服务。""" + +from __future__ import annotations + +from typing import Any, Optional, Protocol + +from app.domain.context import MediaInfo +from app.domain.meta.metabase import MetaBase +from app.schemas.media import resolve_media_identity +from app.schemas.types import MediaType + + +class SubscriptionQueryRepository(Protocol): + """描述订阅查询切片所需的最小仓储能力。""" + + def exists(self, **identity: Any) -> bool: + """按完整订阅身份判断记录是否存在。""" + ... + + def get_by(self, **identity: Any) -> Optional[Any]: + """按来源关键字中的订阅身份读取单条记录。""" + ... + + def list(self, state: Optional[str] = None) -> list[Any]: + """按可选状态集合读取订阅记录。""" + ... + + +class SubscriptionQueryService: + """封装不修改订阅状态的三个公开查询用例。""" + + _SOURCE_FIELDS = { + "type", + "season", + "media_source", + "media_id", + "music_type", + } + + def __init__(self, repository: SubscriptionQueryRepository) -> None: + """保存订阅查询仓储端口。""" + self._repository = repository + + def exists( + self, + mediainfo: MediaInfo, + meta: Optional[MetaBase] = None, + ) -> bool: + """按媒体身份、季、剧集组和音乐实体类型判断订阅是否存在。""" + media_source, media_id = resolve_media_identity(media=mediainfo) + return bool(self._repository.exists( + media_source=media_source, + media_id=media_id, + music_type=getattr(mediainfo, "music_type", None) + if mediainfo.type == MediaType.MUSIC else None, + season=meta.begin_season if meta else None, + episode_group=mediainfo.episode_group, + )) + + def get_by_source(self, source_keyword: Optional[dict]) -> Optional[Any]: + """从已解析来源关键字筛出稳定身份字段并读取订阅。""" + if not source_keyword: + return None + identity = { + key: value + for key, value in source_keyword.items() + if key in self._SOURCE_FIELDS + } + return self._repository.get_by(**identity) + + def has_music(self, searchable_states: str) -> bool: + """判断给定可搜索状态内是否至少存在一个音乐订阅。""" + return any( + subscribe.type == MediaType.MUSIC.value + for subscribe in self._repository.list(searchable_states) or [] + ) diff --git a/app/application/subscription/search.py b/app/application/subscription/search.py new file mode 100644 index 000000000..ba2580984 --- /dev/null +++ b/app/application/subscription/search.py @@ -0,0 +1,82 @@ +"""手工订阅搜索应用用例。""" + +from dataclasses import dataclass +from typing import Callable, Protocol + +from app.application.subscription.delete import SubscribeDeletionCandidate + + +@dataclass(frozen=True, slots=True) +class SubscribeSearchActor: + """执行手工订阅搜索的用户身份。""" + + username: str + is_superuser: bool + + +class SubscribeSearchRepository(Protocol): + """手工订阅搜索所需的最小读取端口。""" + + async def get_candidate( + self, + subscribe_id: int, + ) -> SubscribeDeletionCandidate | None: + """读取单条订阅的归属信息。""" + ... + + async def list_search_ids(self, username: str, state: str) -> list[int]: + """返回用户当前可搜索状态下的订阅编号。""" + ... + + +SubscribeSearchScheduler = Callable[[int | None, str | None], None] + + +class SearchSubscriptionsCommand: + """按用户权限生成并提交手工订阅搜索任务。""" + + def __init__( + self, + repository: SubscribeSearchRepository, + schedule_search: SubscribeSearchScheduler, + ) -> None: + """注入订阅读取端口和后台任务提交端口。""" + self._repository = repository + self._schedule_search = schedule_search + + async def execute( + self, + actor: SubscribeSearchActor, + subscribe_id: int | None = None, + ) -> bool: + """提交单条或当前用户全部可搜索订阅,返回目标是否存在。""" + if subscribe_id is not None: + candidate = await self._repository.get_candidate(subscribe_id) + if not self._can_access(candidate, actor): + return False + self._schedule_search(subscribe_id, None) + return True + + if actor.is_superuser: + self._schedule_search(None, "R") + return True + + subscribe_ids = await self._repository.list_search_ids( + actor.username, + "R", + ) + for current_id in subscribe_ids: + self._schedule_search(current_id, None) + return True + + @staticmethod + def _can_access( + candidate: SubscribeDeletionCandidate | None, + actor: SubscribeSearchActor, + ) -> bool: + """沿用订阅读取接口的超级用户和归属用户权限语义。""" + if candidate is None: + return False + if actor.is_superuser: + return True + return bool(candidate.username) and candidate.username == actor.username diff --git a/app/application/transfer.py b/app/application/transfer.py index 0554169b7..534d28852 100644 --- a/app/application/transfer.py +++ b/app/application/transfer.py @@ -22,7 +22,10 @@ from typing import Callable, Dict, List, Optional, Tuple, Union from pydantic import BaseModel, ConfigDict -from app import schemas +from app.schemas.transfer import MetaInfo as _SchemaMetaInfo +from app.schemas.transfer import MusicInfo as _SchemaMusicInfo +from app.schemas.transfer import MusicMeta as _SchemaMusicMeta +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.adapters.system.host import SystemUtils from app.application.agent import get_prompt_manager, get_running_agent_manager from app.domain.context import MediaInfo, MusicInfo @@ -31,7 +34,6 @@ from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.foundation import text as text_tools from app.runtime.log import logger -from app.schemas.agent import ReplyMode from app.schemas.file import FileItem from app.schemas.history import DownloadHistory from app.schemas.media import OptionalMediaIdentityMixin, resolve_media_identity @@ -43,6 +45,7 @@ from app.schemas.types import ( MUSIC_ENTITY_RECORDING, MediaSource, MediaType, + ReplyMode, ) @@ -109,6 +112,49 @@ class TransferQueue(BaseModel): result: Optional[TransferInfo] = None +class TransferQueueService: + """协调整理任务登记、入队、移除和队列视图查询。""" + + def __init__( + self, + *, + register_task: Callable[[TransferTask], bool], + enqueue: Callable[[TransferQueue], None], + before_enqueue: Callable[[TransferTask], None], + after_enqueue: Callable[[TransferTask], None], + remove_task: Callable[[FileItem], None], + list_tasks: Callable[[], List[TransferJob]], + expire_tasks: Callable[[], None], + ) -> None: + """保存队列用例依赖,避免 Application 服务绑定具体线程队列实现。""" + self._register_task = register_task + self._enqueue = enqueue + self._before_enqueue = before_enqueue + self._after_enqueue = after_enqueue + self._remove_task = remove_task + self._list_tasks = list_tasks + self._expire_tasks = expire_tasks + + def put(self, task: TransferTask, callback: Callable) -> bool: + """登记并入队一个整理任务,保持原有副作用顺序。""" + if not task or not self._register_task(task): + return False + self._before_enqueue(task) + self._enqueue(TransferQueue(task=task, callback=callback)) + self._after_enqueue(task) + return True + + def remove(self, fileitem: FileItem) -> None: + """从整理任务视图移除指定文件。""" + if fileitem: + self._remove_task(fileitem) + + def list(self) -> List[TransferJob]: + """先处理失活任务,再返回当前整理作业视图。""" + self._expire_tasks() + return self._list_tasks() + + # 作业锁:JobManager 与 TransferChain 共享,保护整理作业视图。 job_lock = threading.Lock() @@ -214,7 +260,7 @@ class JobManager: return self.__get_id(task) @staticmethod - def __get_media(task: TransferTask) -> Union[schemas.MediaInfo, schemas.MusicInfo]: + def __get_media(task: TransferTask) -> Union[_SchemaMediaInfo, _SchemaMusicInfo]: """ 获取媒体信息 """ @@ -223,15 +269,15 @@ class JobManager: mediainfo = deepcopy(task.mediainfo) mediainfo.clear() if isinstance(mediainfo, MusicInfo): - return schemas.MusicInfo(**mediainfo.to_dict()) - return schemas.MediaInfo(**mediainfo.to_dict()) + return _SchemaMusicInfo(**mediainfo.to_dict()) + return _SchemaMediaInfo(**mediainfo.to_dict()) else: # 没有媒体信息 meta: MetaBase = task.meta if isinstance(meta, MetaMusic): # 未识别的音乐按已解析元数据兜底展示;音乐年份为 int, # 不能复用 MediaInfo(year 为 str),否则触发 pydantic 校验异常 - return schemas.MusicInfo( + return _SchemaMusicInfo( title=meta.name, artists=list(meta.artists or []), artist=meta.artist, @@ -242,7 +288,7 @@ class JobManager: media_source=meta.media_source, media_id=meta.media_id, ) - return schemas.MediaInfo( + return _SchemaMediaInfo( title=meta.name, year=meta.year, title_year=f"{meta.name} ({meta.year})", @@ -250,13 +296,13 @@ class JobManager: ) @staticmethod - def __get_meta(task: TransferTask) -> schemas.MetaInfo: + def __get_meta(task: TransferTask) -> _SchemaMetaInfo: """ 获取元数据 """ if isinstance(task.meta, MetaMusic): - return schemas.MusicMeta(**task.meta.to_dict()) - return schemas.MetaInfo(**task.meta.to_dict()) + return _SchemaMusicMeta(**task.meta.to_dict()) + return _SchemaMetaInfo(**task.meta.to_dict()) def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool: """ @@ -975,4 +1021,3 @@ class FailedRetryScheduler: logger.error( f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {err}" ) - diff --git a/app/application/workflow.py b/app/application/workflow.py new file mode 100644 index 000000000..5b8d92bb5 --- /dev/null +++ b/app/application/workflow.py @@ -0,0 +1,302 @@ +"""工作流状态与定义写操作应用用例。""" + +from dataclasses import dataclass +import json +from collections.abc import Awaitable +from datetime import datetime +from typing import Any, Callable, Mapping, Optional, Protocol + + +WORKFLOW_TRIGGER_TIMER = "timer" +WORKFLOW_TRIGGER_EVENT = "event" +WORKFLOW_TRIGGER_MANUAL = "manual" +SUPPORTED_WORKFLOW_TRIGGERS = { + WORKFLOW_TRIGGER_TIMER, + WORKFLOW_TRIGGER_EVENT, + WORKFLOW_TRIGGER_MANUAL, +} + + +@dataclass(frozen=True, slots=True) +class WorkflowMutationResult: + """描述工作流写操作是否成功及兼容提示信息。""" + + success: bool + message: str = "" + + +class WorkflowMutationRepository(Protocol): + """工作流写用例需要的最小持久化端口。""" + + def get(self, workflow_id: int) -> Optional[Any]: + """读取工作流。""" + ... + + def stage_state(self, workflow_id: int, state: str) -> bool: + """暂存工作流状态变更。""" + ... + + def stage_update(self, workflow_id: int, payload: Mapping[str, Any]) -> Optional[Any]: + """暂存工作流定义更新并返回更新后的对象。""" + ... + + def stage_delete(self, workflow_id: int) -> None: + """暂存工作流删除。""" + ... + + +class UnitOfWork(Protocol): + """同步工作流写用例使用的事务端口。""" + + def commit(self) -> None: + """提交当前事务。""" + ... + + def rollback(self) -> None: + """回滚当前事务。""" + ... + + +class WorkflowMutationCommand: + """协调工作流状态、定义、调度和事件注册变更。""" + + def __init__( + self, + *, + repository: WorkflowMutationRepository, + unit_of_work: UnitOfWork, + add_timer: Callable[[Any], None], + remove_timer: Callable[[Any], None], + load_event: Callable[[int], None], + remove_event: Callable[[int, Optional[str]], None], + refresh_event: Callable[[Any], None], + stop_running: Callable[[int], None], + delete_cache: Callable[[int], None], + ) -> None: + """保存工作流事务和提交后运行时副作用端口。""" + self._repository = repository + self._unit_of_work = unit_of_work + self._add_timer = add_timer + self._remove_timer = remove_timer + self._load_event = load_event + self._remove_event = remove_event + self._refresh_event = refresh_event + self._stop_running = stop_running + self._delete_cache = delete_cache + + def start(self, workflow_id: int) -> WorkflowMutationResult: + """启用工作流,并在提交后登记定时器或事件触发器。""" + workflow = self._repository.get(workflow_id) + if not workflow: + return WorkflowMutationResult(False, "工作流不存在") + trigger_type = workflow.trigger_type or WORKFLOW_TRIGGER_TIMER + if trigger_type == WORKFLOW_TRIGGER_TIMER and not workflow.timer: + return WorkflowMutationResult(False, "定时工作流缺少定时器配置") + if trigger_type not in SUPPORTED_WORKFLOW_TRIGGERS: + return WorkflowMutationResult(False, "工作流触发类型不支持") + + self._repository.stage_state(workflow_id, "W") + self._commit() + if trigger_type == WORKFLOW_TRIGGER_TIMER: + self._add_timer(workflow) + elif trigger_type == WORKFLOW_TRIGGER_EVENT: + self._load_event(workflow_id) + return WorkflowMutationResult(True) + + def pause(self, workflow_id: int) -> WorkflowMutationResult: + """停用工作流,并在提交后移除运行时触发器和执行状态。""" + workflow = self._repository.get(workflow_id) + if not workflow: + return WorkflowMutationResult(False, "工作流不存在") + + self._repository.stage_state(workflow_id, "P") + self._commit() + if workflow.trigger_type == WORKFLOW_TRIGGER_TIMER: + self._remove_timer(workflow) + elif workflow.trigger_type == WORKFLOW_TRIGGER_EVENT: + self._remove_event(workflow_id, workflow.event_type) + self._stop_running(workflow_id) + return WorkflowMutationResult(True) + + def update(self, payload: Mapping[str, Any]) -> WorkflowMutationResult: + """更新工作流定义,并在提交后刷新调度器和事件注册。""" + values = dict(payload) + workflow_id = values.get("id") + if not workflow_id: + return WorkflowMutationResult(False, "工作流ID不能为空") + current = self._repository.get(workflow_id) + if not current: + return WorkflowMutationResult(False, "工作流不存在") + if not current.trigger_type: + values["trigger_type"] = WORKFLOW_TRIGGER_TIMER + + updated = self._repository.stage_update(workflow_id, values) + self._commit() + self._remove_timer(updated) + if ( + not updated.trigger_type + or updated.trigger_type == WORKFLOW_TRIGGER_TIMER + ) and updated.timer: + self._add_timer(updated) + self._refresh_event(updated) + return WorkflowMutationResult(True, "更新成功") + + def delete(self, workflow_id: int) -> WorkflowMutationResult: + """删除工作流,并在提交后清除缓存和运行时触发器。""" + workflow = self._repository.get(workflow_id) + if not workflow: + return WorkflowMutationResult(False, "工作流不存在") + + self._repository.stage_delete(workflow_id) + self._commit() + self._delete_cache(workflow_id) + if not workflow.trigger_type or workflow.trigger_type == WORKFLOW_TRIGGER_TIMER: + self._remove_timer(workflow) + elif workflow.trigger_type == WORKFLOW_TRIGGER_EVENT: + self._remove_event(workflow_id, workflow.event_type) + return WorkflowMutationResult(True, "删除成功") + + def _commit(self) -> None: + """提交工作流事务,失败时回滚且不执行后续运行时副作用。""" + try: + self._unit_of_work.commit() + except Exception: + self._unit_of_work.rollback() + raise + + +class AsyncWorkflowDefinitionRepository(Protocol): + """工作流创建、复用和重置需要的异步持久化端口。""" + + async def async_get_by_name(self, name: str) -> Optional[Any]: + """按名称读取工作流。""" + ... + + async def stage_create(self, payload: Mapping[str, Any]) -> Any: + """暂存新工作流。""" + ... + + async def stage_reset(self, workflow_id: int, reset_count: bool = False) -> Optional[Any]: + """暂存工作流重置。""" + ... + + async def async_get(self, workflow_id: int) -> Optional[Any]: + """读取指定工作流。""" + ... + + +class AsyncUnitOfWork(Protocol): + """异步工作流定义用例使用的事务端口。""" + + async def commit(self) -> None: + """提交当前事务。""" + ... + + async def rollback(self) -> None: + """回滚当前事务。""" + ... + + +class WorkflowDefinitionCommand: + """协调工作流创建、分享复用和重置的异步写用例。""" + + def __init__( + self, + *, + repository: AsyncWorkflowDefinitionRepository, + unit_of_work: AsyncUnitOfWork, + stop_running: Callable[[int], None], + delete_cache: Callable[[int], None], + report_fork: Optional[Callable[[int], Awaitable[object]]] = None, + ) -> None: + """保存异步事务和提交后运行时副作用端口。""" + self._repository = repository + self._unit_of_work = unit_of_work + self._stop_running = stop_running + self._delete_cache = delete_cache + self._report_fork = report_fork + + async def create(self, payload: Mapping[str, Any]) -> WorkflowMutationResult: + """校验名称并暂存新工作流,提交失败时不产生运行时副作用。""" + values = dict(payload) + name = values.get("name") + if name and await self._repository.async_get_by_name(name): + return WorkflowMutationResult(False, "已存在相同名称的工作流") + if not values.get("add_time"): + values["add_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if not values.get("state"): + values["state"] = "P" + if not values.get("trigger_type"): + values["trigger_type"] = WORKFLOW_TRIGGER_TIMER + try: + await self._repository.stage_create(values) + await self._commit() + except Exception: + raise + return WorkflowMutationResult(True, "创建工作流成功") + + async def fork( + self, + payload: Mapping[str, Any], + share_id: Optional[int] = None, + ) -> WorkflowMutationResult: + """解析共享工作流内容并在提交后更新远程复用次数。""" + values = dict(payload) + if not values.get("name"): + return WorkflowMutationResult(False, "工作流名称不能为空") + parsed = {} + for field, default, error_message in ( + ("actions", "[]", "actions字段JSON格式错误"), + ("flows", "[]", "flows字段JSON格式错误"), + ("context", "{}", "context字段JSON格式错误"), + ("event_conditions", "{}", "event_conditions字段JSON格式错误"), + ): + raw = values.get(field) + try: + parsed[field] = json.loads(raw or default) + except json.JSONDecodeError: + return WorkflowMutationResult(False, error_message) + workflow_values = { + "name": values["name"], + "description": values.get("description"), + "timer": values.get("timer"), + "trigger_type": values.get("trigger_type") or WORKFLOW_TRIGGER_TIMER, + "event_type": values.get("event_type"), + "event_conditions": parsed["event_conditions"], + "actions": parsed["actions"], + "flows": parsed["flows"], + "context": parsed["context"], + "state": "P", + } + if await self._repository.async_get_by_name(workflow_values["name"]): + return WorkflowMutationResult(False, "已存在相同名称的工作流") + try: + created = await self._repository.stage_create(workflow_values) + await self._commit() + except Exception: + raise + if created and share_id and self._report_fork: + try: + await self._report_fork(share_id) + except Exception: + return WorkflowMutationResult(True, "复用成功;共享统计上报失败") + return WorkflowMutationResult(True, "复用成功") + + async def reset(self, workflow_id: int) -> WorkflowMutationResult: + """重置工作流并在提交后停止运行态、清除缓存。""" + workflow = await self._repository.async_get(workflow_id) + if not workflow: + return WorkflowMutationResult(False, "工作流不存在") + await self._repository.stage_reset(workflow_id, reset_count=True) + await self._commit() + self._stop_running(workflow_id) + self._delete_cache(workflow_id) + return WorkflowMutationResult(True) + + async def _commit(self) -> None: + """提交异步事务,失败时回滚且不继续执行运行时副作用。""" + try: + await self._unit_of_work.commit() + except Exception: + await self._unit_of_work.rollback() + raise diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 6fa8cac7f..73c46b5d0 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -import inspect import pickle import traceback from abc import ABCMeta @@ -8,32 +7,23 @@ from collections.abc import Callable from pathlib import Path from typing import Optional, Any, Tuple, List, Set, Union, Dict -from fastapi.concurrency import run_in_threadpool - -from app.application.messaging.message import MessageHelper, MessageQueueManager +from app.application.chain.context import ChainRuntimeContext, get_chain_runtime_context from app.chain._messaging import MessageProcessingMixin, NotificationMixin from app.chain._recognition import RecognitionMixin -from app.db.oper.message import MessageOper from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo from app.domain.meta.metabase import MetaBase -from app.foundation.reflection import ObjectUtils -from app.runtime.cache import FileCache, AsyncFileCache -from app.runtime.events import EventManager -from app.runtime.extensions.module_manager import ModuleManager -from app.runtime.extensions.plugin_manager import PluginManager +from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher from app.runtime.log import logger -from app.schemas import ( - RateLimitExceededException, - TransferInfo, - ExistMediaInfo, - DownloaderTorrent, - IncomingMessage, - WebhookEventInfo, - TmdbEpisode, - MediaPerson, - FileItem, - TransferDirectoryConf, -) +from app.schemas.exception import RateLimitExceededException +from app.schemas.transfer import TransferInfo +from app.schemas.mediaserver import ExistMediaInfo +from app.schemas.transfer import DownloaderTorrent +from app.schemas.message import IncomingMessage +from app.schemas.mediaserver import WebhookEventInfo +from app.schemas.tmdb import TmdbEpisode +from app.schemas.context import MediaPerson +from app.schemas.workflow import FileItem +from app.schemas.system import TransferDirectoryConf from app.schemas.category import CategoryConfig from app.schemas.types import ( TorrentStatus, @@ -50,18 +40,26 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, 处理链基类 """ - def __init__(self): + def __init__(self, runtime_context: Optional[ChainRuntimeContext] = None): """ - 公共初始化 + 公共初始化;未显式传入上下文时继续使用兼容运行时 provider。 """ - self.modulemanager = ModuleManager() - self.eventmanager = EventManager() - self.messageoper = MessageOper() - self.messagehelper = MessageHelper() - self.messagequeue = MessageQueueManager(send_callback=self.run_module) - self.pluginmanager = PluginManager() - self.filecache = FileCache() - self.async_filecache = AsyncFileCache() + context = runtime_context or get_chain_runtime_context() + self.modulemanager = context.module_manager + self.eventmanager = context.event_manager + self.messageoper = context.message_oper + self.messagehelper = context.message_helper + self.pluginmanager = context.plugin_manager + self.filecache = context.file_cache + self.async_filecache = context.async_file_cache + self._module_dispatcher = ModuleInvocationDispatcher( + module_catalog=self.modulemanager, + plugin_catalog=self.pluginmanager, + plugin_error_handler=self.__handle_plugin_error, + system_error_handler=self.__handle_system_error, + rate_limit_handler=self.__handle_rate_limit_error, + ) + self.messagequeue = context.message_queue_factory(self.run_module) def load_cache(self, filename: str) -> Any: """ @@ -121,16 +119,6 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, """ await self.async_filecache.delete(filename) - @staticmethod - def __is_valid_empty(ret): - """ - 判断结果是否为空 - """ - if isinstance(ret, tuple): - return all(value is None for value in ret) - else: - return ret is None - def __handle_plugin_error( self, err: Exception, plugin_id: str, plugin_name: str, method: str, **kwargs ): @@ -195,178 +183,6 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, raise err logger.info(f"{source_type} {source_id}.{method} 已限流,跳过执行:{str(err)}") - def __execute_plugin_modules( - self, method: str, result: Any, *args, **kwargs - ) -> Any: - """ - 执行插件模块 - """ - for plugin, module_dict in self.pluginmanager.get_plugin_modules().items(): - plugin_id, plugin_name = plugin - if method in module_dict: - func = module_dict[method] - if func: - try: - logger.info(f"请求插件 {plugin_name} 执行:{method} ...") - if self.__is_valid_empty(result): - # 返回None,第一次执行或者需继续执行下一模块 - result = func(*args, **kwargs) - elif isinstance(result, list): - # 返回为列表,有多个模块运行结果时进行合并 - temp = func(*args, **kwargs) - if isinstance(temp, list): - result.extend(temp) - else: - break - except RateLimitExceededException as err: - self.__handle_rate_limit_error( - err, "插件", plugin_id, method, **kwargs - ) - except Exception as err: - self.__handle_plugin_error( - err, plugin_id, plugin_name, method, **kwargs - ) - return result - - async def __async_execute_plugin_modules( - self, method: str, result: Any, *args, **kwargs - ) -> Any: - """ - 异步执行插件模块 - """ - for plugin, module_dict in self.pluginmanager.get_plugin_modules().items(): - plugin_id, plugin_name = plugin - if method in module_dict: - func = module_dict[method] - if func: - try: - logger.info(f"请求插件 {plugin_name} 执行:{method} ...") - if self.__is_valid_empty(result): - # 返回None,第一次执行或者需继续执行下一模块 - if inspect.iscoroutinefunction(func): - result = await func(*args, **kwargs) - else: - # 插件同步函数在异步环境中运行,避免阻塞 - result = await run_in_threadpool(func, *args, **kwargs) - elif isinstance(result, list): - # 返回为列表,有多个模块运行结果时进行合并 - if inspect.iscoroutinefunction(func): - temp = await func(*args, **kwargs) - else: - # 插件同步函数在异步环境中运行,避免阻塞 - temp = await run_in_threadpool(func, *args, **kwargs) - if isinstance(temp, list): - result.extend(temp) - else: - break - except RateLimitExceededException as err: - self.__handle_rate_limit_error( - err, "插件", plugin_id, method, **kwargs - ) - except Exception as err: - self.__handle_plugin_error( - err, plugin_id, plugin_name, method, **kwargs - ) - return result - - def __execute_system_modules( - self, method: str, result: Any, *args, **kwargs - ) -> Any: - """ - 执行系统模块 - """ - logger.debug(f"请求系统模块执行:{method} ...") - for module in sorted( - self.modulemanager.get_running_modules(method), - key=lambda x: x.get_priority(), - ): - module_id = module.__class__.__name__ - try: - module_name = module.get_name() - except Exception as err: - logger.debug(f"获取模块名称出错:{str(err)}") - module_name = module_id - try: - func = getattr(module, method) - if self.__is_valid_empty(result): - # 返回None,第一次执行或者需继续执行下一模块 - result = func(*args, **kwargs) - elif ObjectUtils.check_signature(func, result): - # 返回结果与方法签名一致,将结果传入 - result = func(result) - elif isinstance(result, list): - # 返回为列表,有多个模块运行结果时进行合并 - temp = func(*args, **kwargs) - if isinstance(temp, list): - result.extend(temp) - else: - # 中止继续执行 - break - except RateLimitExceededException as err: - self.__handle_rate_limit_error( - err, "模块", module_id, method, **kwargs - ) - except Exception as err: - logger.error(traceback.format_exc()) - self.__handle_system_error( - err, module_id, module_name, method, **kwargs - ) - return result - - async def __async_execute_system_modules( - self, method: str, result: Any, *args, **kwargs - ) -> Any: - """ - 异步执行系统模块 - """ - logger.debug(f"请求系统模块执行:{method} ...") - for module in sorted( - self.modulemanager.get_running_modules(method), - key=lambda x: x.get_priority(), - ): - module_id = module.__class__.__name__ - try: - module_name = module.get_name() - except Exception as err: - logger.debug(f"获取模块名称出错:{str(err)}") - module_name = module_id - try: - func = getattr(module, method) - if self.__is_valid_empty(result): - # 返回None,第一次执行或者需继续执行下一模块 - if inspect.iscoroutinefunction(func): - result = await func(*args, **kwargs) - else: - # 系统同步模块在异步路径里也必须切到线程池,避免阻塞共享事件循环。 - result = await run_in_threadpool(func, *args, **kwargs) - elif ObjectUtils.check_signature(func, result): - # 返回结果与方法签名一致,将结果传入 - if inspect.iscoroutinefunction(func): - result = await func(result) - else: - result = await run_in_threadpool(func, result) - elif isinstance(result, list): - # 返回为列表,有多个模块运行结果时进行合并 - if inspect.iscoroutinefunction(func): - temp = await func(*args, **kwargs) - else: - temp = await run_in_threadpool(func, *args, **kwargs) - if isinstance(temp, list): - result.extend(temp) - else: - # 中止继续执行 - break - except RateLimitExceededException as err: - self.__handle_rate_limit_error( - err, "模块", module_id, method, **kwargs - ) - except Exception as err: - logger.error(traceback.format_exc()) - self.__handle_system_error( - err, module_id, module_name, method, **kwargs - ) - return result - def run_module( self, method: str, @@ -379,15 +195,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, :param method: 模块方法名称 """ - # 执行插件模块 - result = self.__execute_plugin_modules(method, None, *args, **kwargs) - - if not self.__is_valid_empty(result) and not isinstance(result, list): - # 插件模块返回结果不为空且不是列表,直接返回 - return result - - # 执行系统模块 - return self.__execute_system_modules(method, result, *args, **kwargs) + return self._module_dispatcher.dispatch(method, *args, **kwargs) async def async_run_module( self, @@ -402,18 +210,10 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, :param method: 模块方法名称 """ - # 执行插件模块 - result = await self.__async_execute_plugin_modules( - method, None, *args, **kwargs - ) - - if not self.__is_valid_empty(result) and not isinstance(result, list): - # 插件模块返回结果不为空且不是列表,直接返回 - return result - - # 执行系统模块 - return await self.__async_execute_system_modules( - method, result, *args, **kwargs + return await self._module_dispatcher.async_dispatch( + method, + *args, + **kwargs, ) def match_doubaninfo( diff --git a/app/chain/_messaging.py b/app/chain/_messaging.py index 61a7358b1..cc5bccdfd 100644 --- a/app/chain/_messaging.py +++ b/app/chain/_messaging.py @@ -16,7 +16,9 @@ from app.application.messaging.message import MessageTemplateHelper from app.runtime.config import settings from app.runtime.extensions.service_registry import ServiceConfigHelper from app.runtime.log import logger -from app.schemas import MessageResponse, Message, TransferInfo +from app.schemas.message import MessageResponse +from app.schemas.message import Message +from app.schemas.transfer import TransferInfo from app.schemas.message import ChannelCapability, ChannelCapabilityManager from app.schemas.types import EventType, NotificationChannel diff --git a/app/chain/_music.py b/app/chain/_music.py index 1fd4be24f..c8a489aec 100644 --- a/app/chain/_music.py +++ b/app/chain/_music.py @@ -2,6 +2,10 @@ import copy from typing import Any, List, Optional, Tuple from app.application.torrent import TorrentHelper +from app.application.subscription.contract import ( + build_subscribe_meta, + subscribe_media_key, +) from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.search import SearchChain @@ -37,8 +41,8 @@ class MusicSubscribeMixin: 该域方法通过 self 复用 SubscribeChain 主体的 get_sub_sites / get_params / filter_torrents / check_and_handle_existing_media / finish_subscribe_or_not / get_subscribe_source_keyword 等编排能力,因此仅作为 mixin 混入 SubscribeChain, - 不独立成链。build_subscribe_meta / _subscribe_media_key 等订阅通用辅助仍保留在 - subscribe.py,方法内延迟导入以避免 _music ↔ subscribe 的模块级循环。 + 不独立成链。订阅元数据与媒体键由 Application 共享契约提供,避免 mixin 与 + SubscribeChain 主体形成双向模块依赖。 """ @staticmethod @@ -100,8 +104,6 @@ class MusicSubscribeMixin: @staticmethod def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]: """按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。""" - # 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环 - from app.chain.subscribe import build_subscribe_meta if subscribe.media_source and subscribe.media_id: # 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情 mediainfo = MediaChain().recognize_media( @@ -131,8 +133,6 @@ class MusicSubscribeMixin: @staticmethod async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]: """异步按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。""" - # 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环 - from app.chain.subscribe import build_subscribe_meta if subscribe.media_source and subscribe.media_id: # 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情 mediainfo = await MediaChain().async_recognize_media( @@ -219,8 +219,6 @@ class MusicSubscribeMixin: subscribe: Subscribe, ) -> Optional[Tuple[MusicInfo, MetaMusic]]: """识别音乐订阅目标、同步实体快照,并在搜索前处理已完整入库的目标。""" - # 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环 - from app.chain.subscribe import _subscribe_media_key mediainfo = self._recognize_music_subscribe(subscribe) if not mediainfo: logger.warning( @@ -241,7 +239,7 @@ class MusicSubscribeMixin: subscribe=subscribe, meta=meta, mediainfo=mediainfo, - mediakey=_subscribe_media_key(subscribe), + mediakey=subscribe_media_key(subscribe), ) if exists: return None diff --git a/app/chain/_transfer.py b/app/chain/_transfer.py index 241283cef..5f2bbb824 100644 --- a/app/chain/_transfer.py +++ b/app/chain/_transfer.py @@ -12,7 +12,8 @@ from copy import deepcopy from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple, Union -from app import schemas +from app.schemas.history import DownloadHistory as _SchemaDownloadHistory +from app.schemas.transfer import EpisodeFormatRule as _SchemaEpisodeFormatRule from app.adapters.system.host import SystemUtils from app.application.agent import build_manual_redo_prompt, get_running_agent_manager from app.application.formatting import EpisodeFormatRuleHelper @@ -33,19 +34,17 @@ from app.domain.meta.metamusic import MetaMusic from app.foundation import text as text_tools from app.runtime.config import global_vars, settings from app.runtime.log import logger -from app.schemas import ( - FileItem, - Message, - TmdbEpisode, - TransferInfo, -) -from app.schemas.agent import ReplyMode +from app.schemas.workflow import FileItem +from app.schemas.message import Message +from app.schemas.tmdb import TmdbEpisode +from app.schemas.transfer import TransferInfo from app.schemas.types import ( MUSIC_ENTITY_ALBUM, EventType, MediaSource, MediaType, NotificationChannel, + ReplyMode, SystemConfigKey, ) @@ -722,17 +721,17 @@ class EpisodeFormatMixin: return state, errmsg, data @staticmethod - def _get_episode_format_rules() -> List[schemas.EpisodeFormatRule]: + def _get_episode_format_rules() -> List[_SchemaEpisodeFormatRule]: """ 获取启用的集数定位规则 """ rule_items = SystemConfigOper().get(SystemConfigKey.EpisodeFormatRuleTable) or [] - rules: List[schemas.EpisodeFormatRule] = [] + rules: List[_SchemaEpisodeFormatRule] = [] for item in rule_items: if not isinstance(item, dict): continue try: - rule = schemas.EpisodeFormatRule(**item) + rule = _SchemaEpisodeFormatRule(**item) except Exception as err: logger.warn(f"忽略无效的集数定位规则:{err}") continue @@ -941,7 +940,7 @@ class HistoryMatchMixin: # 两种 DownloadHistory 都会进来:库模型(本文件按 ORM 行查历史)与 # schemas DTO(TransferTask.download_history)。本函数只按 getattr 取 # year 与 type,对两者一视同仁 - media: Union[DownloadHistory, schemas.DownloadHistory, MediaInfo, MusicInfo] + media: Union[DownloadHistory, _SchemaDownloadHistory, MediaInfo, MusicInfo] ) -> bool: """ 判断文件名年份是否与已识别电影年份冲突。 diff --git a/app/chain/anilist.py b/app/chain/anilist.py index 597667abc..74f803e3f 100644 --- a/app/chain/anilist.py +++ b/app/chain/anilist.py @@ -1,6 +1,6 @@ from typing import Optional -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.chain import ChainBase from app.domain.context import MediaInfo @@ -86,7 +86,7 @@ class AniListChain(ChainBase): def credits( self, anilist_id: int, page: int = 1, count: int = 20 - ) -> list[schemas.MediaPerson]: + ) -> list[_SchemaMediaPerson]: """ 获取 AniList 动画配音演员。 @@ -98,7 +98,7 @@ class AniListChain(ChainBase): async def async_credits( self, anilist_id: int, page: int = 1, count: int = 20 - ) -> list[schemas.MediaPerson]: + ) -> list[_SchemaMediaPerson]: """ 异步获取 AniList 动画配音演员。 @@ -135,7 +135,7 @@ class AniListChain(ChainBase): count=count, ) or [] - def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + def person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ 获取 AniList 人物详情。 @@ -143,7 +143,7 @@ class AniListChain(ChainBase): """ return self.run_module("anilist_person_detail", person_id=person_id) - async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ 异步获取 AniList 人物详情。 diff --git a/app/chain/bangumi.py b/app/chain/bangumi.py index 11944203c..a154ca976 100644 --- a/app/chain/bangumi.py +++ b/app/chain/bangumi.py @@ -1,6 +1,6 @@ from typing import Optional, List -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.chain import ChainBase from app.domain.context import MediaInfo @@ -30,7 +30,7 @@ class BangumiChain(ChainBase): """ return self.run_module("bangumi_info", bangumiid=bangumiid) - def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]: + def bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]: """ 根据BangumiID查询电影演职员表 :param bangumiid: BangumiID @@ -44,7 +44,7 @@ class BangumiChain(ChainBase): """ return self.run_module("bangumi_recommend", bangumiid=bangumiid) - def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + def person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ 根据人物ID查询Bangumi人物详情 :param person_id: 人物ID @@ -78,7 +78,7 @@ class BangumiChain(ChainBase): """ return await self.async_run_module("async_bangumi_info", bangumiid=bangumiid) - async def async_bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]: + async def async_bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]: """ 根据BangumiID查询电影演职员表(异步版本) :param bangumiid: BangumiID @@ -92,7 +92,7 @@ class BangumiChain(ChainBase): """ return await self.async_run_module("async_bangumi_recommend", bangumiid=bangumiid) - async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ 根据人物ID查询Bangumi人物详情(异步版本) :param person_id: 人物ID diff --git a/app/chain/dashboard.py b/app/chain/dashboard.py index 06b292b38..0a4a6f3c3 100644 --- a/app/chain/dashboard.py +++ b/app/chain/dashboard.py @@ -1,6 +1,7 @@ from typing import Optional, List -from app import schemas +from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo +from app.schemas.dashboard import Statistic as _SchemaStatistic from app.chain import ChainBase @@ -8,13 +9,13 @@ class DashboardChain(ChainBase): """ 各类仪表板统计处理链 """ - def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: + def media_statistic(self, server: Optional[str] = None) -> Optional[List[_SchemaStatistic]]: """ 媒体数量统计 """ return self.run_module("media_statistic", server=server) - def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]: + def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[_SchemaDownloaderInfo]]: """ 下载器信息 """ diff --git a/app/chain/douban.py b/app/chain/douban.py index 663286642..d46ce7755 100644 --- a/app/chain/douban.py +++ b/app/chain/douban.py @@ -1,11 +1,10 @@ from typing import Any, List, Optional -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.chain import ChainBase from app.domain.context import MediaInfo, MusicAlbumInfo, MusicInfo from app.domain.meta.metamusic import MetaMusic -from app.schemas import MediaType -from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource +from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType class DoubanChain(ChainBase): @@ -223,7 +222,7 @@ class DoubanChain(ChainBase): return None return album - def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + def person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ 根据人物ID查询豆瓣人物详情 :param person_id: 人物ID @@ -296,14 +295,14 @@ class DoubanChain(ChainBase): """ return self.run_module("tv_hot", page=page, count=count) - def movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]: + def movie_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]: """ 根据TMDBID查询电影演职人员 :param doubanid: 豆瓣ID """ return self.run_module("douban_movie_credits", doubanid=doubanid) - def tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]: + def tv_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]: """ 根据TMDBID查询电视剧演职人员 :param doubanid: 豆瓣ID @@ -324,7 +323,7 @@ class DoubanChain(ChainBase): """ return self.run_module("douban_tv_recommend", doubanid=doubanid) - async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ 根据人物ID查询豆瓣人物详情(异步版本) :param person_id: 人物ID @@ -404,14 +403,14 @@ class DoubanChain(ChainBase): """ return await self.async_run_module("async_tv_hot", page=page, count=count) - async def async_movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]: + async def async_movie_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]: """ 根据TMDBID查询电影演职人员(异步版本) :param doubanid: 豆瓣ID """ return await self.async_run_module("async_douban_movie_credits", doubanid=doubanid) - async def async_tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]: + async def async_tv_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]: """ 根据TMDBID查询电视剧演职人员(异步版本) :param doubanid: 豆瓣ID diff --git a/app/chain/download.py b/app/chain/download.py index c15e71f11..67aef813f 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -9,7 +9,9 @@ from pathlib import Path from typing import TYPE_CHECKING, List, Optional, Tuple, Set, Dict, Union from urllib.parse import parse_qs, urlencode, urljoin, urlparse -from app import schemas +from app.schemas.transfer import DownloaderTorrent as _SchemaDownloaderTorrent +from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf +from app.schemas.workflow import FileItem as _SchemaFileItem from app.chain import ChainBase from app.chain.media import MediaChain from app.chain.storage import StorageChain @@ -30,11 +32,17 @@ from app.db.oper.downloadfailure import DownloadFailureOper from app.db.oper.downloadhistory import DownloadHistoryOper from app.db.oper.mediaserver import MediaServerOper from app.application.directory import DirectoryHelper, validate_download_save_path +from app.application.download.tasks import DownloadTaskService from app.runtime.thread import ThreadHelper from app.application.torrent import TorrentHelper from app.runtime.log import logger -from app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloaderTorrent, Message, ResourceSelectionEventData, \ - ResourceDownloadEventData +from app.schemas.mediaserver import ExistMediaInfo +from app.schemas.file import FileURI +from app.schemas.mediaserver import NotExistMediaInfo +from app.schemas.transfer import DownloaderTorrent +from app.schemas.message import Message +from app.schemas.event import ResourceSelectionEventData +from app.schemas.event import ResourceDownloadEventData from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, TorrentStatus, EventType, NotificationChannel, MessageType, ContentType, \ ChainEventType from app.adapters.network.http import RequestUtils @@ -221,7 +229,7 @@ class DownloadChain(ChainBase): storage_chain: StorageChain, storage: str, target_path: Path, - ) -> Tuple[Optional[schemas.FileItem], str]: + ) -> Tuple[Optional[_SchemaFileItem], str]: """ 获取字幕保存目录,返回失败原因供前端展示。 """ @@ -296,7 +304,7 @@ class DownloadChain(ChainBase): @staticmethod def _append_download_classification( root_path: Path, - dir_info: schemas.TransferDirectoryConf, + dir_info: _SchemaTransferDirectoryConf, media_info: MediaInfo, ) -> Path: """ @@ -318,7 +326,7 @@ class DownloadChain(ChainBase): def _upload_subtitle_file( storage_chain: StorageChain, storage: str, - working_dir_item: schemas.FileItem, + working_dir_item: _SchemaFileItem, subtitle_file: Path, ) -> Tuple[Optional[str], str]: """ @@ -2030,51 +2038,33 @@ class DownloadChain(ChainBase): """ 查询正在下载的任务 """ - torrents = self.list_torrents(downloader=name, status=TorrentStatus.DOWNLOADING) - if not torrents: - return [] - - history_map = DownloadHistoryOper().get_by_hashes( - [torrent.hash for torrent in torrents if torrent.hash] - ) - ret_torrents = [] - for torrent in torrents: - history = history_map.get(torrent.hash) - if history: - # 媒体信息 - torrent.media = { - "media_source": history.media_source, - "media_id": history.media_id, - "type": history.type, - "title": history.title, - "season": history.seasons, - "episode": history.episodes, - "image": history.poster, - "poster": history.poster, - "backdrop": history.image, - } - torrent.site_name = history.torrent_site - # 下载用户 - torrent.userid = history.userid - torrent.username = history.username - ret_torrents.append(torrent) - return ret_torrents + return self._download_task_service().downloading(name) def set_downloading(self, hash_str, oper: str, name: Optional[str] = None) -> bool: """ 控制下载任务 start/stop """ - if oper == "start": - return self.start_torrents(hashs=[hash_str], downloader=name) - elif oper == "stop": - return self.stop_torrents(hashs=[hash_str], downloader=name) - return False + return self._download_task_service().set_downloading( + hash_str, + oper, + name, + ) def remove_downloading(self, hash_str: str, name: Optional[str] = None) -> bool: """ 删除下载任务 """ - return self.remove_torrents(hashs=[hash_str], downloader=name) + return self._download_task_service().remove_downloading(hash_str, name) + + def _download_task_service(self) -> DownloadTaskService: + """构造绑定当前下载器能力与历史仓储的任务服务。""" + return DownloadTaskService( + list_torrents=self.list_torrents, + get_history_by_hashes=DownloadHistoryOper().get_by_hashes, + start_torrents=self.start_torrents, + stop_torrents=self.stop_torrents, + remove_torrents=self.remove_torrents, + ) @eventmanager.register(EventType.DownloadFileDeleted) def download_file_deleted(self, event: Event): @@ -2088,7 +2078,7 @@ class DownloadChain(ChainBase): return logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}") # 先查询种子 - torrents: List[schemas.DownloaderTorrent] = self.list_torrents(hashs=[hash_str]) + torrents: List[_SchemaDownloaderTorrent] = self.list_torrents(hashs=[hash_str]) if torrents: self.remove_torrents(hashs=[hash_str], delete_file=False) # 发出下载任务删除事件,如需处理辅种,可监听该事件 diff --git a/app/chain/interaction.py b/app/chain/interaction.py index ffd5cfd80..067ebdbdf 100644 --- a/app/chain/interaction.py +++ b/app/chain/interaction.py @@ -21,7 +21,10 @@ from app.domain.meta.metabase import MetaBase from app.foundation import url as url_tools from app.runtime.config import settings from app.runtime.log import logger -from app.schemas import DownloadDirectory, FileURI, NotExistMediaInfo, Message +from app.schemas.download import DownloadDirectory +from app.schemas.file import FileURI +from app.schemas.mediaserver import NotExistMediaInfo +from app.schemas.message import Message from app.schemas.media import build_media_key, resolve_media_identity from app.schemas.notification import ChannelCapabilityManager from app.schemas.system import TransferDirectoryConf diff --git a/app/chain/media.py b/app/chain/media.py index c8afc0c1e..ad688776a 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -1,4 +1,3 @@ -import asyncio from copy import deepcopy from pathlib import Path from threading import Lock @@ -6,7 +5,7 @@ from typing import Any, Iterable, List, Optional, Tuple, Union from fastapi.concurrency import run_in_threadpool -from app import schemas +from app.schemas.event import MediaRecognizeConvertEventData as _SchemaMediaRecognizeConvertEventData from app.chain import ChainBase from app.chain.acoustid import AcoustIdChain from app.chain.douban import DoubanChain @@ -26,13 +25,11 @@ from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo, MetaInfoPath from app.application.audio import AudioMetadataHelper +from app.application.music.catalog import MusicCatalogService from app.runtime.log import logger -from app.schemas import FileItem from app.schemas.types import ( - MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, ChainEventType, - EventType, MediaSource, MediaSourceSelection, MediaType, @@ -92,19 +89,11 @@ class MediaChain(ChainBase, metaclass=Singleton): media_source: Optional[MediaSourceSelection], ) -> list[MediaSource]: """解析有序音乐搜索来源集合,保留合法插件扩展来源并去重。""" - if not media_source: - return [cls._music_primary_source] - raw_sources = ( - (media_source,) - if isinstance(media_source, MediaSource) - else media_source - ) - sources: list[MediaSource] = [] - for raw_source in raw_sources: - source = normalize_media_source(raw_source) - if source and source not in sources: - sources.append(source) - return sources + return MusicCatalogService( + source_resolver=cls._music_source_chain, + warning=logger.warning, + primary_source=cls._music_primary_source, + ).search_sources(media_source) @staticmethod async def _async_search_music_source( @@ -127,29 +116,15 @@ class MediaChain(ChainBase, metaclass=Singleton): limit: Optional[int] = None, ) -> list[MusicInfo]: """标准化并按来源身份或元数据去重音乐候选。""" - results: list[MusicInfo] = [] - identities: set[tuple[str, ...]] = set() - for candidate in candidates or []: - info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate) - if info.media_source and info.media_id: - identity = ( - "id", str(info.media_source).casefold(), - str(info.music_type).casefold(), str(info.media_id).casefold(), - ) - else: - identity = ( - "metadata", str(info.music_type).casefold(), - MetaMusic.compact_text(info.title), - MetaMusic.compact_text(info.artist), - MetaMusic.compact_text(info.album), - ) - if identity in identities: - continue - identities.add(identity) - results.append(info) - if limit and len(results) >= limit: - break - return results + return MusicCatalogService.normalize_candidates(candidates, limit) + + def _music_catalog(self) -> MusicCatalogService: + """构造绑定当前来源解析规则的音乐目录服务。""" + return MusicCatalogService( + source_resolver=self._music_source_chain, + warning=logger.warning, + primary_source=self._music_primary_source, + ) def search_music( self, @@ -158,20 +133,7 @@ class MediaChain(ChainBase, metaclass=Singleton): media_source: Optional[MediaSourceSelection] = None, ) -> list[MusicInfo]: """按一个或多个音乐来源搜索候选,未指定时使用 MusicBrainz。""" - meta = MetaMusic.parse_query(query) - candidates: list[MusicInfo] = [] - for source in self._music_search_sources(media_source): - chain = self._music_source_chain(source) - if not chain: - continue - try: - candidates.extend(chain.search_music(meta, limit=limit)) - except Exception as err: - logger.warning(f"音乐来源 {source} 搜索失败:{str(err)}") - return self.normalize_music_candidates( - candidates, - limit=limit, - ) + return self._music_catalog().search(query, limit, media_source) async def async_search_music( self, @@ -180,18 +142,10 @@ class MediaChain(ChainBase, metaclass=Singleton): media_source: Optional[MediaSourceSelection] = None, ) -> list[MusicInfo]: """并行搜索一个或多个音乐来源,单一来源失败不影响其它结果。""" - meta = MetaMusic.parse_query(query) - searches = [] - for source in self._music_search_sources(media_source): - chain = self._music_source_chain(source) - if chain: - searches.append( - self._async_search_music_source(chain, source, meta, limit) - ) - source_results = await asyncio.gather(*searches) if searches else [] - return self.normalize_music_candidates( - [candidate for results in source_results for candidate in results], - limit=limit, + return await self._music_catalog().async_search( + query, + limit, + media_source, ) @staticmethod @@ -1540,7 +1494,7 @@ class MediaChain(ChainBase, metaclass=Singleton): mtype=mtype or MediaInfo.get_bangumi_media_type(source_info), season=season if season is not None else meta.begin_season, ) - event_data = schemas.MediaRecognizeConvertEventData( + event_data = _SchemaMediaRecognizeConvertEventData( media_source=media_source, media_id=media_id, target_media_source=target_source, @@ -2086,7 +2040,7 @@ class MediaChain(ChainBase, metaclass=Singleton): mtype=mtype or MediaInfo.get_bangumi_media_type(source_info), season=season if season is not None else meta.begin_season, ) - event_data = schemas.MediaRecognizeConvertEventData( + event_data = _SchemaMediaRecognizeConvertEventData( media_source=media_source, media_id=media_id, target_media_source=target_source, diff --git a/app/chain/mediaserver.py b/app/chain/mediaserver.py index e46b3a4a1..3655fa14c 100644 --- a/app/chain/mediaserver.py +++ b/app/chain/mediaserver.py @@ -7,7 +7,10 @@ from app.runtime.config import global_vars from app.db.oper.mediaserver import MediaServerOper from app.runtime.extensions.service_registry import ServiceConfigHelper from app.runtime.log import logger -from app.schemas import MediaServerLibrary, MediaServerItem, MediaServerSeasonInfo, MediaServerPlayItem +from app.schemas.mediaserver import MediaServerLibrary +from app.schemas.mediaserver import MediaServerItem +from app.schemas.mediaserver import MediaServerSeasonInfo +from app.schemas.mediaserver import MediaServerPlayItem from app.schemas.types import MediaType from app.application.security.url import SecurityUtils diff --git a/app/chain/message.py b/app/chain/message.py index 53068fb44..135669742 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -1,12 +1,10 @@ import asyncio import base64 -import math import mimetypes import re -import time import uuid from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime from pathlib import Path from typing import Any, Optional, Dict, Union, List, Tuple from urllib.parse import unquote, urlparse @@ -18,37 +16,26 @@ from app.application.agent import ( transcribe_audio, ) from app.chain import ChainBase -from app.chain.download import DownloadChain -from app.chain.media import MediaChain -from app.chain.search import SearchChain from app.chain.site import SiteChain from app.chain.subscribe import SubscribeChain from app.chain.transfer import TransferChain from app.chain.interaction import MediaInteractionChain as _MediaInteractionChain from app.runtime.config import settings, global_vars -from app.domain.context import MediaInfo, Context -from app.domain.meta.metabase import MetaBase -from app.db.oper.user import UserOper -from app.application.directory import DirectoryHelper from app.application.messaging.agent import agent_interaction_manager, parse_agent_choice_callback from app.application.messaging.interaction import InteractionContext, InteractionDispatch from app.application.messaging.media import media_interaction_manager from app.application.messaging.plugin import PluginInputInteractionHandler from app.application.messaging.router import CallbackRoute, InteractionRouter, SessionRoute +from app.application.messaging.session import MessageSessionService from app.application.messaging.site import site_interaction_manager from app.application.messaging.skill import SkillInteractionHandler, skill_interaction_manager from app.application.messaging.subscribe import subscribe_interaction_manager -from app.application.torrent import TorrentHelper from app.runtime.log import logger -from app.schemas import IncomingMessage, DownloadDirectory, FileURI, NotExistMediaInfo, Message +from app.schemas.message import IncomingMessage +from app.schemas.message import Message from app.schemas.notification import ChannelCapabilityManager -from app.schemas.system import TransferDirectoryConf -from app.schemas.types import EventType, NotificationChannel, MediaType +from app.schemas.types import EventType, NotificationChannel from app.adapters.network.http import RequestUtils -from app.schemas.media import build_media_key, resolve_media_identity -from app.domain import episode as episode_rules -from app.domain import title as title_rules -from app.foundation import url as url_tools class MessageChain(ChainBase): @@ -91,12 +78,15 @@ class MessageChain(ChainBase): """ 清理超过复用窗口的用户会话映射,并同步释放旧 Agent 实例。 """ - timeout = timedelta(minutes=self._session_timeout_minutes) - for userid, (session_id, last_time) in list(self._user_sessions.items()): - if current_time - last_time <= timeout: - continue - self._user_sessions.pop(userid, None) - self._schedule_agent_session_clear(session_id, userid) + self._message_session_service().cleanup(current_time) + + def _message_session_service(self) -> MessageSessionService: + """用类级兼容映射构建可测试的用户会话服务。""" + return MessageSessionService( + sessions=self._user_sessions, + timeout_minutes=self._session_timeout_minutes, + expired_handler=self._schedule_agent_session_clear, + ) @dataclass class _ProcessingStatus: @@ -874,39 +864,21 @@ class MessageChain(ChainBase): 获取或创建会话ID 如果用户上次会话在15分钟内,则复用相同的会话ID;否则创建新的会话ID """ - current_time = datetime.now() - self._cleanup_expired_user_sessions(current_time) - - # 检查用户是否有已存在的会话 - if userid in self._user_sessions: - session_id, last_time = self._user_sessions[userid] - - # 计算时间差 - time_diff = current_time - last_time - - # 如果时间差小于等于xx分钟,复用会话ID - if time_diff <= timedelta(minutes=self._session_timeout_minutes): - # 更新最后使用时间 - self._user_sessions[userid] = (session_id, current_time) - logger.info( - f"复用会话ID: {session_id}, 用户: {userid}, 距离上次会话: {time_diff.total_seconds() / 60:.1f}分钟" - ) - return session_id - - # 创建新的会话ID - new_session_id = f"user_{userid}_{int(time.time())}" - self._user_sessions[userid] = (new_session_id, current_time) - logger.info(f"创建新会话ID: {new_session_id}, 用户: {userid}") - return new_session_id + resolution = self._message_session_service().resolve(userid) + if resolution.reused: + logger.info( + f"复用会话ID: {resolution.session_id}, 用户: {userid}, " + f"距离上次会话: {resolution.inactive_minutes:.1f}分钟" + ) + else: + logger.info(f"创建新会话ID: {resolution.session_id}, 用户: {userid}") + return resolution.session_id def _bind_session_id(self, userid: Union[str, int], session_id: str) -> None: """ 将用户会话绑定到指定的 session_id,并刷新最后活动时间。 """ - old_session = self._user_sessions.get(userid) - if old_session and old_session[0] != session_id: - self._schedule_agent_session_clear(old_session[0], userid) - self._user_sessions[userid] = (session_id, datetime.now()) + self._message_session_service().bind(userid, session_id) def bind_user_session(self, userid: Union[str, int], session_id: str) -> None: """ @@ -951,8 +923,8 @@ class MessageChain(ChainBase): 清除指定用户的会话信息 返回是否成功清除 """ - if userid in self._user_sessions: - session_id, _ = self._user_sessions.pop(userid) + session_id = self._message_session_service().clear(userid) + if session_id: logger.info(f"已清除用户 {userid} 的会话: {session_id}") return True return False @@ -967,9 +939,8 @@ class MessageChain(ChainBase): 清除用户会话(远程命令接口) """ # 获取并清除会话信息 - session_id = None - if userid in self._user_sessions: - session_id, _ = self._user_sessions.pop(userid) + session_id = self._message_session_service().clear(userid) + if session_id: logger.info(f"已清除用户 {userid} 的会话: {session_id}") # 如果有会话ID,同时清除智能体的会话记忆 @@ -1022,7 +993,7 @@ class MessageChain(ChainBase): 停止后用户仍可继续对话。 """ # 查找用户的会话ID(不弹出,保留会话) - session_info = self._user_sessions.get(userid) + session_info = self._message_session_service().get(userid) if session_info: session_id, _ = session_info manager = get_running_agent_manager() @@ -1182,7 +1153,7 @@ class MessageChain(ChainBase): source: Optional[str] = None, ): """查询当前用户的智能体会话状态。""" - session_info = self._user_sessions.get(userid) + session_info = self._message_session_service().get(userid) if not session_info: self.post_message( Message( diff --git a/app/chain/recommend.py b/app/chain/recommend.py index 91bcb4dda..ce8732f55 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -1,6 +1,6 @@ from typing import Callable, List, Optional -import pillow_avif # noqa 用于自动注册AVIF支持 +import pillow_avif # noqa: F401 # pylint: disable=unused-import # AVIF 注册副作用 from app.chain import ChainBase from app.chain.bangumi import BangumiChain @@ -12,11 +12,11 @@ from app.runtime.config import settings, global_vars from app.domain.context import MusicInfo from app.application.image import ImageHelper from app.runtime.log import logger -from app.schemas import MediaType from app.schemas.types import ( MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, + MediaType, ) from app.runtime.execution import log_execution_time from app.schemas.media import normalize_media_source diff --git a/app/chain/scraping.py b/app/chain/scraping.py index d1d1f8f1a..ce919c0b1 100644 --- a/app/chain/scraping.py +++ b/app/chain/scraping.py @@ -7,16 +7,13 @@ from tempfile import NamedTemporaryFile, TemporaryDirectory from threading import Lock from typing import Any, Iterable, List, Optional, Tuple, Union -from fastapi.concurrency import run_in_threadpool - -from app import schemas +from app.schemas.workflow import FileItem as _SchemaFileItem from app.chain import ChainBase from app.chain.lrclib import LrclibChain from app.chain.storage import StorageChain -from app.runtime.cache import async_fresh, cached, fresh +from app.runtime.cache import cached from app.runtime.config import settings from app.domain.context import ( - Context, MediaInfo, MusicAlbumInfo, MusicInfo, @@ -29,11 +26,10 @@ from app.domain.metainfo import MetaInfo, MetaInfoPath from app.db.oper.systemconfig import SystemConfigOper from app.application.audio import AudioMetadataHelper from app.runtime.log import logger -from app.schemas import FileItem +from app.schemas.workflow import FileItem from app.schemas.types import ( MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, - ChainEventType, EventType, MediaSource, MediaType, @@ -43,8 +39,7 @@ from app.schemas.types import ( SystemConfigKey, ) from app.adapters.network.http import RequestUtils -from app.domain.media import is_music_media_source -from app.schemas.media import normalize_media_source, resolve_media_identity +from app.schemas.media import resolve_media_identity from app.runtime.reload import ConfigReloadMixin from app.foundation.singleton import Singleton @@ -267,7 +262,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return False def _save_file( - self, fileitem: schemas.FileItem, path: Path, content: Union[bytes, str] + self, fileitem: _SchemaFileItem, path: Path, content: Union[bytes, str] ): """ 保存或上传文件 @@ -305,7 +300,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): self._cleanup_temp_file(tmp_file_path) def _download_and_save_image( - self, fileitem: schemas.FileItem, path: Path, url: str + self, fileitem: _SchemaFileItem, path: Path, url: str ): """ 流式下载图片并保存到文件 @@ -354,12 +349,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _get_target_fileitem_and_path( self, - current_fileitem: schemas.FileItem, + current_fileitem: _SchemaFileItem, item_type: ScrapingTarget, metadata_type: ScrapingMetadata, filename_hint: Optional[str] = None, - parent_fileitem: Optional[schemas.FileItem] = None, - ) -> Tuple[schemas.FileItem, Optional[Path]]: + parent_fileitem: Optional[_SchemaFileItem] = None, + ) -> Tuple[_SchemaFileItem, Optional[Path]]: """ 根据当前上下文、刮削项类型和元数据类型生成目标 FileItem 和 Path 处理 NFO 和图片文件的命名约定及存储位置 @@ -460,12 +455,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _get_target_fileitems_and_paths( self, - current_fileitem: schemas.FileItem, + current_fileitem: _SchemaFileItem, item_type: ScrapingTarget, metadata_type: ScrapingMetadata, filename_hint: Optional[str] = None, - parent_fileitem: Optional[schemas.FileItem] = None, - ) -> List[Tuple[schemas.FileItem, Path]]: + parent_fileitem: Optional[_SchemaFileItem] = None, + ) -> List[Tuple[_SchemaFileItem, Path]]: """ 根据刮削上下文生成一个或多个保存目标。 季图片需要同时兼容根目录 seasonxx-poster 和季目录 poster 两种命名。 @@ -508,9 +503,9 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _expand_with_aliases( self, - targets: List[Tuple[schemas.FileItem, Path]], + targets: List[Tuple[_SchemaFileItem, Path]], item_type: ScrapingTarget, - ) -> List[Tuple[schemas.FileItem, Path]]: + ) -> List[Tuple[_SchemaFileItem, Path]]: """ 为兼容多媒体服务器,扩展图片保存目标列表,添加别名文件。 例如 backdrop.jpg 同时保存为 fanart.jpg,thumb.jpg 同时保存为 landscape.jpg。 @@ -738,11 +733,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _scrape_nfo_generic( self, - current_fileitem: schemas.FileItem, + current_fileitem: _SchemaFileItem, meta: MetaBase, mediainfo: MediaInfo, item_type: ScrapingTarget, - parent_fileitem: Optional[schemas.FileItem] = None, + parent_fileitem: Optional[_SchemaFileItem] = None, overwrite: bool = False, season_number: Optional[int] = None, episode_number: Optional[int] = None, @@ -792,10 +787,10 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _scrape_images_generic( self, - current_fileitem: schemas.FileItem, + current_fileitem: _SchemaFileItem, mediainfo: MediaInfo, item_type: ScrapingTarget, - parent_fileitem: Optional[schemas.FileItem] = None, + parent_fileitem: Optional[_SchemaFileItem] = None, overwrite: bool = False, season_number: Optional[int] = None, episode_number: Optional[int] = None, @@ -893,14 +888,14 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def scrape_metadata( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, meta: MetaBase = None, mediainfo: Union[MediaInfo, MusicInfo] = None, init_folder: bool = True, - parent: schemas.FileItem = None, + parent: _SchemaFileItem = None, overwrite: bool = False, recursive: bool = True, - audio_files: Optional[list[schemas.FileItem]] = None, + audio_files: Optional[list[_SchemaFileItem]] = None, media_by_path: Optional[dict[str, MusicInfo]] = None, ) -> tuple[bool, str]: """ @@ -992,11 +987,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def scrape_music_metadata( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, mediainfo: Optional[MusicInfo] = None, overwrite: bool = True, media_source: Optional[MediaSource] = None, - audio_files: Optional[list[schemas.FileItem]] = None, + audio_files: Optional[list[_SchemaFileItem]] = None, media_by_path: Optional[dict[str, MusicInfo]] = None, ) -> tuple[bool, str]: """为音频文件或目录写入音乐标签和封面,应用系统刮削策略,复用现有存储下载上传能力。 @@ -1157,7 +1152,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """判断路径是否指向系统支持的音频文件。""" return Path(path).suffix.lower() in settings.RMT_AUDIOEXT - def _music_audio_fileitems(self, fileitem: schemas.FileItem) -> list[schemas.FileItem]: + def _music_audio_fileitems(self, fileitem: _SchemaFileItem) -> list[_SchemaFileItem]: """展开待刮削目录并过滤系统支持的音频文件。""" if fileitem.type != "dir": return [fileitem] if self._is_music_audio_file(fileitem.path or "") else [] @@ -1170,10 +1165,10 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): @classmethod def _normalize_music_audio_fileitems( cls, - fileitems: Iterable[schemas.FileItem], - ) -> list[schemas.FileItem]: + fileitems: Iterable[_SchemaFileItem], + ) -> list[_SchemaFileItem]: """过滤并按存储路径去重已选音频文件,保持调用方给出的顺序。""" - normalized: list[schemas.FileItem] = [] + normalized: list[_SchemaFileItem] = [] seen: set[tuple[str, str]] = set() for item in fileitems or []: if ( @@ -1191,12 +1186,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _music_event_audio_fileitems( self, - root: schemas.FileItem, + root: _SchemaFileItem, file_list: Iterable[str], - ) -> list[schemas.FileItem]: + ) -> list[_SchemaFileItem]: """把刮削事件中的成功路径恢复为文件项,并限制在事件媒体根目录内。""" root_path = Path(root.path) - selected: list[schemas.FileItem] = [] + selected: list[_SchemaFileItem] = [] for raw_path in file_list or []: audio_path = Path(raw_path) if not self._is_music_audio_file(audio_path.as_posix()): @@ -1208,7 +1203,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): storage=root.storage, path=audio_path, ) - selected.append(item or schemas.FileItem( + selected.append(item or _SchemaFileItem( storage=root.storage, path=audio_path.as_posix(), type="file", @@ -1220,7 +1215,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _scrape_music_file( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, mediainfo: Optional[MusicInfo], write_tags: bool, tag_overwrite: bool, @@ -1285,7 +1280,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _apply_music_file_scrape( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, local_path: Path, mediainfo: Optional[MusicInfo], write_tags: bool, @@ -1479,7 +1474,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _scrape_music_lyrics( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, local_path: Path, scrape_info: Optional[MetaMusic | MusicInfo], lyrics_option: Optional[ScrapingOption], @@ -1516,8 +1511,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _find_music_lyrics_sidecar( self, - fileitem: schemas.FileItem, - ) -> Optional[schemas.FileItem]: + fileitem: _SchemaFileItem, + ) -> Optional[_SchemaFileItem]: """查找音轨旁已存在的同步或纯文本歌词文件。""" audio_path = Path(fileitem.path) for extension in self.MUSIC_LYRICS_EXTENSIONS: @@ -1531,7 +1526,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _write_music_lyrics_sidecar( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, local_path: Path, lyrics: MusicLyrics, overwrite: bool, @@ -1582,7 +1577,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _remove_alternate_music_lyrics( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, keep_extension: str, ) -> None: """覆盖歌词格式后删除同音轨的旧扩展名文件,避免播放器优先读取过期内容。""" @@ -1599,11 +1594,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _handle_movie_scraping( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, meta: MetaBase, mediainfo: MediaInfo, init_folder: bool, - parent: schemas.FileItem, + parent: _SchemaFileItem, overwrite: bool, recursive: bool, ): @@ -1641,7 +1636,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _handle_movie_directory( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, meta: MetaBase, mediainfo: MediaInfo, init_folder: bool, @@ -1688,11 +1683,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _handle_tv_scraping( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, meta: MetaBase, mediainfo: MediaInfo, init_folder: bool, - parent: schemas.FileItem, + parent: _SchemaFileItem, overwrite: bool, recursive: bool, ): @@ -1725,10 +1720,10 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _handle_tv_episode_file( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, filepath: Path, mediainfo: MediaInfo, - parent: schemas.FileItem, + parent: _SchemaFileItem, overwrite: bool, ): """ @@ -1779,12 +1774,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _handle_tv_directory( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, filepath: Path, meta: MetaBase, mediainfo: MediaInfo, init_folder: bool, - parent: schemas.FileItem, + parent: _SchemaFileItem, overwrite: bool, recursive: bool, ): @@ -1823,11 +1818,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _initialize_tv_directory_metadata( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, filepath: Path, meta: MetaBase, mediainfo: MediaInfo, - parent: schemas.FileItem, + parent: _SchemaFileItem, overwrite: bool, ): """ diff --git a/app/chain/search.py b/app/chain/search.py index df4cbb434..4b3e2816c 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -24,9 +24,14 @@ from app.domain.context import MusicInfo from app.db.oper.systemconfig import SystemConfigOper from app.runtime.progress import ProgressHelper from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module +from app.application.search.state import ( + SearchStateService, + normalize_search_params, + stringify_sites, +) from app.application.torrent import TorrentHelper from app.runtime.log import logger -from app.schemas import NotExistMediaInfo +from app.schemas.mediaserver import NotExistMediaInfo from app.schemas.types import ( MUSIC_ENTITY_ALBUM, EventType, @@ -35,7 +40,7 @@ from app.schemas.types import ( ProgressKey, SystemConfigKey, ) -from app.schemas.media import build_media_key, parse_media_key, resolve_media_identity +from app.schemas.media import build_media_key, resolve_media_identity from app.foundation import size as size_tools from app.foundation.text import convert as zhconv_convert @@ -291,7 +296,7 @@ class SearchChain(ChainBase): """ 将站点ID列表转换为前端可直接复用的查询字符串。 """ - return ",".join(str(site) for site in sites) if sites else "" + return stringify_sites(sites) @staticmethod def _normalize_search_params(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]: @@ -299,35 +304,19 @@ class SearchChain(ChainBase): 规范化上次搜索参数,供前端结果页重新搜索使用;旧复合关键字仅在 缓存读取边界转换为独立的媒体来源和原生 ID。 """ - if not isinstance(params, dict): - return None + return normalize_search_params(params) - media_source, media_id = resolve_media_identity( - media_source=params.get("media_source"), - media_id=params.get("media_id"), + def _search_state(self) -> SearchStateService: + """构造绑定当前 Chain 缓存端口的搜索状态服务。""" + return SearchStateService( + save_cache=self.save_cache, + load_cache=self.load_cache, + async_save_cache=self.async_save_cache, + async_load_cache=self.async_load_cache, + params_key=self.__search_params_temp_file, + result_key=self.__result_temp_file, + subtitle_result_key=self.__subtitle_result_temp_file, ) - keyword = str(params.get("keyword") or "") - if not media_source and keyword: - media_source, media_id = parse_media_key(keyword) - if media_source and media_id: - keyword = "" - - normalized = { - "keyword": keyword, - "media_source": str(media_source) if media_source else "", - "media_id": media_id or "", - "type": str(params.get("type") or ""), - "area": str(params.get("area") or ""), - "title": str(params.get("title") or ""), - "year": str(params.get("year") or ""), - "season": str(params["season"]) if params.get("season") is not None else "", - "episode": str(params.get("episode") or ""), - "sites": str(params.get("sites") or ""), - "result_type": str(params.get("result_type") or "torrent"), - } - if params.get("music_type"): - normalized["music_type"] = str(params["music_type"]) - return normalized if normalized["keyword"] or media_id else None def save_last_search_params( self, @@ -348,24 +337,20 @@ class SearchChain(ChainBase): """ 保存最后一次资源搜索参数,标题搜索与精确身份搜索使用互斥字段。 """ - params = self._normalize_search_params( - { - "keyword": keyword, - "media_source": media_source, - "media_id": media_id, - "type": mtype.value if isinstance(mtype, MediaType) else mtype, - "area": area, - "title": title, - "year": year, - "season": season, - "episode": episode, - "sites": self._stringify_sites(sites), - "music_type": music_type, - "result_type": result_type or "torrent", - } + self._search_state().save_params( + keyword=keyword, + media_source=media_source, + media_id=media_id, + mtype=mtype, + area=area, + title=title, + year=year, + season=season, + episode=episode, + sites=sites, + music_type=music_type, + result_type=result_type, ) - if params: - self.save_cache(params, self.__search_params_temp_file) async def async_save_last_search_params( self, @@ -386,38 +371,32 @@ class SearchChain(ChainBase): """ 异步保存最后一次资源搜索参数,标题搜索与精确身份搜索使用互斥字段。 """ - params = self._normalize_search_params( - { - "keyword": keyword, - "media_source": media_source, - "media_id": media_id, - "type": mtype.value if isinstance(mtype, MediaType) else mtype, - "area": area, - "title": title, - "year": year, - "season": season, - "episode": episode, - "sites": self._stringify_sites(sites), - "music_type": music_type, - "result_type": result_type or "torrent", - } + await self._search_state().async_save_params( + keyword=keyword, + media_source=media_source, + media_id=media_id, + mtype=mtype, + area=area, + title=title, + year=year, + season=season, + episode=episode, + sites=sites, + music_type=music_type, + result_type=result_type, ) - if params: - await self.async_save_cache(params, self.__search_params_temp_file) def last_search_params(self) -> Optional[Dict[str, str]]: """ 获取上次搜索使用的参数。 """ - return self._normalize_search_params(self.load_cache(self.__search_params_temp_file)) + return self._search_state().load_params() async def async_last_search_params(self) -> Optional[Dict[str, str]]: """ 异步获取上次搜索使用的参数。 """ - return self._normalize_search_params( - await self.async_load_cache(self.__search_params_temp_file) - ) + return await self._search_state().async_load_params() @staticmethod def _normalize_ai_indices(ai_indices: List[Any]) -> List[int]: @@ -510,7 +489,7 @@ class SearchChain(ChainBase): 通过统一后台提示词机制执行资源推荐。 """ from app.application.agent import get_prompt_manager, get_running_agent_manager - from app.schemas.agent import ReplyMode + from app.schemas.types import ReplyMode prompt = get_prompt_manager().render_system_task_message( "search_recommend", @@ -733,19 +712,19 @@ class SearchChain(ChainBase): """ 获取上次搜索结果 """ - return self.load_cache(self.__result_temp_file) + return self._search_state().load_results() async def async_last_search_results(self) -> Optional[List[Context]]: """ 异步获取上次搜索结果 """ - return await self.async_load_cache(self.__result_temp_file) + return await self._search_state().async_load_results() async def async_last_subtitle_search_results(self) -> Optional[List[SubtitleInfo]]: """ 异步获取上次字幕搜索结果。 """ - return await self.async_load_cache(self.__subtitle_result_temp_file) + return await self._search_state().async_load_subtitle_results() async def async_search_subtitles_by_title(self, title: str, page: Optional[int] = 0, sites: List[int] = None, diff --git a/app/chain/site.py b/app/chain/site.py index 204012473..48b0554cf 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -21,7 +21,9 @@ from app.adapters.external.cookiecloud import CookieCloudHelper from app.application.messaging.site import SiteInteractionHandler from app.application.rss import RssHelper from app.runtime.log import logger -from app.schemas import NotificationChannel, Message, SiteUserData +from app.schemas.notification import NotificationChannel +from app.schemas.message import Message +from app.schemas.site import SiteUserData from app.schemas.types import EventType, MessageType from app.adapters.network.http import RequestUtils from app.domain.site import SiteUtils diff --git a/app/chain/storage.py b/app/chain/storage.py index 6f9c2333c..c6be7e7fd 100644 --- a/app/chain/storage.py +++ b/app/chain/storage.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Any, Optional, List, Dict -from app import schemas +from app.schemas.workflow import FileItem as _SchemaFileItem from app.chain import ChainBase from app.runtime.config import settings from app.application.directory import DirectoryHelper @@ -28,31 +28,31 @@ class StorageChain(ChainBase): result = self.run_module("storage_manage", storage=storage, action=action, **params) return result or {"success": False, "message": "该存储类型未启用或不支持此管理动作"} - def list_files(self, fileitem: schemas.FileItem, recursion: bool = False) -> Optional[List[schemas.FileItem]]: + def list_files(self, fileitem: _SchemaFileItem, recursion: bool = False) -> Optional[List[_SchemaFileItem]]: """ 查询当前目录下所有目录和文件 """ return self.run_module("list_files", fileitem=fileitem, recursion=recursion) - def any_files(self, fileitem: schemas.FileItem, extensions: list = None) -> Optional[bool]: + def any_files(self, fileitem: _SchemaFileItem, extensions: list = None) -> Optional[bool]: """ 查询当前目录下是否存在指定扩展名任意文件 """ return self.run_module("any_files", fileitem=fileitem, extensions=extensions) - def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: + def create_folder(self, fileitem: _SchemaFileItem, name: str) -> Optional[_SchemaFileItem]: """ 创建目录 """ return self.run_module("create_folder", fileitem=fileitem, name=name) - def get_folder(self, storage: str, path: Path) -> Optional[schemas.FileItem]: + def get_folder(self, storage: str, path: Path) -> Optional[_SchemaFileItem]: """ 获取目录,不存在则递归创建 """ return self.run_module("get_folder", storage=storage, path=path) - def download_file(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: + def download_file(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]: """ 下载文件 :param fileitem: 文件项 @@ -60,8 +60,8 @@ class StorageChain(ChainBase): """ return self.run_module("download_file", fileitem=fileitem, path=path) - def upload_file(self, fileitem: schemas.FileItem, path: Path, - new_name: Optional[str] = None) -> Optional[schemas.FileItem]: + def upload_file(self, fileitem: _SchemaFileItem, path: Path, + new_name: Optional[str] = None) -> Optional[_SchemaFileItem]: """ 上传文件 :param fileitem: 保存目录项 @@ -70,37 +70,37 @@ class StorageChain(ChainBase): """ return self.run_module("upload_file", fileitem=fileitem, path=path, new_name=new_name) - def delete_file(self, fileitem: schemas.FileItem) -> Optional[bool]: + def delete_file(self, fileitem: _SchemaFileItem) -> Optional[bool]: """ 删除文件或目录 """ return self.run_module("delete_file", fileitem=fileitem) - def rename_file(self, fileitem: schemas.FileItem, name: str) -> Optional[bool]: + def rename_file(self, fileitem: _SchemaFileItem, name: str) -> Optional[bool]: """ 重命名文件或目录 """ return self.run_module("rename_file", fileitem=fileitem, name=name) - def exists(self, fileitem: schemas.FileItem) -> Optional[bool]: + def exists(self, fileitem: _SchemaFileItem) -> Optional[bool]: """ 判断文件或目录是否存在 """ return True if self.get_item(fileitem) else False - def get_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + def get_item(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ 查询目录或文件 """ return self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path)) - def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]: + def get_file_item(self, storage: str, path: Path) -> Optional[_SchemaFileItem]: """ 根据路径获取文件项 """ return self.run_module("get_file_item", storage=storage, path=path) - def get_parent_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + def get_parent_item(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ 获取上级目录项 """ @@ -121,7 +121,7 @@ class StorageChain(ChainBase): last_snapshot_time=last_snapshot_time, max_depth=max_depth, previous_snapshot=previous_snapshot) - def is_bluray_folder(self, fileitem: Optional[schemas.FileItem]) -> bool: + def is_bluray_folder(self, fileitem: Optional[_SchemaFileItem]) -> bool: """ 检查是否蓝光目录 """ @@ -134,7 +134,7 @@ class StorageChain(ChainBase): return False @staticmethod - def contains_bluray_subdirectories(fileitems: Optional[List[schemas.FileItem]]) -> bool: + def contains_bluray_subdirectories(fileitems: Optional[List[_SchemaFileItem]]) -> bool: """ 判断是否包含蓝光必备的文件夹 """ @@ -144,7 +144,7 @@ class StorageChain(ChainBase): for item in fileitems or [] ) - def delete_media_file(self, fileitem: schemas.FileItem, delete_self: bool = True) -> bool: + def delete_media_file(self, fileitem: _SchemaFileItem, delete_self: bool = True) -> bool: """ 删除媒体文件,以及不含媒体文件的目录 """ diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 744c2161c..0a2a8ebe6 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -6,7 +6,13 @@ import time from datetime import datetime from typing import Any, Callable, Dict, List, Optional, Union, Tuple -from app import schemas +from app.schemas.mediaserver import NotExistMediaInfo as _SchemaNotExistMediaInfo +from app.schemas.message import Message as _SchemaMessage +from app.schemas.subscribe import SubscrbieInfo as _SchemaSubscrbieInfo +from app.schemas.subscribe import SubscribeDownloadFileInfo as _SchemaSubscribeDownloadFileInfo +from app.schemas.subscribe import SubscribeEpisodeInfo as _SchemaSubscribeEpisodeInfo +from app.schemas.subscribe import SubscribeLibraryFileInfo as _SchemaSubscribeLibraryFileInfo +from app.schemas.workflow import Subscribe as _SchemaSubscribe from app.chain import ChainBase from app.chain._interaction import InteractionChainMixin from app.chain._music import MusicSubscribeMixin @@ -35,37 +41,25 @@ from app.db.oper.systemconfig import SystemConfigOper from app.application.messaging.subscribe import SubscribeInteractionHandler from app.application.mediaserver import MediaServerHelper from app.application.subscribe import add_subscribe, async_add_subscribe +from app.application.subscription.contract import ( + build_subscribe_meta as _build_subscribe_meta, + subscribe_media_key, + subscribe_media_keys, +) +from app.application.subscription.query import SubscriptionQueryService from app.adapters.external.server import MoviePilotServerHelper from app.application.torrent import TorrentHelper from app.runtime.log import logger -from app.schemas import (SubscribeEpisodesRefreshEventData, - SubscribeCompletionCheckEventData) +from app.schemas.event import SubscribeEpisodesRefreshEventData +from app.schemas.event import SubscribeCompletionCheckEventData from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, SystemConfigKey, NotificationChannel, MessageType, EventType, ChainEventType, \ ContentType -from app.schemas.media import build_media_key, normalize_media_source, resolve_media_identity +from app.schemas.media import normalize_media_source, resolve_media_identity def build_subscribe_meta(subscribe: Subscribe) -> MetaBase: - """ - 按订阅对象构造主程序链路共用的媒体元数据。 - """ - if subscribe.type == MediaType.MUSIC.value: - is_album = getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM - return MetaMusic( - title=subscribe.name, - album=subscribe.name if is_album else None, - year=subscribe.year, - total_tracks=getattr(subscribe, "total_tracks", None) if is_album else None, - media_source=subscribe.media_source, - media_id=str(subscribe.media_id) if subscribe.media_id is not None else None, - ) - meta = MetaInfo(subscribe.name) - meta.year = subscribe.year - meta.begin_season = subscribe.season - meta.type = MediaType(subscribe.type) - meta.media_source = subscribe.media_source - meta.media_id = subscribe.media_id - return meta + """兼容旧导入路径,转发订阅媒体元数据构造。""" + return _build_subscribe_meta(subscribe) def _media_recognize_kwargs(mediainfo: MediaInfo) -> dict: @@ -87,19 +81,13 @@ def _subscribe_recognize_kwargs(subscribe: Subscribe) -> dict: def _subscribe_media_key(subscribe: Subscribe) -> Union[str, int, None]: - """返回订阅缺失集映射使用的稳定媒体键。""" - media_source, media_id = resolve_media_identity(media=subscribe) - return build_media_key(media_source, media_id) or media_id + """兼容旧导入路径,返回订阅缺失集使用的稳定媒体键。""" + return subscribe_media_key(subscribe) def _subscribe_media_keys(subscribe: Subscribe) -> List[Union[str, int]]: - """返回缺失集缓存可识别的规范媒体键。""" - media_source, media_id = resolve_media_identity(media=subscribe) - candidates = [ - build_media_key(media_source, media_id), - media_id, - ] - return [candidate for candidate in candidates if candidate not in (None, "")] + """兼容旧导入路径,返回规范媒体键与旧纯 ID 键。""" + return subscribe_media_keys(subscribe) class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): @@ -258,7 +246,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): def compute_lack_episode( cls, subscribe: Subscribe, - no_exists: Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]] = None, + no_exists: Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]] = None, ) -> int: """ 计算订阅范围内尚未下载到任何版本的集数。 @@ -681,7 +669,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): cls, subscribe: Subscribe, mediakey: Union[int, str], - ) -> Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]: + ) -> Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]]: """ 构造分集洗版优先全集时使用的整季缺失范围。 """ @@ -698,7 +686,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): return { mediakey: { - subscribe.season: schemas.NotExistMediaInfo( + subscribe.season: _SchemaNotExistMediaInfo( season=subscribe.season, episodes=[], total_episode=subscribe.total_episode, @@ -711,14 +699,14 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): def __download_best_version_with_full_pack_first( self, contexts: List[Context], - no_exists: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]], + no_exists: Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]], subscribe: Subscribe, mediakey: Union[int, str], username: Optional[str] = None, save_path: Optional[str] = None, downloader: Optional[str] = None, source: Optional[str] = None, - ) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]: + ) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]]: """ TV 分集洗版先尝试覆盖目标范围的全集资源,失败后回退到按集下载。 """ @@ -972,7 +960,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): logger.error(f'{mediainfo.title_year} {err_msg}') if not exist_ok and message: # 失败发回原用户 - self.post_message(schemas.Message(channel=channel, + self.post_message(_SchemaMessage(channel=channel, source=source, mtype=MessageType.Subscribe, title=f"{mediainfo.title_year} {metainfo.season} " @@ -990,7 +978,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 订阅成功按规则发送消息 self.post_message( - schemas.Message( + _SchemaMessage( channel=channel, source=source, mtype=MessageType.Subscribe, @@ -1176,7 +1164,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): logger.error(f'{mediainfo.title_year} {err_msg}') if not exist_ok and message: # 失败发回原用户 - await self.async_post_message(schemas.Message(channel=channel, + await self.async_post_message(_SchemaMessage(channel=channel, source=source, mtype=MessageType.Subscribe, title=f"{mediainfo.title_year} {metainfo.season} " @@ -1194,7 +1182,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 订阅成功按规则发送消息 await self.async_post_message( - schemas.Message( + _SchemaMessage( channel=channel, source=source, mtype=MessageType.Subscribe, @@ -1234,21 +1222,16 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): return sid, err_msg @staticmethod - def exists(mediainfo: MediaInfo, meta: MetaBase = None): + def _subscription_query() -> SubscriptionQueryService: + """构造绑定订阅 Oper 的查询应用服务。""" + return SubscriptionQueryService(SubscribeOper()) + + @classmethod + def exists(cls, mediainfo: MediaInfo, meta: MetaBase = None): """ 判断订阅是否已存在 """ - media_source, media_id = resolve_media_identity(media=mediainfo) - if SubscribeOper().exists( - media_source=media_source, - media_id=media_id, - music_type=getattr(mediainfo, "music_type", None) - if mediainfo.type == MediaType.MUSIC else None, - season=meta.begin_season if meta else None, - episode_group=mediainfo.episode_group, - ): - return True - return False + return cls._subscription_query().exists(mediainfo, meta) def search( self, @@ -1529,7 +1512,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaBase, mediainfo: MediaInfo, downloads: List[Context] = None, - lefts: Dict[Union[int | str], Dict[int, schemas.NotExistMediaInfo]] = None, + lefts: Dict[Union[int | str], Dict[int, _SchemaNotExistMediaInfo]] = None, force: Optional[bool] = False): """ 判断是否应完成订阅 @@ -1685,9 +1668,8 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): def has_music_subscribe(self) -> bool: """判断是否存在可搜索状态的音乐订阅,用于决定是否额外刷新站点音乐入口。""" - return any( - subscribe.type == MediaType.MUSIC.value - for subscribe in SubscribeOper().list(self.get_states_for_search('R')) or [] + return self._subscription_query().has_music( + self.get_states_for_search('R') ) def match( @@ -2238,18 +2220,9 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): """ 从来源获取订阅 """ - source_keyword = self.parse_subscribe_source_keyword(source) - if not source_keyword: - return None - # 只保留需要的字段动态获取订阅 - valid_fields = { - k: v for k, v in source_keyword.items() - if k in [ - "type", "season", "media_source", "media_id", "music_type", - ] - } - # 暂时不考虑订阅历史, 若有必要再添加 - return SubscribeOper().get_by(**valid_fields) + return self._subscription_query().get_by_source( + self.parse_subscribe_source_keyword(source) + ) @staticmethod def follow(progress_callback: Optional[Callable[..., None]] = None) -> None: @@ -2301,10 +2274,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): continue # 去除无效属性 for key in list(share_sub.keys()): - if not hasattr(schemas.Subscribe(), key): + if not hasattr(_SchemaSubscribe(), key): share_sub.pop(key) # 类型转换 - subscribe_in = schemas.Subscribe(**share_sub) + subscribe_in = _SchemaSubscribe(**share_sub) mtype = MediaType(subscribe_in.type) # 非 TMDB 标题可能携带季号,入库前统一拆分。 if ( @@ -2507,7 +2480,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): def __prepare_subscribe_progress_fields( cls, subscribe: Subscribe, - no_exists: Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]] = None, + no_exists: Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]] = None, touch_last_update: Optional[bool] = False, ) -> Dict[str, Any]: """ @@ -2541,7 +2514,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): def __refresh_subscribe_progress_with_no_exists( self, subscribe: Subscribe, - no_exists: Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]] = None, + no_exists: Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]] = None, touch_last_update: Optional[bool] = False, scene: str = "download", ) -> Dict[str, Any]: @@ -2835,7 +2808,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 完成订阅按规则发送消息 self.post_message( - schemas.Message( + _SchemaMessage( mtype=MessageType.Subscribe, ctype=ContentType.SubscribeComplete, image=mediainfo.get_message_image(), @@ -2870,7 +2843,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): 删除订阅 """ if not arg_str: - self.post_message(schemas.Message( + self.post_message(_SchemaMessage( channel=channel, source=source, title="请输入正确的命令格式:/subscribe_delete [id]," @@ -2887,7 +2860,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): subscribe_id = int(arg_str) subscribe = subscribeoper.get(subscribe_id) if not subscribe: - self.post_message(schemas.Message( + self.post_message(_SchemaMessage( channel=channel, source=source, title=f"订阅编号 {subscribe_id} 不存在!", userid=userid, @@ -2906,13 +2879,13 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): @staticmethod def __get_subscribe_no_exits(subscribe_name: str, - no_exists: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]], + no_exists: Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]], mediakey: Union[str, int], begin_season: int, total_episode: Optional[int], start_episode: Optional[int], downloaded_episodes: List[int] = None - ) -> Tuple[bool, Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]: + ) -> Tuple[bool, Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]]: """ 根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数 :param subscribe_name: 订阅名称 @@ -2972,7 +2945,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): if not episodes: return True, {} # 更新集合 - no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo( + no_exists[mediakey][begin_season] = _SchemaNotExistMediaInfo( season=begin_season, episodes=episodes, total_episode=total_episode, @@ -3000,7 +2973,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): if not episodes: return True, {} # 更新集合 - no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo( + no_exists[mediakey][begin_season] = _SchemaNotExistMediaInfo( season=begin_season, episodes=episodes, total_episode=total, @@ -3015,7 +2988,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): # 如果存在已下载剧集,则差集为空时,说明所有均已存在 if not episodes: return True, {} - no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo( + no_exists[mediakey][begin_season] = _SchemaNotExistMediaInfo( season=begin_season, episodes=episodes, total_episode=total_episode, @@ -3115,7 +3088,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): "min_seeders_time": default_rule.get("min_seeders_time"), }.items() if value is not None} - def subscribe_files_info(self, subscribe: Subscribe) -> Optional[schemas.SubscrbieInfo]: + def subscribe_files_info(self, subscribe: Subscribe) -> Optional[_SchemaSubscrbieInfo]: """ 订阅相关的下载和文件信息 """ @@ -3123,10 +3096,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): return None # 返回订阅数据 - subscribe_info = schemas.SubscrbieInfo() + subscribe_info = _SchemaSubscrbieInfo() # 所有集的数据 - episodes: Dict[int, schemas.SubscribeEpisodeInfo] = {} + episodes: Dict[int, _SchemaSubscribeEpisodeInfo] = {} if ( subscribe.media_source == MediaSource.TMDB.value and subscribe.media_id @@ -3141,7 +3114,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): ) if tmdb_episodes: for episode in tmdb_episodes: - info = schemas.SubscribeEpisodeInfo() + info = _SchemaSubscribeEpisodeInfo() info.title = episode.name info.description = episode.overview info.backdrop = settings.TMDB_IMAGE_URL(episode.still_path, "w500") @@ -3149,12 +3122,12 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): elif subscribe.type == MediaType.TV.value: # 根据开始结束集计算集信息 for i in range(subscribe.start_episode or 1, subscribe.total_episode + 1): - info = schemas.SubscribeEpisodeInfo() + info = _SchemaSubscribeEpisodeInfo() info.title = f'第 {i} 集' episodes[i] = info else: # 电影 - info = schemas.SubscribeEpisodeInfo() + info = _SchemaSubscribeEpisodeInfo() info.title = subscribe.name episodes[0] = info @@ -3174,7 +3147,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): # 识别文件名 file_meta = MetaInfo(file.filepath) # 下载文件信息 - file_info = schemas.SubscribeDownloadFileInfo( + file_info = _SchemaSubscribeDownloadFileInfo( torrent_title=his.torrent_name, site_name=his.torrent_site, downloader=file.downloader, @@ -3218,7 +3191,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): # 识别文件名 file_meta = MetaInfo(fileitem.path) # 媒体库文件信息 - file_info = schemas.SubscribeLibraryFileInfo( + file_info = _SchemaSubscribeLibraryFileInfo( storage=fileitem.storage, file_path=fileitem.path, ) @@ -3236,7 +3209,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): mediaserver_chain = MediaServerChain() server_names = list(MediaServerHelper().get_services().keys()) - def _has_server_entry(library_list: List[schemas.SubscribeLibraryFileInfo], + def _has_server_entry(library_list: List[_SchemaSubscribeLibraryFileInfo], server_name: Optional[str], server_type: Optional[str]) -> bool: for info in library_list or []: @@ -3288,7 +3261,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): item_id=episode_itemid, ) or series_detail_url episode_info.library.append( - schemas.SubscribeLibraryFileInfo( + _SchemaSubscribeLibraryFileInfo( storage=server_storage, file_path=detail_url, server=resolved_server, @@ -3301,7 +3274,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): if episode_info and not _has_server_entry( episode_info.library, resolved_server, exists_media.server_type): episode_info.library.append( - schemas.SubscribeLibraryFileInfo( + _SchemaSubscribeLibraryFileInfo( storage=server_storage, file_path=series_detail_url, server=resolved_server, @@ -3409,7 +3382,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): return True, {} no_exists = { mediakey: { - subscribe.season: schemas.NotExistMediaInfo( + subscribe.season: _SchemaNotExistMediaInfo( season=subscribe.season, episodes=pending_episodes, total_episode=effective_total_episode, diff --git a/app/chain/system.py b/app/chain/system.py index c2db1cf24..267daa4de 100644 --- a/app/chain/system.py +++ b/app/chain/system.py @@ -9,7 +9,8 @@ from app.runtime.config import settings from app.runtime.extensions.plugin_manager import PluginManager from app.runtime.state import SystemHelper from app.runtime.log import logger -from app.schemas import Message, NotificationChannel +from app.schemas.message import Message +from app.schemas.notification import NotificationChannel from app.adapters.network.http import RequestUtils from app.adapters.system.host import SystemUtils from version import FRONTEND_VERSION, APP_VERSION diff --git a/app/chain/tmdb.py b/app/chain/tmdb.py index 19b4a8fc0..c21b6a6e9 100644 --- a/app/chain/tmdb.py +++ b/app/chain/tmdb.py @@ -1,10 +1,12 @@ import random from typing import Optional, List -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason +from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode from app.chain import ChainBase from app.domain.context import MediaInfo -from app.schemas import MediaType +from app.schemas.types import MediaType class TmdbChain(ChainBase): @@ -61,21 +63,21 @@ class TmdbChain(ChainBase): """ return self.run_module("tmdb_collection", collection_id=collection_id) - def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]: + def tmdb_seasons(self, tmdbid: int) -> List[_SchemaTmdbSeason]: """ 根据TMDBID查询themoviedb所有季信息 :param tmdbid: TMDBID """ return self.run_module("tmdb_seasons", tmdbid=tmdbid) - def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: + def tmdb_group_seasons(self, group_id: str) -> List[_SchemaTmdbSeason]: """ 根据剧集组ID查询themoviedb所有季集信息 :param group_id: 剧集组ID """ return self.run_module("tmdb_group_seasons", group_id=group_id) - def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]: + def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[_SchemaTmdbEpisode]: """ 根据TMDBID查询某季的所有信信息 :param tmdbid: TMDBID @@ -112,7 +114,7 @@ class TmdbChain(ChainBase): """ return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid) - def movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]: + def movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]: """ 根据TMDBID查询电影演职人员 :param tmdbid: TMDBID @@ -120,7 +122,7 @@ class TmdbChain(ChainBase): """ return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page) - def tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]: + def tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]: """ 根据TMDBID查询电视剧演职人员 :param tmdbid: TMDBID @@ -128,7 +130,7 @@ class TmdbChain(ChainBase): """ return self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page) - def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + def person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ 根据TMDBID查询演职员详情 :param person_id: 人物ID @@ -215,14 +217,14 @@ class TmdbChain(ChainBase): """ return await self.async_run_module("async_tmdb_collection", collection_id=collection_id) - async def async_tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]: + async def async_tmdb_seasons(self, tmdbid: int) -> List[_SchemaTmdbSeason]: """ 根据TMDBID查询themoviedb所有季信息(异步版本) :param tmdbid: TMDBID """ return await self.async_run_module("async_tmdb_seasons", tmdbid=tmdbid) - async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: + async def async_tmdb_group_seasons(self, group_id: str) -> List[_SchemaTmdbSeason]: """ 根据剧集组ID查询themoviedb所有季集信息(异步版本) :param group_id: 剧集组ID @@ -230,7 +232,7 @@ class TmdbChain(ChainBase): return await self.async_run_module("async_tmdb_group_seasons", group_id=group_id) async def async_tmdb_episodes(self, tmdbid: int, season: int, - episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]: + episode_group: Optional[str] = None) -> List[_SchemaTmdbEpisode]: """ 根据TMDBID查询某季的所有信信息(异步版本) :param tmdbid: TMDBID @@ -268,7 +270,7 @@ class TmdbChain(ChainBase): """ return await self.async_run_module("async_tmdb_tv_recommend", tmdbid=tmdbid) - async def async_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]: + async def async_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]: """ 根据TMDBID查询电影演职人员(异步版本) :param tmdbid: TMDBID @@ -276,7 +278,7 @@ class TmdbChain(ChainBase): """ return await self.async_run_module("async_tmdb_movie_credits", tmdbid=tmdbid, page=page) - async def async_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]: + async def async_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]: """ 根据TMDBID查询电视剧演职人员(异步版本) :param tmdbid: TMDBID @@ -284,7 +286,7 @@ class TmdbChain(ChainBase): """ return await self.async_run_module("async_tmdb_tv_credits", tmdbid=tmdbid, page=page) - async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ 根据TMDBID查询演职员详情(异步版本) :param person_id: 人物ID diff --git a/app/chain/torrents.py b/app/chain/torrents.py index 552b52975..41932b1ef 100644 --- a/app/chain/torrents.py +++ b/app/chain/torrents.py @@ -17,7 +17,7 @@ from app.db.oper.systemconfig import SystemConfigOper from app.application.rss import RssHelper from app.application.torrent import TorrentHelper from app.runtime.log import logger -from app.schemas import Message +from app.schemas.message import Message from app.schemas.types import SystemConfigKey, NotificationChannel, MessageType, MediaType from app.schemas.media import resolve_media_identity from app.domain import site as site_rules diff --git a/app/chain/transfer.py b/app/chain/transfer.py index a4177342d..50684f0cc 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -31,16 +31,14 @@ from app.application.history import (add_transfer_fail, add_transfer_success, evaluate_history_gate, is_skip_action, record_transfer_failure) from app.runtime.log import logger -from app.schemas import StorageOperSelectionEventData -from app.schemas import ( - TransferInfo, - Message, - EpisodeFormat, - FileItem, - TransferDirectoryConf, - TransferJob, - TmdbEpisode, -) +from app.schemas.event import StorageOperSelectionEventData +from app.schemas.transfer import TransferInfo +from app.schemas.message import Message +from app.schemas.transfer import EpisodeFormat +from app.schemas.workflow import FileItem +from app.schemas.system import TransferDirectoryConf +from app.schemas.transfer import TransferJob +from app.schemas.tmdb import TmdbEpisode from app.schemas.exception import OperationInterrupted from app.schemas.types import ( TorrentStatus, @@ -56,7 +54,7 @@ from app.schemas.types import ( ) from app.runtime.reload import ConfigReloadMixin from app.application.transfer import (FailedRetryScheduler, JobManager, - TransferQueue, TransferTask, job_lock) + TransferQueueService, TransferTask, job_lock) from app.chain._transfer import (EpisodeFormatMixin, FailedRetryMixin, FileFilterMixin, FileKeyMixin, HistoryMatchMixin, ManualHistoryMixin, @@ -501,20 +499,19 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo :param task: 任务信息 :return: True表示任务已添加到队列,False表示任务无效或已存在(重复) """ - if not task: - return False - # 维护整理任务视图,如果任务已存在则不添加到队列 - if not self.__put_to_jobview(task): - return False - self._register_scrape_batch_task(task) - # 添加到队列 - self._queue.put(TransferQueue(task=task, callback=self.__default_callback)) - # 落盘登记:队列是纯内存的,进程重启(挂载挂死后的人工重启、升级、OOM) - # 会让队列连同「这些文件还没整理」这个事实一起蒸发,而已稳定落地的文件 - # 不会再产生任何监控事件,等于永久漏件。登记放在入队之后,宁可多留一条 - # 由回放时的整理历史查重挡掉,也不制造「已入队但未登记」的窗口 - self.__register_pending(task) - return True + return self._transfer_queue_service().put(task, self.__default_callback) + + def _transfer_queue_service(self) -> TransferQueueService: + """构建保持旧队列对象和私有兼容接缝的应用服务。""" + return TransferQueueService( + register_task=self.__put_to_jobview, + enqueue=self._queue.put, + before_enqueue=self._register_scrape_batch_task, + after_enqueue=self.__register_pending, + remove_task=self.jobview.remove_task, + list_tasks=self.jobview.list_jobs, + expire_tasks=self.__expire_stale_transfer_tasks, + ) def replay_pending(self): """ @@ -686,9 +683,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo """ 从待整理队列移除 """ - if not fileitem: - return - self.jobview.remove_task(fileitem) + self._transfer_queue_service().remove(fileitem) def __start_job_execution(self, task: TransferTask): """在作业视图支持执行租约时标记主程序任务开始执行。""" @@ -1159,8 +1154,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo """ 获取整理任务列表 """ - self.__expire_stale_transfer_tasks() - return self.jobview.list_jobs() + return self._transfer_queue_service().list() def process(self, progress_callback: Optional[Callable[..., None]] = None) -> bool: """ diff --git a/app/chain/user.py b/app/chain/user.py index 5856f7b12..4f4a2e3c3 100644 --- a/app/chain/user.py +++ b/app/chain/user.py @@ -8,7 +8,8 @@ from app.application.security.access import get_password_hash, verify_password from app.db.models.user import User from app.db.oper.user import UserOper from app.runtime.log import logger -from app.schemas import AuthCredentials, AuthInterceptCredentials +from app.schemas.event import AuthCredentials +from app.schemas.event import AuthInterceptCredentials from app.schemas.types import ChainEventType from app.application.security.otp import OtpUtils diff --git a/app/chain/workflow.py b/app/chain/workflow.py index 717df3f80..36fbf0888 100644 --- a/app/chain/workflow.py +++ b/app/chain/workflow.py @@ -18,7 +18,11 @@ from app.runtime.events import Event, eventmanager from app.db.models import Workflow from app.db.oper.workflow import WorkflowOper from app.runtime.log import logger -from app.schemas import ActionContext, ActionFlow, Action, ActionExecution, ActionResult +from app.schemas.workflow import ActionContext +from app.schemas.workflow import ActionFlow +from app.schemas.workflow import Action +from app.schemas.workflow import ActionExecution +from app.schemas.workflow import ActionResult from app.schemas.types import EventType from app.workflow import WorkFlowManager diff --git a/app/command.py b/app/command.py index 1d6982b27..e9f553e8b 100644 --- a/app/command.py +++ b/app/command.py @@ -17,7 +17,8 @@ from app.application.messaging.skill import SkillInteractionHandler from app.runtime.thread import ThreadHelper from app.runtime.log import logger from app.scheduler import Scheduler -from app.schemas import Message, CommandRegisterEventData +from app.schemas.message import Message +from app.schemas.event import CommandRegisterEventData from app.schemas.types import EventType, NotificationChannel, ChainEventType from app.foundation.reflection import ObjectUtils from app.foundation.singleton import Singleton diff --git a/app/db/__init__.py b/app/db/__init__.py index 29668df49..3940ded05 100644 --- a/app/db/__init__.py +++ b/app/db/__init__.py @@ -1,89 +1,65 @@ +"""数据库包的惰性兼容导出入口。 + +具体实现位于 ``base``、``decorators``、``engine`` 与 ``session``。包入口只维护 +公开符号到所有者模块的映射,避免数据库子模块为了导入同包实现而回流到一个会主动 +导入全部实现的根模块。旧的 ``from app.db import X`` 路径继续可用。 """ -数据库包入口。 -本模块只做符号再导出,不承载实现——具体职责分布在: +from importlib import import_module +from typing import Any -- diagnostics 驱动错误的统一分类与日志 -- engine 引擎构建、连接额度核算 -- session 会话获取、异步连接池与配额 -- decorators 同步/异步事务装饰器 -- base ORM 基类与数据访问基类 -- models 表结构声明,一实体一文件 -- oper 数据访问实现,与 models 同名文件一一对应 -历史上这些代码全部堆在本文件里(782 行),既让包入口承担了实现职责、 -使依赖图难以理清,也让「import 即建立数据库连接」这一副作用被固化下来。 -""" -from typing import TYPE_CHECKING, Any - -from app.db.base import Base, DbOper, execute_dml, get_id_column -from app.db.decorators import async_db_query, async_db_update, db_query, db_update -from app.db.engine import ( - check_connection_budget, - connection_budget, - get_engine, - get_global_async_engine, -) -from app.db.session import ( - AsyncSessionFactory, - ScopedSession, - SessionFactory, - async_session_scope, - close_database, - get_async_db, - get_async_engine, - get_async_session_factory, - get_db, - get_scoped_session, - get_session_factory, -) - -# ==================== 对外契约的分层 ==================== -# 下方 __all__ 是本包**对外承诺**的那一层,仓库外的插件只应依赖其中的名字: -# -# - 数据访问:继承 DbOper 子类(插件基类已备好 self.plugindata / self.systemconfig), -# 或给自己的函数套 db_query / db_update / async_db_query / async_db_update 装饰器。 -# 会话的获取、提交、回滚、释放全部由装饰器收口。 -# - 引擎:Engine / AsyncEngine 保留在契约内。建表、Alembic 迁移、连接诊断这些用途 -# 确实需要引擎对象本身,装饰器覆盖不到,仓库外拿它是正当的。 -# -# SessionFactory / AsyncSessionFactory / ScopedSession 三个名字**不在**契约内,已从 -# __all__ 移除,降级为内部实现细节。它们建出来的是绕过上述装饰器的裸会话——没有提交、 -# 没有回滚、没有释放,谁建谁自己兜底,本身就是误用的形状。仓库内确有几处直接 -# `from app.db import SessionFactory`(scheduler、postgresql 模块、Alembic 迁移脚本), -# 那是包内部的既有用法,直接导入不受 __all__ 约束,照常可用。 -# 若确实需要真正的工厂对象(而非 `X()` 取一个会话),用 get_session_factory() / -# get_scoped_session() / get_async_session_factory()——转发函数上没有 sessionmaker -# 与 scoped_session 的实例接口(.remove() / .configure() / .begin() 等)。 -# -# 实现上,三个工厂名字本身就是转发函数(见 session 模块),直接再导出即可——导入它们 -# 不会碰引擎。Engine / AsyncEngine 则不同:调用方拿到的必须是引擎**对象**而非函数, -# 所以只能靠模块级 __getattr__ 在取属性时才创建。 -# -# 注意这意味着 `from app.db import Engine` 仍会在 import 期把引擎建出来——那是调用方 -# 自己选的时机。本包自身及仓库内代码一律用 get_engine(),所以 `import app.db` 不连库。 -if TYPE_CHECKING: - # 只为静态检查声明这两个名字:运行期由下方 __getattr__ 解析,模块 __dict__ 里并不存在, - # 类型检查器无从知道它们属于本模块(__all__ 里的它们会被报成 reportUnsupportedDunderAll)。 - # 这里同时把类型钉准,比 __getattr__ 的 Any 更有用:调用方拿到的确实是这两类引擎。 - from sqlalchemy.engine import Engine as _SyncEngine - from sqlalchemy.ext.asyncio import AsyncEngine as _SaAsyncEngine - - Engine: _SyncEngine - AsyncEngine: _SaAsyncEngine +_EXPORTS = { + "AsyncSessionFactory": ("app.db.session", "AsyncSessionFactory"), + "Base": ("app.db.base", "Base"), + "DbOper": ("app.db.base", "DbOper"), + "ScopedSession": ("app.db.session", "ScopedSession"), + "SessionFactory": ("app.db.session", "SessionFactory"), + "async_db_query": ("app.db.decorators", "async_db_query"), + "async_db_update": ("app.db.decorators", "async_db_update"), + "async_session_scope": ("app.db.session", "async_session_scope"), + "check_connection_budget": ("app.db.engine", "check_connection_budget"), + "close_database": ("app.db.session", "close_database"), + "connection_budget": ("app.db.engine", "connection_budget"), + "db_query": ("app.db.decorators", "db_query"), + "db_update": ("app.db.decorators", "db_update"), + "execute_dml": ("app.db.base", "execute_dml"), + "get_async_db": ("app.db.session", "get_async_db"), + "get_async_engine": ("app.db.session", "get_async_engine"), + "get_async_session_factory": ( + "app.db.session", + "get_async_session_factory", + ), + "get_db": ("app.db.session", "get_db"), + "get_engine": ("app.db.engine", "get_engine"), + "get_global_async_engine": ("app.db.engine", "get_global_async_engine"), + "get_id_column": ("app.db.base", "get_id_column"), + "get_scoped_session": ("app.db.session", "get_scoped_session"), + "get_session_factory": ("app.db.session", "get_session_factory"), +} def __getattr__(name: str) -> Any: - """ - 惰性解析 Engine / AsyncEngine 两个旧名字,保持仓库外插件的导入路径可用。 - :param name: 属性名 - :return: 对应的引擎 - """ + """按需解析旧数据库导出,并缓存到包命名空间。""" if name == "Engine": - return get_engine() - if name == "AsyncEngine": - return get_global_async_engine() - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return getattr(import_module("app.db.engine"), "get_engine")() + elif name == "AsyncEngine": + return getattr( + import_module("app.db.engine"), + "get_global_async_engine", + )() + elif name in _EXPORTS: + module_name, symbol_name = _EXPORTS[name] + value = getattr(import_module(module_name), symbol_name) + else: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + globals()[name] = value + return value + + +def __dir__() -> list[str]: + """向交互式工具暴露兼容符号,同时保持实现模块惰性。""" + return sorted({*globals(), *_EXPORTS, "Engine", "AsyncEngine"}) __all__ = [ diff --git a/app/db/maintenance.py b/app/db/maintenance.py new file mode 100644 index 000000000..f6c23c5bd --- /dev/null +++ b/app/db/maintenance.py @@ -0,0 +1,63 @@ +"""数据维护用例的 SQLAlchemy 适配器。""" + +from typing import Any, Callable, ContextManager + +from app.db.models.downloadfailure import DownloadFailure +from app.db.models.downloadhistory import DownloadFiles, DownloadHistory +from app.db.models.message import Message +from app.db.models.siteuserdata import SiteUserData +from app.db.models.transferhistory import TransferHistory + + +class DatabaseCleanupRepository: + """把应用层清理端口映射到现有模型批量删除方法。""" + + def __init__(self, *, session_factory: Callable[[], ContextManager[Any]]) -> None: + """保存会话工厂,使测试和不同数据库后端可以显式注入。""" + self._session_factory = session_factory + + def session(self) -> ContextManager[Any]: + """创建一次维护运行共用的数据库会话。""" + return self._session_factory() + + @staticmethod + def delete_messages(db: Any, cutoff: str, limit: int) -> int: + """删除早于截止时间的消息。""" + return Message.delete_before(db=db, before_time=cutoff, limit=limit) + + @staticmethod + def delete_download_history(db: Any, cutoff: str, limit: int) -> int: + """删除早于截止时间的下载历史。""" + return DownloadHistory.delete_before( + db=db, + before_time=cutoff, + limit=limit, + ) + + @staticmethod + def delete_download_orphans(db: Any, limit: int) -> int: + """删除已经失去父下载历史的文件记录。""" + return DownloadFiles.delete_orphans(db=db, limit=limit) + + @staticmethod + def delete_site_userdata(db: Any, cutoff: str, limit: int) -> int: + """删除早于截止日期的站点用户数据快照。""" + return SiteUserData.delete_before(db=db, before_day=cutoff, limit=limit) + + @staticmethod + def delete_transfer_history(db: Any, cutoff: str, limit: int) -> int: + """删除早于截止时间的整理历史。""" + return TransferHistory.delete_before( + db=db, + before_time=cutoff, + limit=limit, + ) + + @staticmethod + def delete_download_failures(db: Any, cutoff: str, limit: int) -> int: + """删除已经过期的下载失败冷却记录。""" + return DownloadFailure.delete_expired( + db=db, + before_time=cutoff, + limit=limit, + ) diff --git a/app/db/models/__init__.py b/app/db/models/__init__.py index e3e79d765..c02c464e5 100644 --- a/app/db/models/__init__.py +++ b/app/db/models/__init__.py @@ -1,28 +1,60 @@ -""" -ORM 模型。 +"""ORM 模型的惰性兼容导出与显式注册入口。""" -_identity 必须在此处导入:它在 import 期把媒体身份归一挂到 mapper 事件上,是六张带 -身份列的表的写入不变量。导入任一模型都会先初始化本包,因此这一行让强制点无处可绕。 -""" -from . import _identity # noqa: F401 仅为注册 mapper 事件,不导出符号 -from .agentchat import AgentChat -from .agenttask import AgentTask -from .agenttaskrun import AgentTaskRun -from .downloadfailure import DownloadFailure -from .downloadhistory import DownloadHistory, DownloadFiles -from .mediaserver import MediaServerItem -from .message import Message -from .passkey import PassKey -from .plugindata import PluginData -from .site import Site -from .siteicon import SiteIcon -from .sitestatistic import SiteStatistic -from .siteuserdata import SiteUserData -from .subscribe import Subscribe -from .subscribehistory import SubscribeHistory -from .systemconfig import SystemConfig -from .transferhistory import TransferHistory -from .transferpending import TransferPending -from .user import User -from .userconfig import UserConfig -from .workflow import Workflow +from importlib import import_module +from typing import Any + +from . import _identity # noqa: F401 注册全局媒体身份写入不变量 + + +_MODEL_EXPORTS = { + "AgentChat": ("app.db.models.agentchat", "AgentChat"), + "AgentTask": ("app.db.models.agenttask", "AgentTask"), + "AgentTaskRun": ("app.db.models.agenttaskrun", "AgentTaskRun"), + "DownloadFailure": ("app.db.models.downloadfailure", "DownloadFailure"), + "DownloadFiles": ("app.db.models.downloadhistory", "DownloadFiles"), + "DownloadHistory": ("app.db.models.downloadhistory", "DownloadHistory"), + "MediaServerItem": ("app.db.models.mediaserver", "MediaServerItem"), + "Message": ("app.db.models.message", "Message"), + "PassKey": ("app.db.models.passkey", "PassKey"), + "PluginData": ("app.db.models.plugindata", "PluginData"), + "Site": ("app.db.models.site", "Site"), + "SiteIcon": ("app.db.models.siteicon", "SiteIcon"), + "SiteStatistic": ("app.db.models.sitestatistic", "SiteStatistic"), + "SiteUserData": ("app.db.models.siteuserdata", "SiteUserData"), + "Subscribe": ("app.db.models.subscribe", "Subscribe"), + "SubscribeHistory": ( + "app.db.models.subscribehistory", + "SubscribeHistory", + ), + "SystemConfig": ("app.db.models.systemconfig", "SystemConfig"), + "TransferHistory": ("app.db.models.transferhistory", "TransferHistory"), + "TransferPending": ("app.db.models.transferpending", "TransferPending"), + "User": ("app.db.models.user", "User"), + "UserConfig": ("app.db.models.userconfig", "UserConfig"), + "Workflow": ("app.db.models.workflow", "Workflow"), +} + + +def load_all_models() -> None: + """显式导入全部 ORM 模型,供建表和 Alembic 元数据收集使用。""" + for module_name, _ in dict.fromkeys(_MODEL_EXPORTS.values()): + import_module(module_name) + + +def __getattr__(name: str) -> Any: + """按需解析旧模型包级导出,并缓存模型类。""" + contract = _MODEL_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) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + """返回模型包的兼容公开面。""" + return sorted({*globals(), *_MODEL_EXPORTS, "load_all_models"}) + + +__all__ = [*_MODEL_EXPORTS, "load_all_models"] diff --git a/app/db/models/agentchat.py b/app/db/models/agentchat.py index 80d5c3e77..4fc948997 100644 --- a/app/db/models/agentchat.py +++ b/app/db/models/agentchat.py @@ -4,7 +4,8 @@ from sqlalchemy import Integer, String, JSON, Index, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import Base, async_db_query, db_query, get_id_column +from app.db.base import Base, get_id_column +from app.db.decorators import async_db_query, db_query class AgentChat(Base): diff --git a/app/db/models/agenttask.py b/app/db/models/agenttask.py index 06d96f4a6..1aebab44e 100644 --- a/app/db/models/agenttask.py +++ b/app/db/models/agenttask.py @@ -3,7 +3,8 @@ from typing import Optional from sqlalchemy import Boolean, Index, Integer, String, Text, select, update from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import Base, db_query, db_update, execute_dml, get_id_column +from app.db.base import Base, execute_dml, get_id_column +from app.db.decorators import db_query, db_update class AgentTask(Base): diff --git a/app/db/models/agenttaskrun.py b/app/db/models/agenttaskrun.py index 1f013da6a..5fe8c6812 100644 --- a/app/db/models/agenttaskrun.py +++ b/app/db/models/agenttaskrun.py @@ -3,7 +3,8 @@ from typing import Any, Dict, List, Optional from sqlalchemy import Index, Integer, String, Text, delete, select, update from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import Base, db_query, db_update, execute_dml, get_id_column +from app.db.base import Base, execute_dml, get_id_column +from app.db.decorators import db_query, db_update from app.db.models.agenttask import AgentTask diff --git a/app/db/models/downloadfailure.py b/app/db/models/downloadfailure.py index 438359f33..49c27de4f 100644 --- a/app/db/models/downloadfailure.py +++ b/app/db/models/downloadfailure.py @@ -3,7 +3,8 @@ from typing import List, Optional from sqlalchemy import Float, Index, Integer, String, delete, select from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import Base, db_query, db_update, execute_dml, get_id_column +from app.db.base import Base, execute_dml, get_id_column +from app.db.decorators import db_query, db_update from app.db.models._constraints import media_identity_constraint diff --git a/app/db/models/downloadhistory.py b/app/db/models/downloadhistory.py index 6e541cf5b..4f0cab880 100644 --- a/app/db/models/downloadhistory.py +++ b/app/db/models/downloadhistory.py @@ -5,7 +5,8 @@ from sqlalchemy import Integer, String, JSON, Index, delete, select, func, updat from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import Base, async_db_query, db_query, db_update, execute_dml, get_id_column +from app.db.base import Base, execute_dml, get_id_column +from app.db.decorators import async_db_query, db_query, db_update from app.db.models._constraints import media_identity_constraint from app.schemas.types import MediaSource diff --git a/app/db/models/mediaserver.py b/app/db/models/mediaserver.py index 9d1bd7d20..cfbd93c56 100644 --- a/app/db/models/mediaserver.py +++ b/app/db/models/mediaserver.py @@ -1,12 +1,13 @@ from datetime import datetime from typing import Any, List, Optional -from sqlalchemy import Integer, String, JSON, Index, delete, or_ +from sqlalchemy import String, JSON, Index, delete, or_ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import Base, async_db_query, db_query, db_update, execute_dml, get_id_column +from app.db.base import Base, execute_dml, get_id_column +from app.db.decorators import async_db_query, db_query, db_update from app.db.models._constraints import media_identity_constraint from app.schemas.types import MediaSource diff --git a/app/db/models/message.py b/app/db/models/message.py index 1a3b707dd..882efdb79 100644 --- a/app/db/models/message.py +++ b/app/db/models/message.py @@ -4,7 +4,8 @@ from sqlalchemy import Integer, String, JSON, Index, and_, delete, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import Base, async_db_query, db_query, db_update, execute_dml, get_id_column +from app.db.base import Base, execute_dml, get_id_column +from app.db.decorators import async_db_query, db_query, db_update class Message(Base): diff --git a/app/db/models/passkey.py b/app/db/models/passkey.py index 94dc09709..430107de8 100644 --- a/app/db/models/passkey.py +++ b/app/db/models/passkey.py @@ -4,7 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column from datetime import datetime -from app.db import Base, db_query, db_update, async_db_query, async_db_update, get_id_column +from app.db.base import Base, get_id_column +from app.db.decorators import db_query, db_update, async_db_query, async_db_update class PassKey(Base): diff --git a/app/db/models/plugindata.py b/app/db/models/plugindata.py index ea70a5e39..ca666abde 100644 --- a/app/db/models/plugindata.py +++ b/app/db/models/plugindata.py @@ -3,13 +3,8 @@ from sqlalchemy import String, JSON, Index, delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import ( - db_query, - db_update, - async_db_query, - get_id_column, - Base, -) +from app.db.base import get_id_column, Base +from app.db.decorators import db_query, db_update, async_db_query class PluginData(Base): diff --git a/app/db/models/site.py b/app/db/models/site.py index 7bd3f22d6..bf2848bf5 100644 --- a/app/db/models/site.py +++ b/app/db/models/site.py @@ -5,7 +5,8 @@ from sqlalchemy import Boolean, Integer, String, JSON, select, delete from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import db_query, db_update, Base, async_db_query, async_db_update, get_id_column +from app.db.base import Base, get_id_column +from app.db.decorators import db_query, db_update, async_db_query, async_db_update class Site(Base): diff --git a/app/db/models/siteicon.py b/app/db/models/siteicon.py index 2237b0350..e176ae7d5 100644 --- a/app/db/models/siteicon.py +++ b/app/db/models/siteicon.py @@ -3,7 +3,8 @@ from sqlalchemy import String, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import db_query, Base, get_id_column, async_db_query +from app.db.base import Base, get_id_column +from app.db.decorators import db_query, async_db_query class SiteIcon(Base): diff --git a/app/db/models/sitestatistic.py b/app/db/models/sitestatistic.py index a22f2c2d3..2406ad9c8 100644 --- a/app/db/models/sitestatistic.py +++ b/app/db/models/sitestatistic.py @@ -5,7 +5,8 @@ from sqlalchemy import Integer, String, JSON, delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import db_query, db_update, get_id_column, Base, async_db_query +from app.db.base import get_id_column, Base +from app.db.decorators import db_query, db_update, async_db_query class SiteStatistic(Base): diff --git a/app/db/models/siteuserdata.py b/app/db/models/siteuserdata.py index e70dc2315..455e39519 100644 --- a/app/db/models/siteuserdata.py +++ b/app/db/models/siteuserdata.py @@ -5,7 +5,8 @@ from sqlalchemy import Integer, String, Float, JSON, Index, delete, func, or_, s from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import Base, async_db_query, db_query, db_update, execute_dml, get_id_column +from app.db.base import Base, execute_dml, get_id_column +from app.db.decorators import async_db_query, db_query, db_update class SiteUserData(Base): diff --git a/app/db/models/subscribe.py b/app/db/models/subscribe.py index 0b5b0717e..b30ab1f3f 100644 --- a/app/db/models/subscribe.py +++ b/app/db/models/subscribe.py @@ -5,7 +5,8 @@ from sqlalchemy import Integer, String, Float, JSON, Index, delete, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import db_query, db_update, get_id_column, Base, async_db_query, async_db_update +from app.db.base import get_id_column, Base +from app.db.decorators import db_query, db_update, async_db_query, async_db_update from app.db.models._constraints import media_identity_constraint from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource diff --git a/app/db/models/subscribehistory.py b/app/db/models/subscribehistory.py index be3bfff23..e388637e8 100644 --- a/app/db/models/subscribehistory.py +++ b/app/db/models/subscribehistory.py @@ -4,7 +4,8 @@ from sqlalchemy import Integer, String, Float, JSON, Index, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import db_query, Base, get_id_column, async_db_query +from app.db.base import Base, get_id_column +from app.db.decorators import db_query, async_db_query from app.db.models._constraints import media_identity_constraint from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource diff --git a/app/db/models/systemconfig.py b/app/db/models/systemconfig.py index f3ab120d5..24d6872ee 100644 --- a/app/db/models/systemconfig.py +++ b/app/db/models/systemconfig.py @@ -3,7 +3,8 @@ from sqlalchemy import String, JSON, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import db_query, db_update, Base, async_db_query, get_id_column +from app.db.base import Base, get_id_column +from app.db.decorators import db_query, db_update, async_db_query class SystemConfig(Base): diff --git a/app/db/models/transferhistory.py b/app/db/models/transferhistory.py index 3be5d8087..060ec657a 100644 --- a/app/db/models/transferhistory.py +++ b/app/db/models/transferhistory.py @@ -7,7 +7,8 @@ from sqlalchemy import Boolean, Index, Integer, JSON, String, delete, func, or_, from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import Base, async_db_query, db_query, db_update, execute_dml, get_id_column +from app.db.base import Base, execute_dml, get_id_column +from app.db.decorators import async_db_query, db_query, db_update from app.db.models._constraints import media_identity_constraint from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType diff --git a/app/db/models/transferpending.py b/app/db/models/transferpending.py index 59269d661..1dbdb1914 100644 --- a/app/db/models/transferpending.py +++ b/app/db/models/transferpending.py @@ -3,7 +3,8 @@ from typing import List, Optional from sqlalchemy import Index, String, delete, select from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import Base, db_query, db_update, execute_dml, get_id_column +from app.db.base import Base, execute_dml, get_id_column +from app.db.decorators import db_query, db_update class TransferPending(Base): diff --git a/app/db/models/user.py b/app/db/models/user.py index 6ad0bc211..e649e4811 100644 --- a/app/db/models/user.py +++ b/app/db/models/user.py @@ -3,7 +3,8 @@ from sqlalchemy import Boolean, JSON, String, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import Base, db_query, db_update, async_db_query, async_db_update, get_id_column +from app.db.base import Base, get_id_column +from app.db.decorators import db_query, db_update, async_db_query, async_db_update class User(Base): diff --git a/app/db/models/userconfig.py b/app/db/models/userconfig.py index 281621d8d..59cc09936 100644 --- a/app/db/models/userconfig.py +++ b/app/db/models/userconfig.py @@ -2,7 +2,8 @@ from typing import Any, Optional from sqlalchemy import String, UniqueConstraint, JSON, select from sqlalchemy.orm import Mapped, Session, mapped_column -from app.db import db_query, db_update, get_id_column, Base +from app.db.base import get_id_column, Base +from app.db.decorators import db_query, db_update class UserConfig(Base): diff --git a/app/db/models/workflow.py b/app/db/models/workflow.py index fd91acbf9..4abc3fbc1 100644 --- a/app/db/models/workflow.py +++ b/app/db/models/workflow.py @@ -6,7 +6,8 @@ from sqlalchemy import Integer, JSON, String, Index, and_, or_, select, update from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.ext.asyncio import AsyncSession -from app.db import Base, db_query, get_id_column, db_update, async_db_query, async_db_update +from app.db.base import Base, get_id_column +from app.db.decorators import db_query, db_update, async_db_query, async_db_update class Workflow(Base): diff --git a/app/db/oper/agentchat.py b/app/db/oper/agentchat.py index fed550526..fdd337395 100644 --- a/app/db/oper/agentchat.py +++ b/app/db/oper/agentchat.py @@ -4,7 +4,7 @@ from typing import Any, Optional, Union from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session -from app.db import DbOper +from app.db.base import DbOper from app.db.models.agentchat import AgentChat from app.schemas.types import NotificationChannel diff --git a/app/db/oper/agenttask.py b/app/db/oper/agenttask.py index f86cee638..a33fc4efb 100644 --- a/app/db/oper/agenttask.py +++ b/app/db/oper/agenttask.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Optional from uuid import uuid4 -from app.db import DbOper +from app.db.base import DbOper from app.db.models.agenttask import AgentTask from app.db.models.agenttaskrun import AgentTaskRun diff --git a/app/db/oper/downloadfailure.py b/app/db/oper/downloadfailure.py index 42faa3d26..e80b5d317 100644 --- a/app/db/oper/downloadfailure.py +++ b/app/db/oper/downloadfailure.py @@ -1,6 +1,6 @@ from typing import Dict, List, Optional -from app.db import DbOper +from app.db.base import DbOper from app.db.models.downloadfailure import DownloadFailure diff --git a/app/db/oper/downloadhistory.py b/app/db/oper/downloadhistory.py index 41c1d7791..ff6042d3b 100644 --- a/app/db/oper/downloadhistory.py +++ b/app/db/oper/downloadhistory.py @@ -1,6 +1,8 @@ from typing import Dict, List, Optional, cast -from app.db import DbOper +from sqlalchemy import delete as sqlalchemy_delete, update as sqlalchemy_update + +from app.db.base import DbOper from app.db.models.downloadhistory import DownloadHistory, DownloadFiles from app.schemas.types import MediaSource @@ -110,6 +112,17 @@ class DownloadHistoryOper(DbOper): """ DownloadFiles.delete_by_fullpath(self._db, fullpath) + def stage_delete_file_by_fullpath(self, fullpath: str) -> None: + """暂存指定完整路径的下载文件记录删除。""" + self._db.execute( + sqlalchemy_update(DownloadFiles) + .where( + DownloadFiles.fullpath == fullpath, + DownloadFiles.state == 1, + ) + .values(state=0) + ) + def get_hash_by_fullpath(self, fullpath: str) -> Optional[str]: """ 按fullpath查询下载文件记录hash @@ -192,6 +205,14 @@ class DownloadHistoryOper(DbOper): """ DownloadHistory.delete(self._db, historyid) + def stage_delete_history(self, historyid: int) -> None: + """暂存下载记录删除,不由模型装饰器提交事务。""" + self._db.execute( + sqlalchemy_delete(DownloadHistory).where( + DownloadHistory.id == historyid + ) + ) + def delete_downloadfile(self, downloadfileid): """ 删除下载文件记录 diff --git a/app/db/oper/mediaserver.py b/app/db/oper/mediaserver.py index a37025c9a..e6a08802e 100644 --- a/app/db/oper/mediaserver.py +++ b/app/db/oper/mediaserver.py @@ -2,7 +2,7 @@ from typing import Optional from sqlalchemy.orm import Session -from app.db import DbOper +from app.db.base import DbOper from app.db.models.mediaserver import MediaServerItem diff --git a/app/db/oper/message.py b/app/db/oper/message.py index b5194449c..f81a6d833 100644 --- a/app/db/oper/message.py +++ b/app/db/oper/message.py @@ -4,9 +4,10 @@ from typing import Optional, Union from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session -from app.db import DbOper +from app.db.base import DbOper from app.db.models.message import Message -from app.schemas import NotificationChannel, MessageType +from app.schemas.notification import NotificationChannel +from app.schemas.message import MessageType class MessageOper(DbOper): diff --git a/app/db/oper/plugindata.py b/app/db/oper/plugindata.py index a9328bc7b..9e3bc2184 100644 --- a/app/db/oper/plugindata.py +++ b/app/db/oper/plugindata.py @@ -1,6 +1,6 @@ from typing import Any, Optional -from app.db import DbOper +from app.db.base import DbOper from app.db.models.plugindata import PluginData diff --git a/app/db/oper/site.py b/app/db/oper/site.py index 40c4c4624..3c3c39865 100644 --- a/app/db/oper/site.py +++ b/app/db/oper/site.py @@ -1,9 +1,11 @@ from datetime import datetime -from typing import List, Tuple, Optional +from typing import Any, List, Mapping, Tuple, Optional -from app.db import DbOper -from app.db.models import SiteIcon +from sqlalchemy import delete as sqlalchemy_delete + +from app.db.base import DbOper from app.db.models.site import Site +from app.db.models.siteicon import SiteIcon from app.db.models.sitestatistic import SiteStatistic from app.db.models.siteuserdata import SiteUserData @@ -35,6 +37,48 @@ class SiteOper(DbOper): """ return await Site.async_get(self._db, sid) + async def get_by_id(self, site_id: int) -> Optional[Site]: + """读取站点写用例需要的目标站点。""" + return await self.async_get(site_id) + + async def get_by_domain(self, domain: str) -> Optional[Site]: + """按域名读取站点写用例的重复目标。""" + return await Site.async_get_by_domain(self._db, domain) + + async def stage_create(self, payload: Mapping[str, Any]) -> None: + """暂存新增站点,不由仓储自行提交。""" + values = dict(payload) + values.pop("id", None) + self._db.add(Site(**values)) + + async def stage_update( + self, + site_id: int, + payload: Mapping[str, Any], + ) -> bool: + """暂存站点字段更新,不由模型装饰器提前提交。""" + site = await self.async_get(site_id) + if not site: + return False + for key, value in payload.items(): + if key != "id": + setattr(site, key, value) + return True + + async def stage_delete(self, site_id: int) -> None: + """暂存站点删除,由请求级 UnitOfWork 统一提交。""" + await self._db.execute( + sqlalchemy_delete(Site).where(Site.id == site_id) + ) + + async def stage_priorities(self, priorities: list[dict]) -> None: + """暂存批量优先级更新,避免逐行独立提交。""" + for priority in priorities: + site_id = priority.get("id") + site = await self.async_get(site_id) if site_id else None + if site: + site.pri = priority.get("pri") + def list(self) -> List[Site]: """ 获取站点列表 diff --git a/app/db/oper/subscribe.py b/app/db/oper/subscribe.py index fc50cec1e..3a1e92373 100644 --- a/app/db/oper/subscribe.py +++ b/app/db/oper/subscribe.py @@ -10,7 +10,10 @@ import time from typing import Any, Tuple, List, Optional -from app.db import DbOper +from sqlalchemy import delete as sqlalchemy_delete + +from app.application.subscription.delete import SubscribeDeletionCandidate +from app.db.base import DbOper from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory from app.schemas.types import MediaSource @@ -154,6 +157,75 @@ class SubscribeOper(DbOper): """ return await Subscribe.async_get(self._db, rid=sid) + async def get_candidate( + self, + subscribe_id: int, + ) -> Optional[SubscribeDeletionCandidate]: + """读取订阅删除用例需要的权限字段与完整事件快照。""" + subscribe = await self.async_get(subscribe_id) + if not subscribe: + return None + values = subscribe.__dict__ + event_payload = { + column.name: values.get(column.name) + for column in subscribe.__table__.columns + } + return SubscribeDeletionCandidate( + subscribe_id=subscribe_id, + username=subscribe.username, + event_payload=event_payload, + ) + + async def list_candidates_by_identity( + self, + media_source: MediaSource, + media_id: str, + season: Optional[int], + music_type: Optional[str], + ) -> List[SubscribeDeletionCandidate]: + """按媒体身份读取去重后的订阅删除快照。""" + subscribes = await Subscribe.async_list_by_media_identity( + self._db, + media_source=media_source, + media_id=media_id, + music_type=music_type, + ) + candidates = [] + seen_ids = set() + for subscribe in subscribes or []: + subscribe_music_type = getattr(subscribe, "music_type", None) + if music_type and not ( + subscribe_music_type == music_type + or (music_type == "recording" and subscribe_music_type is None) + ): + continue + if season is not None and subscribe.season != season: + continue + if not subscribe.id or subscribe.id in seen_ids: + continue + seen_ids.add(subscribe.id) + values = subscribe.__dict__ + candidates.append( + SubscribeDeletionCandidate( + subscribe_id=subscribe.id, + username=subscribe.username, + event_payload={ + column.name: values.get(column.name) + for column in subscribe.__table__.columns + }, + ) + ) + return candidates + + async def list_search_ids(self, username: str, state: str) -> List[int]: + """返回用户指定状态的订阅编号,不向应用用例暴露 ORM 列表。""" + subscribes = await Subscribe.async_list_by_username( + self._db, + username, + state=state, + ) + return [subscribe.id for subscribe in subscribes if subscribe.id] + def get_by( self, type: str, media_source: MediaSource, media_id: str, season: Optional[str] = None, @@ -206,6 +278,12 @@ class SubscribeOper(DbOper): """ await Subscribe.async_delete(self._db, rid=sid) + async def stage_delete(self, sid: int) -> None: + """登记订阅删除但不提交,由 Application UnitOfWork 控制事务边界。""" + await self._db.execute( + sqlalchemy_delete(Subscribe).where(Subscribe.id == sid) + ) + async def async_update(self, sid: int, payload: dict) -> Optional[Subscribe]: """ 异步更新订阅。 diff --git a/app/db/oper/subscribehistory.py b/app/db/oper/subscribehistory.py index aef1a9bf8..d33286e1d 100644 --- a/app/db/oper/subscribehistory.py +++ b/app/db/oper/subscribehistory.py @@ -1,6 +1,6 @@ -from typing import List, Optional +from typing import List -from app.db import DbOper +from app.db.base import DbOper from app.db.models.subscribehistory import SubscribeHistory diff --git a/app/db/oper/systemconfig.py b/app/db/oper/systemconfig.py index 3f632daa6..c6896db9a 100644 --- a/app/db/oper/systemconfig.py +++ b/app/db/oper/systemconfig.py @@ -3,7 +3,7 @@ import copy import threading from typing import Any, Optional, Union -from app.db import DbOper +from app.db.base import DbOper from app.db.models.systemconfig import SystemConfig from app.schemas.types import SystemConfigKey from app.foundation.singleton import Singleton diff --git a/app/db/oper/transferhistory.py b/app/db/oper/transferhistory.py index f96f94fb3..c8e3da4ad 100644 --- a/app/db/oper/transferhistory.py +++ b/app/db/oper/transferhistory.py @@ -1,7 +1,9 @@ import time from typing import Any, List, Optional -from app.db import DbOper +from sqlalchemy import delete as sqlalchemy_delete + +from app.db.base import DbOper from app.db.models.transferhistory import TransferHistory from app.schemas.types import MediaSource @@ -207,6 +209,18 @@ class TransferHistoryOper(DbOper): """ TransferHistory.delete(self._db, historyid) + def stage_delete(self, historyid: int) -> None: + """暂存整理记录删除,不由模型装饰器提交事务。""" + self._db.execute( + sqlalchemy_delete(TransferHistory).where( + TransferHistory.id == historyid + ) + ) + + def stage_truncate(self) -> None: + """暂存全部整理记录删除,由请求级事务统一提交。""" + self._db.execute(sqlalchemy_delete(TransferHistory)) + async def async_delete(self, historyid): """ 异步删除转移记录。 diff --git a/app/db/oper/transferpending.py b/app/db/oper/transferpending.py index ec01258eb..6324f5687 100644 --- a/app/db/oper/transferpending.py +++ b/app/db/oper/transferpending.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import List, Optional, Tuple -from app.db import DbOper +from app.db.base import DbOper from app.db.models.transferpending import TransferPending diff --git a/app/db/oper/user.py b/app/db/oper/user.py index c61ecf3d6..595210e7f 100644 --- a/app/db/oper/user.py +++ b/app/db/oper/user.py @@ -11,7 +11,7 @@ runtime 兼容映射指向 SDK 薄门面;canonical 数据访问模块仍只依 """ from typing import List, Optional -from app.db import DbOper +from app.db.base import DbOper from app.db.models.user import User diff --git a/app/db/oper/userconfig.py b/app/db/oper/userconfig.py index fd8eb93f5..6d5b46a41 100644 --- a/app/db/oper/userconfig.py +++ b/app/db/oper/userconfig.py @@ -1,6 +1,6 @@ from typing import Any, Union, Dict, Optional -from app.db import DbOper +from app.db.base import DbOper from app.db.models.userconfig import UserConfig from app.schemas.types import UserConfigKey from app.foundation.singleton import Singleton diff --git a/app/db/oper/workflow.py b/app/db/oper/workflow.py index 05eb991df..e2439912b 100644 --- a/app/db/oper/workflow.py +++ b/app/db/oper/workflow.py @@ -1,6 +1,8 @@ -from typing import List, Tuple, Optional, Any, Coroutine, Sequence +from typing import List, Mapping, Tuple, Optional, Any -from app.db import DbOper +from sqlalchemy import delete as sqlalchemy_delete + +from app.db.base import DbOper from app.db.models.workflow import Workflow @@ -25,6 +27,34 @@ class WorkflowOper(DbOper): """ return Workflow.get(self._db, wid) + def stage_state(self, workflow_id: int, state: str) -> bool: + """暂存工作流状态变更,不由模型方法自行提交。""" + workflow = self.get(workflow_id) + if not workflow: + return False + workflow.state = state + return True + + def stage_update( + self, + workflow_id: int, + payload: Mapping[str, Any], + ) -> Optional[Workflow]: + """暂存工作流字段更新并返回同一会话中的对象。""" + workflow = self.get(workflow_id) + if not workflow: + return None + for key, value in payload.items(): + if key != "id": + setattr(workflow, key, value) + return workflow + + def stage_delete(self, workflow_id: int) -> None: + """暂存工作流删除,由请求级 UnitOfWork 统一提交。""" + self._db.execute( + sqlalchemy_delete(Workflow).where(Workflow.id == workflow_id) + ) + async def async_get(self, wid: int) -> Optional[Workflow]: """ 异步查询单个工作流 @@ -73,6 +103,31 @@ class WorkflowOper(DbOper): """ return await Workflow.async_get_by_name(self._db, name) + async def stage_create(self, payload: Mapping[str, Any]) -> Workflow: + """暂存新工作流,不在操作器内提交事务。""" + workflow = Workflow(**dict(payload)) + self._db.add(workflow) + await self._db.flush() + return workflow + + async def stage_reset( + self, + workflow_id: int, + reset_count: bool = False, + ) -> Optional[Workflow]: + """暂存工作流重置字段,不触发模型装饰器的隐式提交。""" + workflow = await self.async_get(workflow_id) + if not workflow: + return None + workflow.state = "W" + workflow.result = None + workflow.current_action = None + workflow.context = {} + workflow.execution_state = {} + if reset_count: + workflow.run_count = 0 + return workflow + def start(self, wid: int) -> bool: """ 启动 diff --git a/app/db/session.py b/app/db/session.py index b8311c95a..85a82d453 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -19,10 +19,10 @@ from sqlalchemy.ext.asyncio import AsyncEngine as SaAsyncEngine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.orm import Session, scoped_session, sessionmaker -from app.runtime.config import global_vars, settings -from app.db import engine as engine_module +import app.db.engine as engine_module from app.db.engine import (_async_pool_enabled, _get_database_engine, get_engine, get_global_async_engine) +from app.runtime.config import global_vars, settings from app.runtime.log import logger # 会话工厂同样惰性:sessionmaker 在构造时就要绑定引擎,模块级构造等于把引擎的 diff --git a/app/db/uow.py b/app/db/uow.py new file mode 100644 index 000000000..52fbb6884 --- /dev/null +++ b/app/db/uow.py @@ -0,0 +1,36 @@ +"""SQLAlchemy 请求级事务适配器。""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session + + +class SqlAlchemyUnitOfWork: + """把同步 Session 的提交与回滚能力适配为应用层事务端口。""" + + def __init__(self, session: Session) -> None: + """保存由请求依赖提供的同步数据库会话。""" + self._session = session + + def commit(self) -> None: + """提交请求级事务。""" + self._session.commit() + + def rollback(self) -> None: + """回滚请求级事务。""" + self._session.rollback() + + +class SqlAlchemyAsyncUnitOfWork: + """把 AsyncSession 的提交与回滚能力适配为应用层事务端口。""" + + def __init__(self, session: AsyncSession) -> None: + """保存由请求依赖提供的数据库会话。""" + self._session = session + + async def commit(self) -> None: + """提交请求级事务。""" + await self._session.commit() + + async def rollback(self) -> None: + """回滚请求级事务。""" + await self._session.rollback() diff --git a/app/factory.py b/app/factory.py index fb36aa9bf..2d103c1fb 100644 --- a/app/factory.py +++ b/app/factory.py @@ -295,7 +295,7 @@ def create_app() -> FastAPI: localized_validation_exception_handler, ) _app.add_exception_handler(Exception, localized_unhandled_exception_handler) - # 动态注册的插件接口也必须使用统一响应路由类。 + # 主程序静态路由统一使用 ResponseAPIRoute;动态插件注册时会显式覆盖为原生 APIRoute。 _app.router.route_class = ResponseAPIRoute # 配置 CORS 中间件 diff --git a/app/main.py b/app/main.py index 1630da944..82f3f2fed 100644 --- a/app/main.py +++ b/app/main.py @@ -26,7 +26,6 @@ def _prepare_direct_execution_import_path() -> None: _prepare_direct_execution_import_path() -import multiprocessing import setproctitle import signal import threading diff --git a/app/modules/__init__.py b/app/modules/__init__.py index 483e22ecb..a159f63bd 100644 --- a/app/modules/__init__.py +++ b/app/modules/__init__.py @@ -5,7 +5,10 @@ from pathlib import Path from app.runtime.extensions.service_config import ServiceConfigHelper from app.runtime.log import logger -from app.schemas import Message, NotificationConf, MediaServerConf, DownloaderConf +from app.schemas.message import Message +from app.schemas.system import NotificationConf +from app.schemas.system import MediaServerConf +from app.schemas.system import DownloaderConf from app.schemas.types import ModuleType, DownloaderType, MediaServerType, NotificationChannel, StorageSchema, \ OtherModulesType, SystemConfigKey, MediaRecognizeType from app.runtime.reload import ConfigReloadMixin diff --git a/app/modules/_base/mediaserver.py b/app/modules/_base/mediaserver.py index 4b4b3aee9..b260f20ab 100644 --- a/app/modules/_base/mediaserver.py +++ b/app/modules/_base/mediaserver.py @@ -6,7 +6,9 @@ """ from typing import Optional, Tuple -from app import schemas +from app.schemas.event import AuthCredentials as _SchemaAuthCredentials +from app.schemas.event import AuthInterceptCredentials as _SchemaAuthInterceptCredentials +from app.schemas.mediaserver import ExistMediaInfo as _SchemaExistMediaInfo from app.application.mediaserver import MusicMediaServerHelper from app.domain.context import MediaInfo from app.modules import _MediaServerBase, _ModuleBase, TService @@ -25,9 +27,9 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): def user_authenticate( self, - credentials: schemas.AuthCredentials, + credentials: _SchemaAuthCredentials, service_name: Optional[str] = None, - ) -> Optional[schemas.AuthCredentials]: + ) -> Optional[_SchemaAuthCredentials]: """ 使用媒体服务器用户辅助完成用户认证 @@ -53,7 +55,7 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): # 触发认证拦截事件 intercept_event = eventmanager.send_event( etype=ChainEventType.AuthIntercept, - data=schemas.AuthInterceptCredentials( + data=_SchemaAuthInterceptCredentials( username=credentials.username, channel=self.get_name(), service=name, @@ -61,7 +63,7 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): ), ) if intercept_event and intercept_event.event_data: - intercept_data: schemas.AuthInterceptCredentials = intercept_event.event_data + intercept_data: _SchemaAuthInterceptCredentials = intercept_event.event_data if intercept_data.cancel: continue token = server.authenticate(credentials.username, credentials.password) @@ -77,7 +79,7 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None, - ) -> Optional[schemas.ExistMediaInfo]: + ) -> Optional[_SchemaExistMediaInfo]: """ 判断媒体文件是否存在 @@ -100,7 +102,7 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): ) match = MusicMediaServerHelper.find_match(mediainfo, matches) if match: - return schemas.ExistMediaInfo( + return _SchemaExistMediaInfo( type=MediaType.MUSIC, server_type=self._server_type_value, server=name, @@ -112,7 +114,7 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): movie = s.get_iteminfo(itemid) if movie: logger.info(f"媒体库 {name} 中找到了 {movie}") - return schemas.ExistMediaInfo( + return _SchemaExistMediaInfo( type=MediaType.MOVIE, server_type=self._server_type_value, server=name, @@ -127,7 +129,7 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): continue else: logger.info(f"媒体库 {name} 中找到了 {movies}") - return schemas.ExistMediaInfo( + return _SchemaExistMediaInfo( type=MediaType.MOVIE, server_type=self._server_type_value, server=name, @@ -144,7 +146,7 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): continue else: logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到 了这些季集:{tvs}") - return schemas.ExistMediaInfo( + return _SchemaExistMediaInfo( type=MediaType.TV, seasons=tvs, server_type=self._server_type_value, diff --git a/app/modules/_base/notification.py b/app/modules/_base/notification.py index 83f49557a..4f5025f47 100644 --- a/app/modules/_base/notification.py +++ b/app/modules/_base/notification.py @@ -15,7 +15,7 @@ from app.foundation.collections import DictUtils from app.modules import _MessageBase, _ModuleBase, TService from app.runtime.events import eventmanager from app.runtime.log import logger -from app.schemas import CommandRegisterEventData +from app.schemas.event import CommandRegisterEventData from app.schemas.types import ChainEventType diff --git a/app/modules/anilist/__init__.py b/app/modules/anilist/__init__.py index 853848456..0cf9e419d 100644 --- a/app/modules/anilist/__init__.py +++ b/app/modules/anilist/__init__.py @@ -1,6 +1,6 @@ from typing import List, Optional, Tuple, Union -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.runtime.config import settings from app.domain.context import MediaInfo from app.domain.meta.metabase import MetaBase @@ -168,7 +168,7 @@ class AniListModule(_ModuleBase): return MediaInfo._anilist_date(date_info) @classmethod - def _build_credit_person(cls, edge: dict) -> Optional[schemas.MediaPerson]: + def _build_credit_person(cls, edge: dict) -> Optional[_SchemaMediaPerson]: """ 将 AniList 角色配音关系转换为统一人物信息。 @@ -181,7 +181,7 @@ class AniListModule(_ModuleBase): name_info = actor.get("name") or {} character_name = (edge.get("node") or {}).get("name") or {} images = actor.get("image") or {} - return schemas.MediaPerson( + return _SchemaMediaPerson( source="anilist", id=actor.get("id"), name=cls._person_name(name_info), @@ -194,7 +194,7 @@ class AniListModule(_ModuleBase): ) @classmethod - def _build_person_detail(cls, info: dict) -> schemas.MediaPerson: + def _build_person_detail(cls, info: dict) -> _SchemaMediaPerson: """ 将 AniList 人物详情转换为统一人物信息。 @@ -203,7 +203,7 @@ class AniListModule(_ModuleBase): """ name_info = info.get("name") or {} images = info.get("image") or {} - return schemas.MediaPerson( + return _SchemaMediaPerson( source="anilist", id=info.get("id"), name=cls._person_name(name_info), @@ -458,7 +458,7 @@ class AniListModule(_ModuleBase): def anilist_credits( self, anilist_id: int, page: int = 1, count: int = 20 - ) -> List[schemas.MediaPerson]: + ) -> List[_SchemaMediaPerson]: """ 获取 AniList 动画配音演员。 @@ -472,7 +472,7 @@ class AniListModule(_ModuleBase): async def async_anilist_credits( self, anilist_id: int, page: int = 1, count: int = 20 - ) -> List[schemas.MediaPerson]: + ) -> List[_SchemaMediaPerson]: """ 异步获取 AniList 动画配音演员。 @@ -506,7 +506,7 @@ class AniListModule(_ModuleBase): ) return [MediaInfo(anilist_info=info) for info in infos] - def anilist_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + def anilist_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ 获取 AniList 人物详情。 @@ -518,7 +518,7 @@ class AniListModule(_ModuleBase): async def async_anilist_person_detail( self, person_id: int - ) -> Optional[schemas.MediaPerson]: + ) -> Optional[_SchemaMediaPerson]: """ 异步获取 AniList 人物详情。 diff --git a/app/modules/bangumi/__init__.py b/app/modules/bangumi/__init__.py index 524709720..9ec067673 100644 --- a/app/modules/bangumi/__init__.py +++ b/app/modules/bangumi/__init__.py @@ -1,6 +1,6 @@ from typing import List, Optional, Tuple, Union -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.runtime.config import settings from app.domain.context import MediaInfo from app.domain.meta.metabase import MetaBase @@ -345,24 +345,24 @@ class BangumiModule(_ModuleBase): return [MediaInfo(bangumi_info=info) for info in infos] return [] - def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]: + def bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]: """ 根据TMDBID查询电影演职员表 :param bangumiid: BangumiID """ persons = self.bangumiapi.credits(bangumiid) if persons: - return [schemas.MediaPerson(source='bangumi', **person) for person in persons] + return [_SchemaMediaPerson(source='bangumi', **person) for person in persons] return [] - async def async_bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]: + async def async_bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]: """ 根据TMDBID查询电影演职员表(异步版本) :param bangumiid: BangumiID """ persons = await self.bangumiapi.async_credits(bangumiid) if persons: - return [schemas.MediaPerson(source='bangumi', **person) for person in persons] + return [_SchemaMediaPerson(source='bangumi', **person) for person in persons] return [] def bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]: @@ -385,7 +385,7 @@ class BangumiModule(_ModuleBase): return [MediaInfo(bangumi_info=subject) for subject in subjects] return [] - def bangumi_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + def bangumi_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ 获取人物详细信息 :param person_id: 豆瓣人物ID @@ -395,7 +395,7 @@ class BangumiModule(_ModuleBase): return self._build_person_detail(personinfo) return None - async def async_bangumi_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + async def async_bangumi_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ 获取人物详细信息(异步版本) :param person_id: 豆瓣人物ID @@ -406,13 +406,13 @@ class BangumiModule(_ModuleBase): return None @classmethod - def _build_person_detail(cls, personinfo: dict) -> schemas.MediaPerson: + def _build_person_detail(cls, personinfo: dict) -> _SchemaMediaPerson: """ 构造Bangumi人物详情信息。 :param personinfo: Bangumi人物详情接口返回数据 :return: 媒体人物信息 """ - return schemas.MediaPerson(source='bangumi', **{ + return _SchemaMediaPerson(source='bangumi', **{ "id": personinfo.get("id"), "name": personinfo.get("name"), "images": personinfo.get("images"), diff --git a/app/modules/discord/__init__.py b/app/modules/discord/__init__.py index 3f5337d6d..9eba96a59 100644 --- a/app/modules/discord/__init__.py +++ b/app/modules/discord/__init__.py @@ -10,13 +10,11 @@ from app.application.messaging.agent import ( ) from app.runtime.log import logger from app.modules._base import _MessageChannelModuleBase -from app.schemas import ( - CommandRegisterEventData, - IncomingMessage, - NotificationChannel, - MessageResponse, - Message, -) +from app.schemas.event import CommandRegisterEventData +from app.schemas.message import IncomingMessage +from app.schemas.notification import NotificationChannel +from app.schemas.message import MessageResponse +from app.schemas.message import Message from app.schemas.types import ModuleType from app.adapters.network.http import RequestUtils diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 7b2da29a9..cdb8854f0 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -3,7 +3,7 @@ from typing import Any, List, Optional, Tuple, Union import cn2an -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.runtime.config import settings from app.domain.context import ( MediaInfo, @@ -17,7 +17,8 @@ from app.runtime.log import logger from app.modules import _ModuleBase from app.modules.douban.apiv2 import DoubanApi from app.modules.douban.scraper import DoubanScraper -from app.schemas import MediaPerson, APIRateLimitException +from app.schemas.context import MediaPerson +from app.schemas.exception import APIRateLimitException from app.schemas.types import ( MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, @@ -1777,7 +1778,7 @@ class DoubanModule(_ModuleBase): self.doubanapi.clear_cache() logger.info("豆瓣缓存清除完成") - def douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]: + def douban_movie_credits(self, doubanid: str) -> List[_SchemaMediaPerson]: """ 根据豆瓣ID查询电影演职员表 :param doubanid: 豆瓣ID @@ -1785,7 +1786,7 @@ class DoubanModule(_ModuleBase): result = self.doubanapi.movie_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) - def douban_tv_credits(self, doubanid: str) -> List[schemas.MediaPerson]: + def douban_tv_credits(self, doubanid: str) -> List[_SchemaMediaPerson]: """ 根据豆瓣ID查询电视剧演职员表 :param doubanid: 豆瓣ID @@ -1813,7 +1814,7 @@ class DoubanModule(_ModuleBase): return [MediaInfo(douban_info=info) for info in recommend] return [] - def douban_person_detail(self, person_id: int) -> schemas.MediaPerson: + def douban_person_detail(self, person_id: int) -> _SchemaMediaPerson: """ 获取人物详细信息 :param person_id: 豆瓣人物ID @@ -1827,14 +1828,14 @@ class DoubanModule(_ModuleBase): image = detail.get("cover_img", {}).get("url") if image: image = image.replace("/l/public/", "/s/public/") - return schemas.MediaPerson(source='douban', **{ + return _SchemaMediaPerson(source='douban', **{ "id": detail.get("id"), "name": detail.get("title"), "avatar": image, "biography": detail.get("extra", {}).get("short_info"), "also_known_as": also_known_as, }) - return schemas.MediaPerson(source='douban') + return _SchemaMediaPerson(source='douban') def douban_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]: """ @@ -1859,7 +1860,7 @@ class DoubanModule(_ModuleBase): return [] @staticmethod - def _process_celebrity_data(result: dict) -> List[schemas.MediaPerson]: + def _process_celebrity_data(result: dict) -> List[_SchemaMediaPerson]: """ 处理演职员表数据的公共方法 :param result: API返回的演职员表数据 @@ -1872,10 +1873,10 @@ class DoubanModule(_ModuleBase): # 更新豆瓣演员信息中的ID,从URI中提取'douban://douban.com/celebrity/1316132?subject_id=27503705' subject_id for doubaninfo in ret_list: doubaninfo['id'] = doubaninfo.get('uri', '').split('?subject_id=')[-1] - return [schemas.MediaPerson(source='douban', **doubaninfo) for doubaninfo in ret_list] + return [_SchemaMediaPerson(source='douban', **doubaninfo) for doubaninfo in ret_list] return [] - async def async_douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]: + async def async_douban_movie_credits(self, doubanid: str) -> List[_SchemaMediaPerson]: """ 根据豆瓣ID查询电影演职员表(异步版本) :param doubanid: 豆瓣ID @@ -1883,7 +1884,7 @@ class DoubanModule(_ModuleBase): result = await self.doubanapi.async_movie_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) - async def async_douban_tv_credits(self, doubanid: str) -> List[schemas.MediaPerson]: + async def async_douban_tv_credits(self, doubanid: str) -> List[_SchemaMediaPerson]: """ 根据豆瓣ID查询电视剧演职员表(异步版本) :param doubanid: 豆瓣ID @@ -1911,7 +1912,7 @@ class DoubanModule(_ModuleBase): return [MediaInfo(douban_info=info) for info in recommend] return [] - async def async_douban_person_detail(self, person_id: int) -> schemas.MediaPerson: + async def async_douban_person_detail(self, person_id: int) -> _SchemaMediaPerson: """ 获取人物详细信息(异步版本) :param person_id: 豆瓣人物ID @@ -1925,14 +1926,14 @@ class DoubanModule(_ModuleBase): image = detail.get("cover_img", {}).get("url") if image: image = image.replace("/l/public/", "/s/public/") - return schemas.MediaPerson(source='douban', **{ + return _SchemaMediaPerson(source='douban', **{ "id": detail.get("id"), "name": detail.get("title"), "avatar": image, "biography": detail.get("extra", {}).get("short_info"), "also_known_as": also_known_as, }) - return schemas.MediaPerson(source='douban') + return _SchemaMediaPerson(source='douban') async def async_douban_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]: """ diff --git a/app/modules/emby/__init__.py b/app/modules/emby/__init__.py index 28c6d9fbd..15f1f6c3d 100644 --- a/app/modules/emby/__init__.py +++ b/app/modules/emby/__init__.py @@ -1,6 +1,11 @@ from typing import Any, Dict, Generator, List, Optional, Tuple, Union -from app import schemas +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.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.runtime.log import logger from app.modules._base import _MediaServerModuleBase from app.modules.emby.emby import Emby @@ -50,7 +55,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]: + def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[_SchemaWebhookEventInfo]: """ 解析Webhook报文体 :param body: 请求体 @@ -75,7 +80,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): return result return None - def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: + def media_statistic(self, server: Optional[str] = None) -> Optional[List[_SchemaStatistic]]: """ 媒体数量统计 """ @@ -97,7 +102,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): def mediaserver_librarys(self, server: str, username: Optional[str] = None, - hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]: + hidden: Optional[bool] = False) -> Optional[List[_SchemaMediaServerLibrary]]: """ 媒体库列表 """ @@ -136,7 +141,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): return server_obj.get_items_count(library_id) return None - def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]: + def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[_SchemaMediaServerItem]: """ 媒体库项目详情 """ @@ -146,7 +151,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): return None def mediaserver_tv_episodes(self, server: str, - item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]: + item_id: Union[str, int]) -> Optional[List[_SchemaMediaServerSeasonInfo]]: """ 获取剧集信息 """ @@ -156,13 +161,13 @@ class EmbyModule(_MediaServerModuleBase[Emby]): _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id) if not seasoninfo: return [] - return [schemas.MediaServerSeasonInfo( + return [_SchemaMediaServerSeasonInfo( season=season, episodes=episodes ) for season, episodes in seasoninfo.items()] def mediaserver_playing(self, server: str, count: Optional[int] = 20, - username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: + username: Optional[str] = None) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ @@ -196,7 +201,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): return server_obj.get_season_episode_ids(str(item_id), season) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, - username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: + username: Optional[str] = None) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ diff --git a/app/modules/emby/emby.py b/app/modules/emby/emby.py index 0ca03593e..e2041687c 100644 --- a/app/modules/emby/emby.py +++ b/app/modules/emby/emby.py @@ -7,11 +7,17 @@ from typing import List, Optional, Union, Dict, Generator, Tuple, Any from requests import Response -from app import schemas +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.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.runtime.config import settings from app.application.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper from app.runtime.log import logger -from app.schemas import MediaServerItem +from app.schemas.mediaserver import MediaServerItem from app.schemas.types import MediaSource, MediaType from app.adapters.network.http import RequestUtils from app.foundation.url import UrlUtils @@ -149,7 +155,7 @@ class Emby: self, username: Optional[str] = None, hidden: Optional[bool] = False, - ) -> Optional[List[schemas.MediaServerLibrary]]: + ) -> Optional[List[_SchemaMediaServerLibrary]]: """ 获取媒体服务器所有媒体库列表 """ @@ -175,7 +181,7 @@ class Emby: server_id = library.get("ServerId") or self.serverid server_query = f"serverId={server_id}&" if server_id else "" libraries.append( - schemas.MediaServerLibrary( + _SchemaMediaServerLibrary( server="emby", id=library.get("Id"), item_id=library.get("Id"), @@ -314,13 +320,13 @@ class Emby: logger.error(f"连接Users/Query出错:" + str(e)) return 0 - def get_medias_count(self) -> schemas.Statistic: + def get_medias_count(self) -> _SchemaStatistic: """ 获得电影、电视剧、动漫媒体数量 :return: MovieCount SeriesCount SongCount """ if not self._host or not self._apikey: - return schemas.Statistic() + return _SchemaStatistic() url = f"{self._host}emby/Items/Counts" params = { 'api_key': self._apikey @@ -329,7 +335,7 @@ class Emby: res = RequestUtils().get_res(url, params) if res: result = res.json() - return schemas.Statistic( + return _SchemaStatistic( movie_count=result.get("MovieCount") or 0, tv_count=result.get("SeriesCount") or 0, episode_count=result.get("EpisodeCount") or 0, @@ -338,10 +344,10 @@ class Emby: ) else: logger.error(f"Items/Counts 未获取到返回数据") - return schemas.Statistic() + return _SchemaStatistic() except Exception as e: logger.error(f"连接Items/Counts出错:" + str(e)) - return schemas.Statistic() + return _SchemaStatistic() def __get_emby_series_id_by_name(self, name: str, year: str) -> Optional[str]: """ @@ -381,7 +387,7 @@ class Emby: title: str, year: Optional[str] = None, media_source: Optional[MediaSource] = None, - media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]: + media_id: Optional[str] = None) -> Optional[List[_SchemaMediaServerItem]]: """ 根据标题和年份,检查电影是否在Emby中存在,存在则返回列表 :param title: 标题 @@ -429,7 +435,7 @@ class Emby: def get_music( self, title: Optional[str] = None, artist: Optional[str] = None, album: Optional[str] = None, - ) -> List[schemas.MediaServerItem]: + ) -> List[_SchemaMediaServerItem]: """按歌曲、艺术家或专辑名称查询 Emby 音乐条目。""" if not self._host or not self._apikey or not self.user: return [] @@ -657,7 +663,7 @@ class Emby: return False return False - def refresh_library_by_items(self, items: List[schemas.RefreshMediaItem]) -> Optional[bool]: + def refresh_library_by_items(self, items: List[_SchemaRefreshMediaItem]) -> Optional[bool]: """ 按类型、名称、年份来刷新媒体库 :param items: 已识别的需要刷新媒体库的媒体信息列表 @@ -682,7 +688,7 @@ class Emby: logger.info(f"Emby媒体库刷新完成") return success - def __get_emby_library_id_by_item(self, item: schemas.RefreshMediaItem) -> Optional[str]: + def __get_emby_library_id_by_item(self, item: _SchemaRefreshMediaItem) -> Optional[str]: """ 根据媒体信息查询在哪个媒体库,返回要刷新的位置的ID :param item: {title, year, type, category, target_path} @@ -722,7 +728,7 @@ class Emby: return "/" @staticmethod - def __format_item_info(item) -> Optional[schemas.MediaServerItem]: + def __format_item_info(item) -> Optional[_SchemaMediaServerItem]: """ 格式化item """ @@ -736,7 +742,7 @@ class Emby: last_played_date = item.get("UserData", {}).get("LastPlayedDate") if last_played_date is not None and "." in last_played_date: last_played_date = last_played_date.split(".")[0] - user_state = schemas.MediaServerItemUserState( + user_state = _SchemaMediaServerItemUserState( played=item.get("UserData", {}).get("Played"), resume=resume, last_played_date=datetime.strptime(last_played_date, "%Y-%m-%dT%H:%M:%S").strftime( @@ -747,7 +753,7 @@ class Emby: media_source, media_id = MediaServerIdentityHelper.from_provider_ids( item.get("ProviderIds") ) - return schemas.MediaServerItem( + return _SchemaMediaServerItem( server="emby", library=item.get("ParentId"), server_id=item.get("ServerId"), @@ -768,7 +774,7 @@ class Emby: logger.error(e) return None - def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]: + def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]: """ 获取单个项目详情 """ @@ -859,7 +865,7 @@ class Emby: logger.error(f"连接Users/Items出错:" + str(e)) return None - def get_webhook_message(self, form: Any, args: dict) -> Optional[schemas.WebhookEventInfo]: + def get_webhook_message(self, form: Any, args: dict) -> Optional[_SchemaWebhookEventInfo]: """ 解析Emby Webhook报文 电影: @@ -1112,7 +1118,7 @@ class Emby: if not eventType: return None logger.debug(f"接收到emby webhook:{message}") - eventItem = schemas.WebhookEventInfo(event=eventType, channel="emby") + eventItem = _SchemaWebhookEventInfo(event=eventType, channel="emby") if message.get('Item'): eventItem.media_type = message.get('Item', {}).get('Type') if message.get('Item', {}).get('Type') == 'Episode' \ @@ -1266,7 +1272,7 @@ class Emby: return "%sItems/%s/Images/Primary" % (self._host, item_id) def get_resume(self, num: Optional[int] = 12, username: Optional[str] = None) -> Optional[ - List[schemas.MediaServerPlayItem]]: + List[_SchemaMediaServerPlayItem]]: """ 获得继续观看 """ @@ -1321,7 +1327,7 @@ class Emby: image_tag=item.get("SeriesPrimaryImageTag")) if not image: image = self.__get_local_image_by_id(item.get("SeriesId")) - ret_resume.append(schemas.MediaServerPlayItem( + ret_resume.append(_SchemaMediaServerPlayItem( id=item.get("Id"), item_id=item.get("Id"), server_id=server_id, @@ -1341,7 +1347,7 @@ class Emby: return None def get_latest(self, num: Optional[int] = 20, username: Optional[str] = None) -> Optional[ - List[schemas.MediaServerPlayItem]]: + List[_SchemaMediaServerPlayItem]]: """ 获得最近更新 """ @@ -1380,7 +1386,7 @@ class Emby: server_id = item.get("ServerId") or self.serverid link = self.get_play_url(item.get("Id"), server_id=server_id) image = self.__get_local_image_by_id(item_id=item.get("Id")) - ret_latest.append(schemas.MediaServerPlayItem( + ret_latest.append(_SchemaMediaServerPlayItem( id=item.get("Id"), item_id=item.get("Id"), server_id=server_id, diff --git a/app/modules/feishu/__init__.py b/app/modules/feishu/__init__.py index 8853e6a99..3a391feb8 100644 --- a/app/modules/feishu/__init__.py +++ b/app/modules/feishu/__init__.py @@ -5,7 +5,10 @@ from app.application.messaging.agent import register_channel_admin_resolver, res from app.runtime.log import logger from app.modules._base import _MessageChannelModuleBase from app.modules.feishu.feishu import Feishu -from app.schemas import IncomingMessage, NotificationChannel, MessageResponse, Message +from app.schemas.message import IncomingMessage +from app.schemas.notification import NotificationChannel +from app.schemas.message import MessageResponse +from app.schemas.message import Message from app.schemas.types import ModuleType diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index 85660eb1c..e78947b6d 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -55,7 +55,8 @@ from app.domain.context import Context, MediaInfo from app.db.oper.user import UserOper from app.application.messaging.agent import matches_channel_admin from app.runtime.log import logger -from app.schemas import IncomingMessage, Message +from app.schemas.message import IncomingMessage +from app.schemas.message import Message from app.schemas.types import NotificationChannel, MessageType from app.adapters.network.http import RequestUtils diff --git a/app/modules/filemanager/__init__.py b/app/modules/filemanager/__init__.py index 1a26a0cf9..b67a53a72 100644 --- a/app/modules/filemanager/__init__.py +++ b/app/modules/filemanager/__init__.py @@ -1,732 +1,45 @@ -from pathlib import Path -from typing import Any, Optional, List, Tuple, Union, Dict, Callable +"""文件管理模块的惰性兼容入口。 -from app.runtime.config import settings -from app.domain.context import MediaInfo, MusicInfo -from app.domain.meta.metabase import MetaBase -from app.domain.meta.metamusic import MetaMusic -from app.domain.metainfo import MetaInfo -from app.application.directory import DirectoryHelper -from app.application.messaging.message import MessageHelper -from app.foundation.reflection import ModuleHelper -from app.runtime.log import logger -from app.modules import _ModuleBase -from app.modules.filemanager.storages import StorageBase -from app.modules.filemanager.transhandler import TransHandler -from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage -from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, ModuleType, OtherModulesType, StorageAction -from app.adapters.system.host import SystemUtils -from app.foundation import text as text_tools +宿主能力清单和历史调用方继续使用 ``app.modules.filemanager:FileManagerModule``; +实现移入 ``module`` 后,包初始化不再反向加载传输处理器和存储实现。 +""" + +from importlib import import_module +from typing import Any -class FileManagerModule(_ModuleBase): - """ - 文件整理模块 - """ +_EXPORTS = { + "DirectoryHelper": ("app.modules.filemanager.module", "DirectoryHelper"), + "FileManagerModule": ("app.modules.filemanager.module", "FileManagerModule"), + "StorageBase": ("app.modules.filemanager.storages", "StorageBase"), + "TransHandler": ("app.modules.filemanager.transhandler", "TransHandler"), + "settings": ("app.modules.filemanager.module", "settings"), +} - _storage_schemas = [] - _support_storages = [] - def __init__(self): - super().__init__() - self.directoryhelper = DirectoryHelper() - self.messagehelper = MessageHelper() +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 == "FileManagerModule": + # 保持插件反射、Pickle 和能力入口依赖的历史类路径。 + value.__module__ = __name__ + globals()[name] = value + return value - def init_module(self) -> None: - """初始化文件整理模块支持的存储实现""" - # 加载模块 - self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages', - filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema) - # 获取存储类型 - self._support_storages = [storage.schema.value for storage in self._storage_schemas if storage.schema] - @staticmethod - def get_name() -> str: - """获取模块名称""" - return "文件整理" +def __dir__() -> list[str]: + """向交互式工具公开兼容符号而不提前导入实现。""" + return sorted({*globals(), *_EXPORTS}) - @staticmethod - def get_type() -> ModuleType: - """ - 获取模块类型 - """ - return ModuleType.Other - @staticmethod - def get_subtype() -> OtherModulesType: - """ - 获取模块子类型 - """ - return OtherModulesType.FileManager - - @staticmethod - def get_priority() -> int: - """ - 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 - """ - return 4 - - def stop(self): - """停止文件整理模块""" - pass - - def test(self) -> Tuple[bool, str]: - """ - 测试模块连接性 - """ - # 检查目录 - dirs = self.directoryhelper.get_dirs() - if not dirs: - return False, "未设置任何目录" - for d in dirs: - # 下载目录 - download_path = d.download_path - if not download_path: - return False, f"{d.name} 的下载目录未设置" - if d.storage == "local" and not Path(download_path).exists(): - return False, f"{d.name} 的下载目录 {download_path} 不存在" - # 仅在启用整理时检查媒体库目录 - library_path = d.library_path - if d.transfer_type: - if not library_path: - return False, f"{d.name} 的媒体库目录未设置" - if d.library_storage == "local" and not Path(library_path).exists(): - return False, f"{d.name} 的媒体库目录 {library_path} 不存在" - # 硬链接 - if d.transfer_type == "link" \ - and d.storage == "local" \ - and d.library_storage == "local" \ - and not SystemUtils.is_same_disk(Path(download_path), Path(library_path)): - return False, f"{d.name} 的下载目录 {download_path} 与媒体库目录 {library_path} 不在同一磁盘,无法硬链接" - # 存储 - storage_oper = self.__get_storage_oper(d.storage) - if storage_oper: - if not storage_oper.check(): - return False, f"{d.name} 的存储测试不通过" - if d.transfer_type and d.transfer_type not in storage_oper.support_transtype(): - return False, f"{d.name} 的存储不支持 {d.transfer_type} 整理方式" - - return True, "" - - def __get_storage_oper(self, _storage: str, _func: Optional[str] = None) -> Optional[StorageBase]: - """ - 获取存储操作对象 - """ - for storage_schema in self._storage_schemas: - if storage_schema.schema \ - and storage_schema.schema.value == _storage \ - and (not _func or hasattr(storage_schema, _func)): - return storage_schema() - return None - - def init_setting(self) -> Tuple[str, Union[str, bool]]: - pass - - def storage_manage(self, storage: str, action: StorageAction, **params) -> Dict[str, Any]: - """ - 网盘存储统一管理入口,按存储标识路由 - - 动作语义与参数解释交给具体存储实现, - 统一返回 {"success": bool, "message": ..., "data": ...} - """ - try: - action = StorageAction(action) - except ValueError: - return {"success": False, "message": f"不支持的存储管理动作:{action}"} - if storage not in self._support_storages: - return {"success": False, "message": f"不支持的存储类型:{storage}"} - - if action == StorageAction.SAVE_CONFIG: - storage_oper = self.__get_storage_oper(storage) - if not storage_oper: - return {"success": False, "message": f"不支持 {storage} 的配置保存"} - storage_oper.set_config(params.get("conf") or {}) - return {"success": True} - if action == StorageAction.RESET_CONFIG: - storage_oper = self.__get_storage_oper(storage) - if not storage_oper: - return {"success": False, "message": f"不支持 {storage} 的重置存储配置"} - storage_oper.reset_config() - return {"success": True} - if action == StorageAction.SUPPORT_TRANSTYPE: - storage_oper = self.__get_storage_oper(storage) - if not storage_oper: - return {"success": False, "message": f"不支持 {storage} 的整理方式获取"} - # 与旧契约一致:返回值包装为 transtype,空结果同样返回成功空结构 - return {"success": True, "data": {"transtype": storage_oper.support_transtype() or {}}} - if action == StorageAction.USAGE: - storage_oper = self.__get_storage_oper(storage) - if not storage_oper: - return {"success": False, "message": f"不支持 {storage} 的存储使用情况"} - # 实现返回 pydantic 模型,转为 dict 后才能透过通用响应的开放映射校验 - return {"success": True, "data": (storage_oper.usage() or StorageUsage()).model_dump()} - - # 登录类动作:存储实现不支持时返回失败信息 - oper_method = action.value - storage_oper = self.__get_storage_oper(storage, oper_method) - if not storage_oper: - return {"success": False, "message": f"{storage} 不支持 {oper_method}"} - result = getattr(storage_oper, oper_method)(**params) - if result is None: - return {"success": False, "message": f"{storage} 的 {oper_method} 执行失败"} - data, errmsg = result - return {"success": bool(data), "message": errmsg, "data": data} - - @staticmethod - def recommend_name(meta: MetaBase, mediainfo: MediaInfo, - episodes_info: Optional[List[TmdbEpisode]] = None) -> Optional[str]: - """ - 获取重命名后的名称 - :param meta: 元数据 - :param mediainfo: 媒体信息 - :param episodes_info: 集信息,由调用方链层预先获取 - :return: 重命名后的名称(含目录) - """ - handler = TransHandler() - # 重命名格式 - rename_format = settings.RENAME_FORMAT(mediainfo.type) - # 获取重命名后的名称 - path = handler.get_rename_path( - template_string=rename_format, - rename_dict=handler.get_naming_dict(meta=meta, - mediainfo=mediainfo, - episodes_info=episodes_info, - file_ext=Path(meta.title).suffix) - ) - return path.as_posix() if path else "" - - def list_files(self, fileitem: FileItem, recursion: Optional[bool] = False) -> Optional[List[FileItem]]: - """ - 浏览文件 - :param fileitem: 源文件 - :param recursion: 是否递归,此时只浏览文件 - :return: 文件项列表 - """ - if fileitem.storage not in self._support_storages: - return None - storage_oper = self.__get_storage_oper(fileitem.storage) - if not storage_oper: - logger.error(f"不支持 {fileitem.storage} 的文件浏览") - return None - - def __get_files(_item: FileItem, _r: Optional[bool] = False): - """ - 递归处理 - """ - _items = storage_oper.list(_item) - if _items: - if _r: - for t in _items: - if t.type == "dir": - __get_files(t, _r) - else: - result.append(t) - else: - result.extend(_items) - - # 返回结果 - result = [] - __get_files(fileitem, recursion) - - return result - - def any_files(self, fileitem: FileItem, extensions: list = None) -> Optional[bool]: - """ - 查询当前目录下是否存在指定扩展名任意文件 - """ - if fileitem.storage not in self._support_storages: - return None - storage_oper = self.__get_storage_oper(fileitem.storage) - if not storage_oper: - logger.error(f"不支持 {fileitem.storage} 的文件浏览") - return None - - def __any_file(_item: FileItem): - """ - 递归处理 - """ - _items = storage_oper.list(_item) - if _items: - if not extensions: - return True - for t in _items: - if (t.type == "file" - and t.extension - and f".{t.extension.lower()}" in extensions): - return True - elif t.type == "dir": - if __any_file(t): - return True - return False - - # 返回结果 - return __any_file(fileitem) - - def create_folder(self, fileitem: FileItem, name: str) -> Optional[FileItem]: - """ - 创建目录 - :param fileitem: 源文件 - :param name: 目录名 - :return: 创建的目录 - """ - if fileitem.storage not in self._support_storages: - return None - storage_oper = self.__get_storage_oper(fileitem.storage) - if not storage_oper: - logger.error(f"不支持 {fileitem.storage} 的目录创建") - return None - return storage_oper.create_folder(fileitem, name) - - def get_folder(self, storage: str, path: Path) -> Optional[FileItem]: - """ - 获取目录,如目录不存在则创建 - """ - if storage not in self._support_storages: - return None - storage_oper = self.__get_storage_oper(storage) - if not storage_oper: - logger.error(f"不支持 {storage} 的目录获取") - return None - return storage_oper.get_folder(path) - - def delete_file(self, fileitem: FileItem) -> Optional[bool]: - """ - 删除文件或目录 - """ - if fileitem.storage not in self._support_storages: - return None - storage_oper = self.__get_storage_oper(fileitem.storage) - if not storage_oper: - logger.error(f"不支持 {fileitem.storage} 的删除处理") - return False - return storage_oper.delete(fileitem) - - def rename_file(self, fileitem: FileItem, name: str) -> Optional[bool]: - """ - 重命名文件或目录 - """ - if fileitem.storage not in self._support_storages: - return None - storage_oper = self.__get_storage_oper(fileitem.storage) - if not storage_oper: - logger.error(f"不支持 {fileitem.storage} 的重命名处理") - return False - return storage_oper.rename(fileitem, name) - - def download_file(self, fileitem: FileItem, path: Path = None) -> Optional[Path]: - """ - 下载文件 - """ - if fileitem.storage not in self._support_storages: - return None - storage_oper = self.__get_storage_oper(fileitem.storage) - if not storage_oper: - logger.error(f"不支持 {fileitem.storage} 的下载处理") - return None - return storage_oper.download(fileitem, path=path) - - def upload_file(self, fileitem: FileItem, path: Path, new_name: Optional[str] = None) -> Optional[FileItem]: - """ - 上传文件 - """ - if fileitem.storage not in self._support_storages: - return None - storage_oper = self.__get_storage_oper(fileitem.storage) - if not storage_oper: - logger.error(f"不支持 {fileitem.storage} 的上传处理") - return None - return storage_oper.upload(fileitem, path, new_name) - - def get_file_item(self, storage: str, path: Path) -> Optional[FileItem]: - """ - 根据路径获取文件项 - """ - if storage not in self._support_storages: - return None - storage_oper = self.__get_storage_oper(storage) - if not storage_oper: - logger.error(f"不支持 {storage} 的文件获取") - return None - return storage_oper.get_item(path) - - def get_parent_item(self, fileitem: FileItem) -> Optional[FileItem]: - """ - 获取上级目录项 - """ - if fileitem.storage not in self._support_storages: - return None - storage_oper = self.__get_storage_oper(fileitem.storage) - if not storage_oper: - logger.error(f"不支持 {fileitem.storage} 的文件获取") - return None - return storage_oper.get_parent(fileitem) - - def snapshot_storage(self, storage: str, path: Path, - last_snapshot_time: float = None, max_depth: int = 5, - previous_snapshot: Optional[Dict[str, Dict]] = None) -> Optional[Dict[str, Dict]]: - """ - 快照存储 - :param storage: 存储类型 - :param path: 路径 - :param last_snapshot_time: 上次快照时间,用于增量快照 - :param max_depth: 最大递归深度,避免过深遍历 - :param previous_snapshot: 上次完整快照,用于增量对账 - """ - if storage not in self._support_storages: - return None - storage_oper = self.__get_storage_oper(storage) - if not storage_oper: - logger.error(f"不支持 {storage} 的快照处理") - return None - return storage_oper.snapshot( - path, - last_snapshot_time=last_snapshot_time, - max_depth=max_depth, - previous_snapshot=previous_snapshot - ) - - def transfer(self, fileitem: FileItem, meta: MetaBase, mediainfo: MediaInfo, - target_directory: TransferDirectoryConf = None, - target_storage: Optional[str] = None, target_path: Path = None, - transfer_type: Optional[str] = None, scrape: Optional[bool] = None, - library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None, - episodes_info: List[TmdbEpisode] = None, - source_oper: Callable = None, target_oper: Callable = None, - preview: Optional[bool] = False) -> TransferInfo: - """ - 文件整理 - :param fileitem: 文件信息 - :param meta: 预识别的元数据 - :param mediainfo: 识别的媒体信息 - :param target_directory: 目标目录配置 - :param target_storage: 目标存储 - :param target_path: 目标路径 - :param transfer_type: 转移模式 - :param scrape: 是否刮削元数据 - :param library_type_folder: 是否按媒体类型创建目录 - :param library_category_folder: 是否按媒体类别创建目录 - :param episodes_info: 当前季的全部集信息 - :param source_oper: 源存储操作对象 - :param target_oper: 目标存储操作对象 - :return: {path, target_path, message} - """ - handler = TransHandler() - # 检查目录路径 - if fileitem.storage == "local" and not Path(fileitem.path).exists(): - return TransferInfo(success=False, - fileitem=fileitem, - message=f"{fileitem.path} 不存在") - # 目标路径不能是文件 - if target_path and target_path.is_file(): - logger.error(f"整理目标路径 {target_path} 是一个文件") - return TransferInfo(success=False, - fileitem=fileitem, - message=f"{target_path} 不是有效目录") - # 获取目标路径 - if target_directory: - # 目标媒体库目录未设置 - if not target_directory.library_path: - logger.error(f"目标媒体库目录未设置,无法整理文件,源路径:{fileitem.path}") - return TransferInfo(success=False, - fileitem=fileitem, - message="目标媒体库目录未设置") - # 整理方式 - if not transfer_type: - transfer_type = target_directory.transfer_type - # 目标存储 - if not target_storage: - target_storage = target_directory.library_storage - # 是否需要重命名 - need_rename = target_directory.renaming - # 是否需要通知 - need_notify = target_directory.notify - # 覆盖模式 - overwrite_mode = target_directory.overwrite_mode - # 是否需要刮削 - need_scrape = target_directory.scraping if scrape is None else scrape - # 拼装媒体库一、二级子目录 - target_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=target_directory, - need_type_folder=library_type_folder, - need_category_folder=library_category_folder) - elif target_path: - need_scrape = scrape or False - need_rename = True - need_notify = False - overwrite_mode = "never" - # 手动整理的场景,有自定义目标路径 - target_path = handler.get_dest_path(mediainfo=mediainfo, target_path=target_path, - need_type_folder=library_type_folder, - need_category_folder=library_category_folder) - else: - # 未找到有效的媒体库目录 - logger.error( - f"{mediainfo.type.value if mediainfo.type else '未知类型'} {mediainfo.title_year} 未找到有效的媒体库目录,无法整理文件,源路径:{fileitem.path}") - return TransferInfo(success=False, - fileitem=fileitem, - message="未找到有效的媒体库目录") - # 整理方式 - if not transfer_type: - logger.error(f"{target_directory.name} 未设置整理方式") - return TransferInfo(success=False, - fileitem=fileitem, - message=f"{target_directory.name} 未设置整理方式") - - # 源操作对象 - if not source_oper: - source_oper = self.__get_storage_oper(fileitem.storage) - if not source_oper: - return TransferInfo(success=False, - message=f"不支持的存储类型:{fileitem.storage}", - fileitem=fileitem, - fail_list=[fileitem.path], - transfer_type=transfer_type, - need_notify=need_notify - ) - # 目的操作对象 - if not target_oper: - if not target_storage: - target_storage = fileitem.storage - target_oper = self.__get_storage_oper(target_storage) - if not target_oper: - return TransferInfo(success=False, - message=f"不支持的存储类型:{target_storage}", - fileitem=fileitem, - fail_list=[fileitem.path], - transfer_type=transfer_type, - need_notify=need_notify) - - # 整理 - logger.info(f"获取整理目标路径:【{target_storage}】{target_path}") - return handler.transfer_media(fileitem=fileitem, - in_meta=meta, - mediainfo=mediainfo, - target_storage=target_storage, - target_path=target_path, - transfer_type=transfer_type, - need_scrape=need_scrape, - need_rename=need_rename, - need_notify=need_notify, - overwrite_mode=overwrite_mode, - episodes_info=episodes_info, - preview=preview, - source_oper=source_oper, - target_oper=target_oper) - - @staticmethod - def _build_library_lookup_meta( - mediainfo: Union[MediaInfo, MusicInfo], - ) -> MetaBase: - """构造标准媒体库路径反查使用的最小元数据。""" - if mediainfo.type == MediaType.MUSIC: - music_type = getattr(mediainfo, "music_type", None) - album = getattr(mediainfo, "album", None) - if not album and music_type == MUSIC_ENTITY_ALBUM: - album = mediainfo.title - return MetaMusic( - title=mediainfo.title, - artists=list(getattr(mediainfo, "artists", None) or []), - album=album, - album_artist=getattr(mediainfo, "album_artist", None), - year=mediainfo.year, - disc_number=getattr(mediainfo, "disc_number", None), - track_number=getattr(mediainfo, "track_number", None), - total_tracks=getattr(mediainfo, "total_tracks", None), - media_source=getattr(mediainfo, "source", None), - media_id=getattr(mediainfo, "media_id", None), - ) - - meta = MetaInfo(mediainfo.title) - if meta.type == MediaType.UNKNOWN and mediainfo.type is not None: - meta.type = mediainfo.type - if meta.year is None: - meta.year = mediainfo.year - if meta.begin_season is None: - meta.begin_season = 1 - if meta.begin_episode is None: - meta.begin_episode = 1 - return meta - - @staticmethod - def _music_file_identity(fileitem: FileItem) -> Tuple[Optional[int], Optional[int], str]: - """从标准音乐文件路径提取碟号、曲序和归一化曲名。""" - file_path = Path(fileitem.path or fileitem.name or "") - file_meta = MetaMusic( - org_string=file_path.name, - title=file_path.stem, - ).apply_path_context(file_path) - return ( - file_meta.disc_number, - file_meta.track_number, - text_tools.normalize_upper(file_meta.title or file_path.stem), - ) - - @classmethod - def _music_recording_exists( - cls, - fileitems: List[FileItem], - mediainfo: MusicInfo, - ) -> bool: - """按曲名和可用曲序判断单曲是否存在,避免专辑内任一文件造成误判。""" - target_title = text_tools.normalize_upper(mediainfo.title or "") - target_track = getattr(mediainfo, "track_number", None) - target_disc = getattr(mediainfo, "disc_number", None) - if not target_title: - return False - for fileitem in fileitems: - disc_number, track_number, title = cls._music_file_identity(fileitem) - if title != target_title: - continue - if target_track is not None and track_number not in (None, target_track): - continue - if target_disc is not None and disc_number not in (None, target_disc): - continue - return True - return False - - @classmethod - def _music_album_is_complete( - cls, - fileitems: List[FileItem], - total_tracks: Optional[int], - ) -> bool: - """按去重后的曲序或曲名判断本地专辑是否达到目标曲目数。""" - if not fileitems: - return False - if not total_tracks: - return False - track_identities = { - ( - disc_number or 1, - track_number if track_number is not None else title, - ) - for disc_number, track_number, title in ( - cls._music_file_identity(fileitem) for fileitem in fileitems - ) - if track_number is not None or title - } - return len(track_identities) >= total_tracks - - def media_files(self, mediainfo: Union[MediaInfo, MusicInfo]) -> List[FileItem]: - """ - 获取对应媒体的媒体库文件列表 - :param mediainfo: 媒体信息 - """ - handler = TransHandler() - ret_fileitems = [] - # 检查本地媒体库 - dest_dirs = DirectoryHelper().get_library_dirs() - # 检查每一个媒体库目录 - for dest_dir in dest_dirs: - # 存储 - storage_oper = self.__get_storage_oper(dest_dir.library_storage) - if not storage_oper: - continue - # 媒体分类路径 - dir_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=dest_dir) - # 重命名格式 - rename_format = settings.RENAME_FORMAT(mediainfo.type) - # 元数据补上常用属性,尽可能确保重命名后的路径不出现空白 - meta = self._build_library_lookup_meta(mediainfo) - # 获取路径(重命名路径) - target_path = handler.get_rename_path( - path=dir_path, - template_string=rename_format, - rename_dict=handler.get_naming_dict(meta=meta, - mediainfo=mediainfo) - ) - # 获取重命名后的媒体文件根路径 - media_path = DirectoryHelper.get_media_root_path( - rename_format, - rename_path=target_path, - media_type=mediainfo.type, - ) - if not media_path: - # 忽略 - continue - if dir_path != media_path and dir_path.is_relative_to(media_path): - # 兜底检查,避免不必要的扫盘 - logger.warn(f"{media_path} 是媒体库目录 {dir_path} 的父目录,忽略获取媒体文件列表,请检查重命名格式!") - continue - # 检索媒体文件 - fileitem = storage_oper.get_item(media_path) - if not fileitem: - continue - try: - media_files = self.list_files(fileitem, True) - except Exception as e: - logger.debug(f"获取媒体文件列表失败:{str(e)}") - continue - if media_files: - media_extensions = ( - settings.RMT_AUDIOEXT - if mediainfo.type == MediaType.MUSIC - else settings.RMT_MEDIAEXT - ) - for media_file in media_files: - if ( - media_file.extension - and f".{media_file.extension.lower()}" in media_extensions - ): - if media_file not in ret_fileitems: - ret_fileitems.append(media_file) - return ret_fileitems - - def media_exists( - self, - mediainfo: Union[MediaInfo, MusicInfo], - **kwargs, - ) -> Optional[ExistMediaInfo]: - """ - 判断媒体文件是否存在于文件系统(网盘或本地文件),只支持标准媒体库结构 - :param mediainfo: 识别的媒体信息 - :param server: 指定媒体服务器名称时跳过本地文件系统检查 - :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} - """ - if kwargs.get("server"): - return None - - if not settings.LOCAL_EXISTS_SEARCH: - return None - - logger.debug(f"正在本地媒体库中查找 {mediainfo.title_year}...") - - # 检查媒体库 - fileitems = self.media_files(mediainfo) - if not fileitems: - logger.debug(f"{mediainfo.title_year} 不在本地媒体库中") - return None - - if mediainfo.type == MediaType.MOVIE: - # 电影存在任何文件为存在 - logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了") - return ExistMediaInfo(type=MediaType.MOVIE) - if mediainfo.type == MediaType.MUSIC: - if getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM: - exists = self._music_album_is_complete( - fileitems, - getattr(mediainfo, "total_tracks", None), - ) - else: - exists = self._music_recording_exists(fileitems, mediainfo) - if not exists: - logger.debug(f"{mediainfo.title_year} 在本地音乐库中尚不完整") - return None - logger.info(f"{mediainfo.title_year} 在本地音乐库中找到了") - return ExistMediaInfo(type=MediaType.MUSIC) - if mediainfo.type == MediaType.TV: - # 电视剧检索集数 - seasons: Dict[int, list] = {} - for fileitem in fileitems: - file_meta = MetaInfo(fileitem.basename) - season_index = file_meta.begin_season if file_meta.begin_season is not None else 1 - episode_index = file_meta.begin_episode - if not episode_index: - continue - if season_index not in seasons: - seasons[season_index] = [] - if episode_index not in seasons[season_index]: - seasons[season_index].append(episode_index) - # 返回剧集情况 - logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{seasons}") - return ExistMediaInfo(type=MediaType.TV, seasons=seasons) - return None +__all__ = [ + "DirectoryHelper", + "FileManagerModule", + "StorageBase", + "TransHandler", + "settings", +] diff --git a/app/modules/filemanager/module.py b/app/modules/filemanager/module.py new file mode 100644 index 000000000..13c7d4cad --- /dev/null +++ b/app/modules/filemanager/module.py @@ -0,0 +1,737 @@ +from pathlib import Path +from typing import Any, Optional, List, Tuple, Union, Dict, Callable + +from app.runtime.config import settings +from app.domain.context import MediaInfo, MusicInfo +from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic +from app.domain.metainfo import MetaInfo +from app.application.directory import DirectoryHelper +from app.application.messaging.message import MessageHelper +from app.foundation.reflection import ModuleHelper +from app.runtime.log import logger +from app.modules import _ModuleBase +from app.modules.filemanager.storages import StorageBase +from app.modules.filemanager.transhandler import TransHandler +from app.schemas.transfer import TransferInfo +from app.schemas.mediaserver import ExistMediaInfo +from app.schemas.tmdb import TmdbEpisode +from app.schemas.system import TransferDirectoryConf +from app.schemas.workflow import FileItem +from app.schemas.file import StorageUsage +from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, ModuleType, OtherModulesType, StorageAction +from app.adapters.system.host import SystemUtils +from app.foundation import text as text_tools + + +class FileManagerModule(_ModuleBase): + """ + 文件整理模块 + """ + + _storage_schemas = [] + _support_storages = [] + + def __init__(self): + super().__init__() + self.directoryhelper = DirectoryHelper() + self.messagehelper = MessageHelper() + + def init_module(self) -> None: + """初始化文件整理模块支持的存储实现""" + # 加载模块 + self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages', + filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema) + # 获取存储类型 + self._support_storages = [storage.schema.value for storage in self._storage_schemas if storage.schema] + + @staticmethod + def get_name() -> str: + """获取模块名称""" + return "文件整理" + + @staticmethod + def get_type() -> ModuleType: + """ + 获取模块类型 + """ + return ModuleType.Other + + @staticmethod + def get_subtype() -> OtherModulesType: + """ + 获取模块子类型 + """ + return OtherModulesType.FileManager + + @staticmethod + def get_priority() -> int: + """ + 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 + """ + return 4 + + def stop(self): + """停止文件整理模块""" + pass + + def test(self) -> Tuple[bool, str]: + """ + 测试模块连接性 + """ + # 检查目录 + dirs = self.directoryhelper.get_dirs() + if not dirs: + return False, "未设置任何目录" + for d in dirs: + # 下载目录 + download_path = d.download_path + if not download_path: + return False, f"{d.name} 的下载目录未设置" + if d.storage == "local" and not Path(download_path).exists(): + return False, f"{d.name} 的下载目录 {download_path} 不存在" + # 仅在启用整理时检查媒体库目录 + library_path = d.library_path + if d.transfer_type: + if not library_path: + return False, f"{d.name} 的媒体库目录未设置" + if d.library_storage == "local" and not Path(library_path).exists(): + return False, f"{d.name} 的媒体库目录 {library_path} 不存在" + # 硬链接 + if d.transfer_type == "link" \ + and d.storage == "local" \ + and d.library_storage == "local" \ + and not SystemUtils.is_same_disk(Path(download_path), Path(library_path)): + return False, f"{d.name} 的下载目录 {download_path} 与媒体库目录 {library_path} 不在同一磁盘,无法硬链接" + # 存储 + storage_oper = self.__get_storage_oper(d.storage) + if storage_oper: + if not storage_oper.check(): + return False, f"{d.name} 的存储测试不通过" + if d.transfer_type and d.transfer_type not in storage_oper.support_transtype(): + return False, f"{d.name} 的存储不支持 {d.transfer_type} 整理方式" + + return True, "" + + def __get_storage_oper(self, _storage: str, _func: Optional[str] = None) -> Optional[StorageBase]: + """ + 获取存储操作对象 + """ + for storage_schema in self._storage_schemas: + if storage_schema.schema \ + and storage_schema.schema.value == _storage \ + and (not _func or hasattr(storage_schema, _func)): + return storage_schema() + return None + + def init_setting(self) -> Tuple[str, Union[str, bool]]: + pass + + def storage_manage(self, storage: str, action: StorageAction, **params) -> Dict[str, Any]: + """ + 网盘存储统一管理入口,按存储标识路由 + + 动作语义与参数解释交给具体存储实现, + 统一返回 {"success": bool, "message": ..., "data": ...} + """ + try: + action = StorageAction(action) + except ValueError: + return {"success": False, "message": f"不支持的存储管理动作:{action}"} + if storage not in self._support_storages: + return {"success": False, "message": f"不支持的存储类型:{storage}"} + + if action == StorageAction.SAVE_CONFIG: + storage_oper = self.__get_storage_oper(storage) + if not storage_oper: + return {"success": False, "message": f"不支持 {storage} 的配置保存"} + storage_oper.set_config(params.get("conf") or {}) + return {"success": True} + if action == StorageAction.RESET_CONFIG: + storage_oper = self.__get_storage_oper(storage) + if not storage_oper: + return {"success": False, "message": f"不支持 {storage} 的重置存储配置"} + storage_oper.reset_config() + return {"success": True} + if action == StorageAction.SUPPORT_TRANSTYPE: + storage_oper = self.__get_storage_oper(storage) + if not storage_oper: + return {"success": False, "message": f"不支持 {storage} 的整理方式获取"} + # 与旧契约一致:返回值包装为 transtype,空结果同样返回成功空结构 + return {"success": True, "data": {"transtype": storage_oper.support_transtype() or {}}} + if action == StorageAction.USAGE: + storage_oper = self.__get_storage_oper(storage) + if not storage_oper: + return {"success": False, "message": f"不支持 {storage} 的存储使用情况"} + # 实现返回 pydantic 模型,转为 dict 后才能透过通用响应的开放映射校验 + return {"success": True, "data": (storage_oper.usage() or StorageUsage()).model_dump()} + + # 登录类动作:存储实现不支持时返回失败信息 + oper_method = action.value + storage_oper = self.__get_storage_oper(storage, oper_method) + if not storage_oper: + return {"success": False, "message": f"{storage} 不支持 {oper_method}"} + result = getattr(storage_oper, oper_method)(**params) + if result is None: + return {"success": False, "message": f"{storage} 的 {oper_method} 执行失败"} + data, errmsg = result + return {"success": bool(data), "message": errmsg, "data": data} + + @staticmethod + def recommend_name(meta: MetaBase, mediainfo: MediaInfo, + episodes_info: Optional[List[TmdbEpisode]] = None) -> Optional[str]: + """ + 获取重命名后的名称 + :param meta: 元数据 + :param mediainfo: 媒体信息 + :param episodes_info: 集信息,由调用方链层预先获取 + :return: 重命名后的名称(含目录) + """ + handler = TransHandler() + # 重命名格式 + rename_format = settings.RENAME_FORMAT(mediainfo.type) + # 获取重命名后的名称 + path = handler.get_rename_path( + template_string=rename_format, + rename_dict=handler.get_naming_dict(meta=meta, + mediainfo=mediainfo, + episodes_info=episodes_info, + file_ext=Path(meta.title).suffix) + ) + return path.as_posix() if path else "" + + def list_files(self, fileitem: FileItem, recursion: Optional[bool] = False) -> Optional[List[FileItem]]: + """ + 浏览文件 + :param fileitem: 源文件 + :param recursion: 是否递归,此时只浏览文件 + :return: 文件项列表 + """ + if fileitem.storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(fileitem.storage) + if not storage_oper: + logger.error(f"不支持 {fileitem.storage} 的文件浏览") + return None + + def __get_files(_item: FileItem, _r: Optional[bool] = False): + """ + 递归处理 + """ + _items = storage_oper.list(_item) + if _items: + if _r: + for t in _items: + if t.type == "dir": + __get_files(t, _r) + else: + result.append(t) + else: + result.extend(_items) + + # 返回结果 + result = [] + __get_files(fileitem, recursion) + + return result + + def any_files(self, fileitem: FileItem, extensions: list = None) -> Optional[bool]: + """ + 查询当前目录下是否存在指定扩展名任意文件 + """ + if fileitem.storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(fileitem.storage) + if not storage_oper: + logger.error(f"不支持 {fileitem.storage} 的文件浏览") + return None + + def __any_file(_item: FileItem): + """ + 递归处理 + """ + _items = storage_oper.list(_item) + if _items: + if not extensions: + return True + for t in _items: + if (t.type == "file" + and t.extension + and f".{t.extension.lower()}" in extensions): + return True + elif t.type == "dir": + if __any_file(t): + return True + return False + + # 返回结果 + return __any_file(fileitem) + + def create_folder(self, fileitem: FileItem, name: str) -> Optional[FileItem]: + """ + 创建目录 + :param fileitem: 源文件 + :param name: 目录名 + :return: 创建的目录 + """ + if fileitem.storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(fileitem.storage) + if not storage_oper: + logger.error(f"不支持 {fileitem.storage} 的目录创建") + return None + return storage_oper.create_folder(fileitem, name) + + def get_folder(self, storage: str, path: Path) -> Optional[FileItem]: + """ + 获取目录,如目录不存在则创建 + """ + if storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(storage) + if not storage_oper: + logger.error(f"不支持 {storage} 的目录获取") + return None + return storage_oper.get_folder(path) + + def delete_file(self, fileitem: FileItem) -> Optional[bool]: + """ + 删除文件或目录 + """ + if fileitem.storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(fileitem.storage) + if not storage_oper: + logger.error(f"不支持 {fileitem.storage} 的删除处理") + return False + return storage_oper.delete(fileitem) + + def rename_file(self, fileitem: FileItem, name: str) -> Optional[bool]: + """ + 重命名文件或目录 + """ + if fileitem.storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(fileitem.storage) + if not storage_oper: + logger.error(f"不支持 {fileitem.storage} 的重命名处理") + return False + return storage_oper.rename(fileitem, name) + + def download_file(self, fileitem: FileItem, path: Path = None) -> Optional[Path]: + """ + 下载文件 + """ + if fileitem.storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(fileitem.storage) + if not storage_oper: + logger.error(f"不支持 {fileitem.storage} 的下载处理") + return None + return storage_oper.download(fileitem, path=path) + + def upload_file(self, fileitem: FileItem, path: Path, new_name: Optional[str] = None) -> Optional[FileItem]: + """ + 上传文件 + """ + if fileitem.storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(fileitem.storage) + if not storage_oper: + logger.error(f"不支持 {fileitem.storage} 的上传处理") + return None + return storage_oper.upload(fileitem, path, new_name) + + def get_file_item(self, storage: str, path: Path) -> Optional[FileItem]: + """ + 根据路径获取文件项 + """ + if storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(storage) + if not storage_oper: + logger.error(f"不支持 {storage} 的文件获取") + return None + return storage_oper.get_item(path) + + def get_parent_item(self, fileitem: FileItem) -> Optional[FileItem]: + """ + 获取上级目录项 + """ + if fileitem.storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(fileitem.storage) + if not storage_oper: + logger.error(f"不支持 {fileitem.storage} 的文件获取") + return None + return storage_oper.get_parent(fileitem) + + def snapshot_storage(self, storage: str, path: Path, + last_snapshot_time: float = None, max_depth: int = 5, + previous_snapshot: Optional[Dict[str, Dict]] = None) -> Optional[Dict[str, Dict]]: + """ + 快照存储 + :param storage: 存储类型 + :param path: 路径 + :param last_snapshot_time: 上次快照时间,用于增量快照 + :param max_depth: 最大递归深度,避免过深遍历 + :param previous_snapshot: 上次完整快照,用于增量对账 + """ + if storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(storage) + if not storage_oper: + logger.error(f"不支持 {storage} 的快照处理") + return None + return storage_oper.snapshot( + path, + last_snapshot_time=last_snapshot_time, + max_depth=max_depth, + previous_snapshot=previous_snapshot + ) + + def transfer(self, fileitem: FileItem, meta: MetaBase, mediainfo: MediaInfo, + target_directory: TransferDirectoryConf = None, + target_storage: Optional[str] = None, target_path: Path = None, + transfer_type: Optional[str] = None, scrape: Optional[bool] = None, + library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None, + episodes_info: List[TmdbEpisode] = None, + source_oper: Callable = None, target_oper: Callable = None, + preview: Optional[bool] = False) -> TransferInfo: + """ + 文件整理 + :param fileitem: 文件信息 + :param meta: 预识别的元数据 + :param mediainfo: 识别的媒体信息 + :param target_directory: 目标目录配置 + :param target_storage: 目标存储 + :param target_path: 目标路径 + :param transfer_type: 转移模式 + :param scrape: 是否刮削元数据 + :param library_type_folder: 是否按媒体类型创建目录 + :param library_category_folder: 是否按媒体类别创建目录 + :param episodes_info: 当前季的全部集信息 + :param source_oper: 源存储操作对象 + :param target_oper: 目标存储操作对象 + :return: {path, target_path, message} + """ + handler = TransHandler() + # 检查目录路径 + if fileitem.storage == "local" and not Path(fileitem.path).exists(): + return TransferInfo(success=False, + fileitem=fileitem, + message=f"{fileitem.path} 不存在") + # 目标路径不能是文件 + if target_path and target_path.is_file(): + logger.error(f"整理目标路径 {target_path} 是一个文件") + return TransferInfo(success=False, + fileitem=fileitem, + message=f"{target_path} 不是有效目录") + # 获取目标路径 + if target_directory: + # 目标媒体库目录未设置 + if not target_directory.library_path: + logger.error(f"目标媒体库目录未设置,无法整理文件,源路径:{fileitem.path}") + return TransferInfo(success=False, + fileitem=fileitem, + message="目标媒体库目录未设置") + # 整理方式 + if not transfer_type: + transfer_type = target_directory.transfer_type + # 目标存储 + if not target_storage: + target_storage = target_directory.library_storage + # 是否需要重命名 + need_rename = target_directory.renaming + # 是否需要通知 + need_notify = target_directory.notify + # 覆盖模式 + overwrite_mode = target_directory.overwrite_mode + # 是否需要刮削 + need_scrape = target_directory.scraping if scrape is None else scrape + # 拼装媒体库一、二级子目录 + target_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=target_directory, + need_type_folder=library_type_folder, + need_category_folder=library_category_folder) + elif target_path: + need_scrape = scrape or False + need_rename = True + need_notify = False + overwrite_mode = "never" + # 手动整理的场景,有自定义目标路径 + target_path = handler.get_dest_path(mediainfo=mediainfo, target_path=target_path, + need_type_folder=library_type_folder, + need_category_folder=library_category_folder) + else: + # 未找到有效的媒体库目录 + logger.error( + f"{mediainfo.type.value if mediainfo.type else '未知类型'} {mediainfo.title_year} 未找到有效的媒体库目录,无法整理文件,源路径:{fileitem.path}") + return TransferInfo(success=False, + fileitem=fileitem, + message="未找到有效的媒体库目录") + # 整理方式 + if not transfer_type: + logger.error(f"{target_directory.name} 未设置整理方式") + return TransferInfo(success=False, + fileitem=fileitem, + message=f"{target_directory.name} 未设置整理方式") + + # 源操作对象 + if not source_oper: + source_oper = self.__get_storage_oper(fileitem.storage) + if not source_oper: + return TransferInfo(success=False, + message=f"不支持的存储类型:{fileitem.storage}", + fileitem=fileitem, + fail_list=[fileitem.path], + transfer_type=transfer_type, + need_notify=need_notify + ) + # 目的操作对象 + if not target_oper: + if not target_storage: + target_storage = fileitem.storage + target_oper = self.__get_storage_oper(target_storage) + if not target_oper: + return TransferInfo(success=False, + message=f"不支持的存储类型:{target_storage}", + fileitem=fileitem, + fail_list=[fileitem.path], + transfer_type=transfer_type, + need_notify=need_notify) + + # 整理 + logger.info(f"获取整理目标路径:【{target_storage}】{target_path}") + return handler.transfer_media(fileitem=fileitem, + in_meta=meta, + mediainfo=mediainfo, + target_storage=target_storage, + target_path=target_path, + transfer_type=transfer_type, + need_scrape=need_scrape, + need_rename=need_rename, + need_notify=need_notify, + overwrite_mode=overwrite_mode, + episodes_info=episodes_info, + preview=preview, + source_oper=source_oper, + target_oper=target_oper) + + @staticmethod + def _build_library_lookup_meta( + mediainfo: Union[MediaInfo, MusicInfo], + ) -> MetaBase: + """构造标准媒体库路径反查使用的最小元数据。""" + if mediainfo.type == MediaType.MUSIC: + music_type = getattr(mediainfo, "music_type", None) + album = getattr(mediainfo, "album", None) + if not album and music_type == MUSIC_ENTITY_ALBUM: + album = mediainfo.title + return MetaMusic( + title=mediainfo.title, + artists=list(getattr(mediainfo, "artists", None) or []), + album=album, + album_artist=getattr(mediainfo, "album_artist", None), + year=mediainfo.year, + disc_number=getattr(mediainfo, "disc_number", None), + track_number=getattr(mediainfo, "track_number", None), + total_tracks=getattr(mediainfo, "total_tracks", None), + media_source=getattr(mediainfo, "source", None), + media_id=getattr(mediainfo, "media_id", None), + ) + + meta = MetaInfo(mediainfo.title) + if meta.type == MediaType.UNKNOWN and mediainfo.type is not None: + meta.type = mediainfo.type + if meta.year is None: + meta.year = mediainfo.year + if meta.begin_season is None: + meta.begin_season = 1 + if meta.begin_episode is None: + meta.begin_episode = 1 + return meta + + @staticmethod + def _music_file_identity(fileitem: FileItem) -> Tuple[Optional[int], Optional[int], str]: + """从标准音乐文件路径提取碟号、曲序和归一化曲名。""" + file_path = Path(fileitem.path or fileitem.name or "") + file_meta = MetaMusic( + org_string=file_path.name, + title=file_path.stem, + ).apply_path_context(file_path) + return ( + file_meta.disc_number, + file_meta.track_number, + text_tools.normalize_upper(file_meta.title or file_path.stem), + ) + + @classmethod + def _music_recording_exists( + cls, + fileitems: List[FileItem], + mediainfo: MusicInfo, + ) -> bool: + """按曲名和可用曲序判断单曲是否存在,避免专辑内任一文件造成误判。""" + target_title = text_tools.normalize_upper(mediainfo.title or "") + target_track = getattr(mediainfo, "track_number", None) + target_disc = getattr(mediainfo, "disc_number", None) + if not target_title: + return False + for fileitem in fileitems: + disc_number, track_number, title = cls._music_file_identity(fileitem) + if title != target_title: + continue + if target_track is not None and track_number not in (None, target_track): + continue + if target_disc is not None and disc_number not in (None, target_disc): + continue + return True + return False + + @classmethod + def _music_album_is_complete( + cls, + fileitems: List[FileItem], + total_tracks: Optional[int], + ) -> bool: + """按去重后的曲序或曲名判断本地专辑是否达到目标曲目数。""" + if not fileitems: + return False + if not total_tracks: + return False + track_identities = { + ( + disc_number or 1, + track_number if track_number is not None else title, + ) + for disc_number, track_number, title in ( + cls._music_file_identity(fileitem) for fileitem in fileitems + ) + if track_number is not None or title + } + return len(track_identities) >= total_tracks + + def media_files(self, mediainfo: Union[MediaInfo, MusicInfo]) -> List[FileItem]: + """ + 获取对应媒体的媒体库文件列表 + :param mediainfo: 媒体信息 + """ + handler = TransHandler() + ret_fileitems = [] + # 检查本地媒体库 + dest_dirs = DirectoryHelper().get_library_dirs() + # 检查每一个媒体库目录 + for dest_dir in dest_dirs: + # 存储 + storage_oper = self.__get_storage_oper(dest_dir.library_storage) + if not storage_oper: + continue + # 媒体分类路径 + dir_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=dest_dir) + # 重命名格式 + rename_format = settings.RENAME_FORMAT(mediainfo.type) + # 元数据补上常用属性,尽可能确保重命名后的路径不出现空白 + meta = self._build_library_lookup_meta(mediainfo) + # 获取路径(重命名路径) + target_path = handler.get_rename_path( + path=dir_path, + template_string=rename_format, + rename_dict=handler.get_naming_dict(meta=meta, + mediainfo=mediainfo) + ) + # 获取重命名后的媒体文件根路径 + media_path = DirectoryHelper.get_media_root_path( + rename_format, + rename_path=target_path, + media_type=mediainfo.type, + ) + if not media_path: + # 忽略 + continue + if dir_path != media_path and dir_path.is_relative_to(media_path): + # 兜底检查,避免不必要的扫盘 + logger.warn(f"{media_path} 是媒体库目录 {dir_path} 的父目录,忽略获取媒体文件列表,请检查重命名格式!") + continue + # 检索媒体文件 + fileitem = storage_oper.get_item(media_path) + if not fileitem: + continue + try: + media_files = self.list_files(fileitem, True) + except Exception as e: + logger.debug(f"获取媒体文件列表失败:{str(e)}") + continue + if media_files: + media_extensions = ( + settings.RMT_AUDIOEXT + if mediainfo.type == MediaType.MUSIC + else settings.RMT_MEDIAEXT + ) + for media_file in media_files: + if ( + media_file.extension + and f".{media_file.extension.lower()}" in media_extensions + ): + if media_file not in ret_fileitems: + ret_fileitems.append(media_file) + return ret_fileitems + + def media_exists( + self, + mediainfo: Union[MediaInfo, MusicInfo], + **kwargs, + ) -> Optional[ExistMediaInfo]: + """ + 判断媒体文件是否存在于文件系统(网盘或本地文件),只支持标准媒体库结构 + :param mediainfo: 识别的媒体信息 + :param server: 指定媒体服务器名称时跳过本地文件系统检查 + :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} + """ + if kwargs.get("server"): + return None + + if not settings.LOCAL_EXISTS_SEARCH: + return None + + logger.debug(f"正在本地媒体库中查找 {mediainfo.title_year}...") + + # 检查媒体库 + fileitems = self.media_files(mediainfo) + if not fileitems: + logger.debug(f"{mediainfo.title_year} 不在本地媒体库中") + return None + + if mediainfo.type == MediaType.MOVIE: + # 电影存在任何文件为存在 + logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了") + return ExistMediaInfo(type=MediaType.MOVIE) + if mediainfo.type == MediaType.MUSIC: + if getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM: + exists = self._music_album_is_complete( + fileitems, + getattr(mediainfo, "total_tracks", None), + ) + else: + exists = self._music_recording_exists(fileitems, mediainfo) + if not exists: + logger.debug(f"{mediainfo.title_year} 在本地音乐库中尚不完整") + return None + logger.info(f"{mediainfo.title_year} 在本地音乐库中找到了") + return ExistMediaInfo(type=MediaType.MUSIC) + if mediainfo.type == MediaType.TV: + # 电视剧检索集数 + seasons: Dict[int, list] = {} + for fileitem in fileitems: + file_meta = MetaInfo(fileitem.basename) + season_index = file_meta.begin_season if file_meta.begin_season is not None else 1 + episode_index = file_meta.begin_episode + if not episode_index: + continue + if season_index not in seasons: + seasons[season_index] = [] + if episode_index not in seasons[season_index]: + seasons[season_index].append(episode_index) + # 返回剧集情况 + logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{seasons}") + return ExistMediaInfo(type=MediaType.TV, seasons=seasons) + return None diff --git a/app/modules/filemanager/storages/__init__.py b/app/modules/filemanager/storages/__init__.py index f9fbe98b2..ebca7cd44 100644 --- a/app/modules/filemanager/storages/__init__.py +++ b/app/modules/filemanager/storages/__init__.py @@ -4,7 +4,9 @@ from typing import Optional, List, Dict, Tuple, Callable, Union from tqdm import tqdm -from app import schemas +from app.schemas.file import StorageUsage as _SchemaStorageUsage +from app.schemas.system import StorageConf as _SchemaStorageConf +from app.schemas.workflow import FileItem as _SchemaFileItem from app.runtime.progress import ProgressHelper from app.application.storage import StorageHelper from app.runtime.log import logger @@ -69,7 +71,7 @@ class StorageBase(metaclass=ABCMeta): """检查存储登录状态""" pass - def get_config(self) -> Optional[schemas.StorageConf]: + def get_config(self) -> Optional[_SchemaStorageConf]: """ 获取配置 """ @@ -122,7 +124,7 @@ class StorageBase(metaclass=ABCMeta): return safe_name def _build_download_path( - self, fileitem: schemas.FileItem, path: Path + self, fileitem: _SchemaFileItem, path: Path ) -> Optional[Path]: """ 构造本地下载路径,避免远端文件名携带目录片段时越过目标目录。 @@ -148,14 +150,14 @@ class StorageBase(metaclass=ABCMeta): pass @abstractmethod - def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: + def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]: """ 浏览文件 """ pass @abstractmethod - def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: + def create_folder(self, fileitem: _SchemaFileItem, name: str) -> Optional[_SchemaFileItem]: """ 创建目录 :param fileitem: 父目录 @@ -164,20 +166,20 @@ class StorageBase(metaclass=ABCMeta): pass @abstractmethod - def get_folder(self, path: Path) -> Optional[schemas.FileItem]: + def get_folder(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取目录,如目录不存在则创建 """ pass @abstractmethod - def get_item(self, path: Path) -> Optional[schemas.FileItem]: + def get_item(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取文件或目录,不存在返回None """ pass - def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]: + def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。 @@ -188,28 +190,28 @@ class StorageBase(metaclass=ABCMeta): """ raise StorageQueryError(f"存储 {self.schema} 未实现严格查询,无法确认目标状态: {path}") - def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + def get_parent(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ 获取父目录 """ return self.get_item(Path(fileitem.path).parent) @abstractmethod - def delete(self, fileitem: schemas.FileItem) -> bool: + def delete(self, fileitem: _SchemaFileItem) -> bool: """ 删除文件 """ pass @abstractmethod - def rename(self, fileitem: schemas.FileItem, name: str) -> bool: + def rename(self, fileitem: _SchemaFileItem, name: str) -> bool: """ 重命名文件 """ pass @abstractmethod - def download(self, fileitem: schemas.FileItem, path: Path = None) -> Path: + def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Path: """ 下载文件,保存到本地,返回本地临时文件地址 :param fileitem: 文件项 @@ -218,8 +220,8 @@ class StorageBase(metaclass=ABCMeta): pass @abstractmethod - def upload(self, fileitem: schemas.FileItem, path: Path, - new_name: Optional[str] = None) -> Optional[schemas.FileItem]: + def upload(self, fileitem: _SchemaFileItem, path: Path, + new_name: Optional[str] = None) -> Optional[_SchemaFileItem]: """ 上传文件 :param fileitem: 上传目录项 @@ -229,14 +231,14 @@ class StorageBase(metaclass=ABCMeta): pass @abstractmethod - def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ 获取文件详情 """ pass @abstractmethod - def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 复制文件 :param fileitem: 文件项 @@ -246,7 +248,7 @@ class StorageBase(metaclass=ABCMeta): pass @abstractmethod - def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 移动文件 :param fileitem: 文件项 @@ -256,21 +258,21 @@ class StorageBase(metaclass=ABCMeta): pass @abstractmethod - def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: """ 硬链接文件 """ pass @abstractmethod - def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: """ 软链接文件 """ pass @abstractmethod - def usage(self) -> Optional[schemas.StorageUsage]: + def usage(self) -> Optional[_SchemaStorageUsage]: """ 存储使用情况 """ @@ -292,8 +294,8 @@ class StorageBase(metaclass=ABCMeta): if PurePosixPath(file_path).is_relative_to(root_path) } - def __remove_deleted_children(_fileitm: schemas.FileItem, - sub_files: List[schemas.FileItem]) -> None: + def __remove_deleted_children(_fileitm: _SchemaFileItem, + sub_files: List[_SchemaFileItem]) -> None: """ 清理已确认遍历目录中不再存在的直接子项。 未变化的子目录仍保留旧基线,避免增量遍历将其误删。 @@ -311,7 +313,7 @@ class StorageBase(metaclass=ABCMeta): if direct_child_path not in child_paths: files_info.pop(old_file_path, None) - def __snapshot_file(_fileitm: schemas.FileItem, current_depth: int = 0): + def __snapshot_file(_fileitm: _SchemaFileItem, current_depth: int = 0): """ 递归获取文件信息 """ diff --git a/app/modules/filemanager/storages/alipan.py b/app/modules/filemanager/storages/alipan.py index a55a9c562..206b79fb1 100644 --- a/app/modules/filemanager/storages/alipan.py +++ b/app/modules/filemanager/storages/alipan.py @@ -8,7 +8,8 @@ from typing import List, Optional, Tuple, Union import requests -from app import schemas +from app.schemas.file import StorageUsage as _SchemaStorageUsage +from app.schemas.workflow import FileItem as _SchemaFileItem from app.runtime.config import settings, global_vars from app.runtime.log import logger from app.modules.filemanager import StorageBase @@ -287,16 +288,16 @@ class AliPan(StorageBase, metaclass=WeakSingleton): return ret_data.get(result_key) return ret_data - def __get_fileitem(self, fileinfo: dict, parent: str = "/") -> schemas.FileItem: + def __get_fileitem(self, fileinfo: dict, parent: str = "/") -> _SchemaFileItem: """ 获取文件信息 """ if not fileinfo: - return schemas.FileItem() + return _SchemaFileItem() if not parent.endswith("/"): parent += "/" if fileinfo.get("type") == "folder": - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, fileid=fileinfo.get("file_id"), parent_fileid=fileinfo.get("parent_file_id"), @@ -309,7 +310,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): drive_id=fileinfo.get("drive_id"), ) else: - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, fileid=fileinfo.get("file_id"), parent_fileid=fileinfo.get("parent_file_id"), @@ -343,7 +344,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): def init_storage(self): pass - def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: + def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]: """ 目录遍历实现 """ @@ -386,7 +387,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): break return items - def _delay_get_item(self, path: Path) -> Optional[schemas.FileItem]: + def _delay_get_item(self, path: Path) -> Optional[_SchemaFileItem]: """ 自动延迟重试 get_item 模块 """ @@ -398,8 +399,8 @@ class AliPan(StorageBase, metaclass=WeakSingleton): return None def create_folder( - self, parent_item: schemas.FileItem, name: str - ) -> Optional[schemas.FileItem]: + self, parent_item: _SchemaFileItem, name: str + ) -> Optional[_SchemaFileItem]: """ 创建目录 """ @@ -588,10 +589,10 @@ class AliPan(StorageBase, metaclass=WeakSingleton): def upload( self, - target_dir: schemas.FileItem, + target_dir: _SchemaFileItem, local_path: Path, new_name: Optional[str] = None, - ) -> Optional[schemas.FileItem]: + ) -> Optional[_SchemaFileItem]: """ 文件上传:分片、支持秒传 """ @@ -721,7 +722,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): ) return self.__get_fileitem(result, parent=target_dir.path) - def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: + def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]: """ 带实时进度显示的下载 """ @@ -801,7 +802,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): def check(self) -> bool: return self.access_token is not None - def delete(self, fileitem: schemas.FileItem) -> bool: + def delete(self, fileitem: _SchemaFileItem) -> bool: """ 删除文件/目录 """ @@ -815,7 +816,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): except requests.exceptions.HTTPError: return False - def rename(self, fileitem: schemas.FileItem, name: str) -> bool: + def rename(self, fileitem: _SchemaFileItem, name: str) -> bool: """ 重命名文件/目录 """ @@ -835,7 +836,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): return False return True - def __get_by_path_item(self, path: Path, drive_id: str = None) -> Optional[schemas.FileItem]: + def __get_by_path_item(self, path: Path, drive_id: str = None) -> Optional[_SchemaFileItem]: """ 按路径查询文件/目录项,无法确认状态时抛出 StorageQueryError。 NotFound 系列错误码表示确认不存在,其余错误(网络失败、限流、 @@ -861,7 +862,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): f"【阿里云盘】查询文件信息出错: {path} - {code} {resp.get('message')}") return self.__get_fileitem(resp, parent=str(path.parent)) - def get_item(self, path: Path, drive_id: str = None) -> Optional[schemas.FileItem]: + def get_item(self, path: Path, drive_id: str = None) -> Optional[_SchemaFileItem]: """ 获取指定路径的文件/目录项 """ @@ -871,7 +872,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): logger.debug(f"【阿里云盘】获取文件信息失败: {str(e)}") return None - def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]: + def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取指定路径的文件/目录项,无法确认状态时抛出 StorageQueryError。 """ @@ -882,14 +883,14 @@ class AliPan(StorageBase, metaclass=WeakSingleton): except Exception as e: raise StorageQueryError(f"【阿里云盘】查询文件信息失败: {path} - {e}") from e - def get_folder(self, path: Path) -> Optional[schemas.FileItem]: + def get_folder(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取指定路径的文件夹,如不存在则创建 """ def __find_dir( - _fileitem: schemas.FileItem, _name: str - ) -> Optional[schemas.FileItem]: + _fileitem: _SchemaFileItem, _name: str + ) -> Optional[_SchemaFileItem]: """ 查找下级目录中匹配名称的目录 """ @@ -905,7 +906,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): if folder: return folder # 逐级查找和创建目录 - fileitem = schemas.FileItem( + fileitem = _SchemaFileItem( storage=self.schema.value, path="/", drive_id=self._default_drive_id ) for part in path.parts[1:]: @@ -920,13 +921,13 @@ class AliPan(StorageBase, metaclass=WeakSingleton): fileitem = dir_file return fileitem - def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ 获取文件/目录详细信息 """ return self.get_item(Path(fileitem.path)) - def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 复制文件到指定路径 :param fileitem: 要复制的文件项 @@ -958,7 +959,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): self.rename(new_file, new_name) return True - def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 移动文件到指定路径 :param fileitem: 要移动的文件项 @@ -988,13 +989,13 @@ class AliPan(StorageBase, metaclass=WeakSingleton): return False return True - def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: pass - def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: pass - def usage(self) -> Optional[schemas.StorageUsage]: + def usage(self) -> Optional[_SchemaStorageUsage]: """ 获取带有企业级配额信息的存储使用情况 """ @@ -1005,7 +1006,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): space = resp.get("personal_space_info") or {} total_size = space.get("total_size") or 0 used_size = space.get("used_size") or 0 - return schemas.StorageUsage( + return _SchemaStorageUsage( total=total_size, available=total_size - used_size ) except NoCheckInException: diff --git a/app/modules/filemanager/storages/alist.py b/app/modules/filemanager/storages/alist.py index 46f2f6883..4b936abf6 100644 --- a/app/modules/filemanager/storages/alist.py +++ b/app/modules/filemanager/storages/alist.py @@ -5,7 +5,8 @@ from datetime import datetime from pathlib import Path from typing import Optional, List -from app import schemas +from app.schemas.file import StorageUsage as _SchemaStorageUsage +from app.schemas.workflow import FileItem as _SchemaFileItem from app.runtime.cache import cached from app.runtime.config import settings, global_vars from app.runtime.log import logger @@ -54,7 +55,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): def _delay_get_item( self, path: Path, /, refresh: bool = False - ) -> Optional[schemas.FileItem]: + ) -> Optional[_SchemaFileItem]: """ 自动延迟重试 get_item 模块 @@ -70,8 +71,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): return None def __build_transfer_item( - self, source_item: schemas.FileItem, target_path: Path - ) -> schemas.FileItem: + self, source_item: _SchemaFileItem, target_path: Path + ) -> _SchemaFileItem: """ 根据目标路径构造文件项,用于 OpenList 操作成功但元数据短时间不可见的场景。 目录项路径需要遵循 FileItem 以斜杠结尾的约定。 @@ -80,7 +81,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): if source_item.type == "dir" and not target_path_str.endswith("/"): target_path_str = f"{target_path_str}/" - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type=source_item.type, path=target_path_str, @@ -197,12 +198,12 @@ class Alist(StorageBase, metaclass=WeakSingleton): def list( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, password: Optional[str] = "", page: int = 1, per_page: int = 0, refresh: bool = False, - ) -> List[schemas.FileItem]: + ) -> List[_SchemaFileItem]: """ 浏览文件 :param fileitem: 文件项 @@ -291,7 +292,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): page_content = page_data.get("content") or [] items.extend( [ - schemas.FileItem( + _SchemaFileItem( storage=self.schema.value, type="dir" if item["is_dir"] else "file", path=(Path(fileitem.path) / item["name"]).as_posix() @@ -321,8 +322,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): current_page += 1 def create_folder( - self, fileitem: schemas.FileItem, name: str - ) -> Optional[schemas.FileItem]: + self, fileitem: _SchemaFileItem, name: str + ) -> Optional[_SchemaFileItem]: """ 创建目录 :param fileitem: 父目录 @@ -364,7 +365,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): return self._delay_get_item( path, refresh=True ) or self.__build_transfer_item( - schemas.FileItem( + _SchemaFileItem( storage=self.schema.value, type="dir", path=fileitem.path, @@ -374,7 +375,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): path, ) - def get_folder(self, path: Path) -> Optional[schemas.FileItem]: + def get_folder(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取目录,如目录不存在则创建 @@ -386,7 +387,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): return folder if not folder: folder = self.create_folder( - schemas.FileItem( + _SchemaFileItem( storage=self.schema.value, type="dir", path=path.parent.as_posix(), @@ -404,7 +405,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): page: int = 1, per_page: int = 0, refresh: bool = False, - ) -> Optional[schemas.FileItem]: + ) -> Optional[_SchemaFileItem]: """ 获取文件或目录,不存在返回None :param path: 文件路径 @@ -473,14 +474,14 @@ class Alist(StorageBase, metaclass=WeakSingleton): return self.__build_fileitem(path, result["data"]) - def __build_fileitem(self, path: Path, data: dict) -> schemas.FileItem: + def __build_fileitem(self, path: Path, data: dict) -> _SchemaFileItem: """ 根据接口返回数据构建文件项。 :param path: 文件路径 :param data: 接口返回的 data 字段 :return: 文件项 """ - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type="dir" if data["is_dir"] else "file", path=path.as_posix() + ("/" if data["is_dir"] else ""), @@ -492,7 +493,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): thumbnail=data["thumb"], ) - def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]: + def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。 只有接口明确回报「对象不存在」才是确定结果,连接失败、HTTP 异常与其他 @@ -523,7 +524,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): raise StorageQueryError(f"【OpenList】查询文件 {path} 失败:{message}") return self.__build_fileitem(path, result["data"]) - def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + def get_parent(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ 获取父目录 @@ -532,7 +533,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): """ return self.get_folder(Path(fileitem.path).parent) - def delete(self, fileitem: schemas.FileItem) -> bool: + def delete(self, fileitem: _SchemaFileItem) -> bool: """ 删除文件或目录 @@ -570,7 +571,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): return False return True - def rename(self, fileitem: schemas.FileItem, name: str) -> bool: + def rename(self, fileitem: _SchemaFileItem, name: str) -> bool: """ 重命名文件 @@ -619,7 +620,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): def download( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, path: Path = None, password: Optional[str] = "", ) -> Optional[Path]: @@ -712,11 +713,11 @@ class Alist(StorageBase, metaclass=WeakSingleton): def upload( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, path: Path, new_name: Optional[str] = None, task: bool = False, - ) -> Optional[schemas.FileItem]: + ) -> Optional[_SchemaFileItem]: """ 上传文件(带进度) :param fileitem: 上传目录项 @@ -830,13 +831,13 @@ class Alist(StorageBase, metaclass=WeakSingleton): "X-File-Sha256": sha256_hash.hexdigest(), } - def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ 获取文件详情 """ return self.get_item(Path(fileitem.path)) - def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 复制文件 :param fileitem: 文件项 @@ -892,8 +893,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): return True def copy_item( - self, fileitem: schemas.FileItem, path: Path, new_name: str - ) -> Optional[schemas.FileItem]: + self, fileitem: _SchemaFileItem, path: Path, new_name: str + ) -> Optional[_SchemaFileItem]: """ 复制文件并返回目标文件项,兼容 OpenList 成功响应不携带目标对象的格式。 """ @@ -913,7 +914,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): ) or self.__build_transfer_item(fileitem, target_path) return None - def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 移动文件 :param fileitem: 文件项 @@ -967,8 +968,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): return True def move_item( - self, fileitem: schemas.FileItem, path: Path, new_name: str - ) -> Optional[schemas.FileItem]: + self, fileitem: _SchemaFileItem, path: Path, new_name: str + ) -> Optional[_SchemaFileItem]: """ 移动文件并返回目标文件项,兼容 OpenList 成功响应不携带目标对象的格式。 """ @@ -979,19 +980,19 @@ class Alist(StorageBase, metaclass=WeakSingleton): fileitem, target_path ) - def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: """ 硬链接文件 """ pass - def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: """ 软链接文件 """ pass - def usage(self) -> Optional[schemas.StorageUsage]: + def usage(self) -> Optional[_SchemaStorageUsage]: """ 存储使用情况 """ diff --git a/app/modules/filemanager/storages/local.py b/app/modules/filemanager/storages/local.py index 44ddfc921..55b55fd7f 100644 --- a/app/modules/filemanager/storages/local.py +++ b/app/modules/filemanager/storages/local.py @@ -4,7 +4,8 @@ import time from pathlib import Path from typing import Optional, List -from app import schemas +from app.schemas.file import StorageUsage as _SchemaStorageUsage +from app.schemas.workflow import FileItem as _SchemaFileItem from app.runtime.config import global_vars, settings from app.application.directory import DirectoryHelper from app.runtime.log import logger @@ -45,7 +46,7 @@ class LocalStorage(StorageBase): """ return True - def __get_fileitem(self, path: Path) -> schemas.FileItem: + def __get_fileitem(self, path: Path) -> _SchemaFileItem: """ 获取文件项 """ @@ -53,7 +54,7 @@ class LocalStorage(StorageBase): # 顺带只 stat 一次——原先 size 与 modify_time 各 stat 一次,在网络挂载上 # 等于把这个热点路径的开销翻倍 info = fsproxy.stat(path) - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type="file", path=path.as_posix(), @@ -64,11 +65,11 @@ class LocalStorage(StorageBase): modify_time=info["mtime"], ) - def __get_diritem(self, path: Path) -> schemas.FileItem: + def __get_diritem(self, path: Path) -> _SchemaFileItem: """ 获取目录项 """ - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type="dir", path=path.as_posix() + "/", @@ -77,7 +78,7 @@ class LocalStorage(StorageBase): modify_time=fsproxy.stat(path)["mtime"], ) - def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: + def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]: """ 浏览文件 """ @@ -88,7 +89,7 @@ class LocalStorage(StorageBase): if SystemUtils.is_windows(): partitions = SystemUtils.get_windows_drives() or ["C:/"] for partition in partitions: - ret_items.append(schemas.FileItem( + ret_items.append(_SchemaFileItem( storage=self.schema.value, type="dir", path=partition + "/", @@ -126,7 +127,7 @@ class LocalStorage(StorageBase): ret_items.append(self.__get_fileitem(item)) return ret_items - def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: + def create_folder(self, fileitem: _SchemaFileItem, name: str) -> Optional[_SchemaFileItem]: """ 创建目录 :param fileitem: 父目录 @@ -139,7 +140,7 @@ class LocalStorage(StorageBase): path_obj.mkdir(parents=True, exist_ok=True) return self.__get_diritem(path_obj) - def get_folder(self, path: Path) -> Optional[schemas.FileItem]: + def get_folder(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取目录 """ @@ -147,7 +148,7 @@ class LocalStorage(StorageBase): path.mkdir(parents=True, exist_ok=True) return self.__get_diritem(path) - def get_item(self, path: Path) -> Optional[schemas.FileItem]: + def get_item(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取文件或目录,不存在返回None """ @@ -159,7 +160,7 @@ class LocalStorage(StorageBase): return self.__get_fileitem(path) return self.__get_diritem(path) - def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]: + def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取文件或目录,无法确认状态时抛出 StorageQueryError。 Path.exists() 会把部分 errno(如 EBADF/ELOOP)归入「不存在」, @@ -178,7 +179,7 @@ class LocalStorage(StorageBase): except OSError as e: raise StorageQueryError(f"【本地】读取文件信息失败: {path} - {e}") from e - def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ 获取文件详情 """ @@ -187,7 +188,7 @@ class LocalStorage(StorageBase): return None return self.__get_fileitem(path_obj) - def delete(self, fileitem: schemas.FileItem) -> bool: + def delete(self, fileitem: _SchemaFileItem) -> bool: """ 删除文件 """ @@ -211,7 +212,7 @@ class LocalStorage(StorageBase): return False return True - def rename(self, fileitem: schemas.FileItem, name: str) -> bool: + def rename(self, fileitem: _SchemaFileItem, name: str) -> bool: """ 重命名文件 """ @@ -225,7 +226,7 @@ class LocalStorage(StorageBase): return False return True - def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: + def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]: """ 下载文件 """ @@ -370,10 +371,10 @@ class LocalStorage(StorageBase): def upload( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, path: Path, new_name: Optional[str] = None - ) -> Optional[schemas.FileItem]: + ) -> Optional[_SchemaFileItem]: """ 上传文件(带进度) """ @@ -402,7 +403,7 @@ class LocalStorage(StorageBase): def copy( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, path: Path, new_name: str ) -> bool: @@ -413,7 +414,7 @@ class LocalStorage(StorageBase): def move( self, - fileitem: schemas.FileItem, + fileitem: _SchemaFileItem, path: Path, new_name: str ) -> bool: @@ -443,7 +444,7 @@ class LocalStorage(StorageBase): logger.warn(f"【本地】移动已完成但删除源文件失败:{src} - {err}") return True - def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: """ 硬链接文件 """ @@ -454,7 +455,7 @@ class LocalStorage(StorageBase): return False return True - def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: """ 软链接文件 """ @@ -465,7 +466,7 @@ class LocalStorage(StorageBase): return False return True - def usage(self) -> Optional[schemas.StorageUsage]: + def usage(self) -> Optional[_SchemaStorageUsage]: """ 存储使用情况 """ @@ -475,7 +476,7 @@ class LocalStorage(StorageBase): [Path(d.library_path) for d in directory_helper.get_local_library_dirs() if d.library_path], btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP, ) - return schemas.StorageUsage( + return _SchemaStorageUsage( total=total_storage, available=free_storage ) diff --git a/app/modules/filemanager/storages/rclone.py b/app/modules/filemanager/storages/rclone.py index c5cc884cd..19036c949 100644 --- a/app/modules/filemanager/storages/rclone.py +++ b/app/modules/filemanager/storages/rclone.py @@ -6,7 +6,8 @@ from collections import OrderedDict from pathlib import Path from typing import Optional, List, Union -from app import schemas +from app.schemas.file import StorageUsage as _SchemaStorageUsage +from app.schemas.workflow import FileItem as _SchemaFileItem from app.runtime.config import settings from app.runtime.log import logger from app.modules.filemanager.storages import StorageBase, transfer_process @@ -114,14 +115,14 @@ class Rclone(StorageBase): return None - def __get_rcloneitem(self, item: dict, parent: Optional[str] = "/") -> schemas.FileItem: + def __get_rcloneitem(self, item: dict, parent: Optional[str] = "/") -> _SchemaFileItem: """ 获取rclone文件项 """ if not item: - return schemas.FileItem() + return _SchemaFileItem() if item.get("IsDir"): - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type="dir", path=f"{parent}{item.get('Name')}" + "/", @@ -130,7 +131,7 @@ class Rclone(StorageBase): modify_time=time_tools.parse_timestamp(item.get("ModTime")) ) else: - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type="file", path=f"{parent}{item.get('Name')}", @@ -171,7 +172,7 @@ class Rclone(StorageBase): def __wait_for_item( self, path: Path, retries: int = 3, delay: float = 0.2 - ) -> Optional[schemas.FileItem]: + ) -> Optional[_SchemaFileItem]: """ 等待目录或文件在远端可见,兼容云盘最终一致性延迟。 """ @@ -198,7 +199,7 @@ class Rclone(StorageBase): logger.error(f"【rclone】存储检查失败:{err}") return False - def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: + def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]: """ 浏览文件 """ @@ -220,7 +221,7 @@ class Rclone(StorageBase): logger.error(f"【rclone】浏览文件失败:{err}") return [] - def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: + def create_folder(self, fileitem: _SchemaFileItem, name: str) -> Optional[_SchemaFileItem]: """ 创建目录 :param fileitem: 父目录 @@ -253,7 +254,7 @@ class Rclone(StorageBase): return folder return None - def get_folder(self, path: Path) -> Optional[schemas.FileItem]: + def get_folder(self, path: Path) -> Optional[_SchemaFileItem]: """ 根据文件路程获取目录,不存在则创建 """ @@ -264,7 +265,7 @@ class Rclone(StorageBase): if folder: return folder # 逐级查找和创建目录 - fileitem = schemas.FileItem(storage=self.schema.value, type="dir", path="/") + fileitem = _SchemaFileItem(storage=self.schema.value, type="dir", path="/") for part in normalized.parts[1:]: current_path = Path(self.__normalize_remote_path(Path(fileitem.path) / part)) with self.__get_path_lock(current_path): @@ -277,7 +278,7 @@ class Rclone(StorageBase): fileitem = dir_file return fileitem - def get_item(self, path: Path) -> Optional[schemas.FileItem]: + def get_item(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取文件或目录,不存在返回None """ @@ -300,7 +301,7 @@ class Rclone(StorageBase): logger.debug(f"【rclone】获取文件项失败:{err}") return None - def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]: + def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。 rclone 用退出码 3/4 表示目录/文件不存在,其余非零退出无法区分 @@ -332,7 +333,7 @@ class Rclone(StorageBase): return self.__get_rcloneitem(item, parent=str(path.parent) + "/") return None - def delete(self, fileitem: schemas.FileItem) -> bool: + def delete(self, fileitem: _SchemaFileItem) -> bool: """ 删除文件 """ @@ -350,7 +351,7 @@ class Rclone(StorageBase): logger.error(f"【rclone】删除文件失败:{err}") return False - def rename(self, fileitem: schemas.FileItem, name: str) -> bool: + def rename(self, fileitem: _SchemaFileItem, name: str) -> bool: """ 重命名文件 """ @@ -369,7 +370,7 @@ class Rclone(StorageBase): logger.error(f"【rclone】重命名文件失败:{err}") return False - def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: + def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]: """ 带实时进度显示的下载 """ @@ -426,8 +427,8 @@ class Rclone(StorageBase): local_path.unlink() return None - def upload(self, fileitem: schemas.FileItem, path: Path, - new_name: Optional[str] = None) -> Optional[schemas.FileItem]: + def upload(self, fileitem: _SchemaFileItem, path: Path, + new_name: Optional[str] = None) -> Optional[_SchemaFileItem]: """ 带实时进度显示的上传 :param fileitem: 上传目录项 @@ -483,7 +484,7 @@ class Rclone(StorageBase): logger.error(f"【rclone】上传失败: {target_name} - {err}") return None - def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ 获取文件详情 """ @@ -503,7 +504,7 @@ class Rclone(StorageBase): logger.error(f"【rclone】获取文件详情失败:{err}") return None - def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 移动文件 :param fileitem: 文件项 @@ -558,7 +559,7 @@ class Rclone(StorageBase): logger.error(f"【rclone】移动失败: {fileitem.name} - {err}") return False - def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 复制文件 :param fileitem: 文件项 @@ -613,13 +614,13 @@ class Rclone(StorageBase): logger.error(f"【rclone】复制失败: {fileitem.name} - {err}") return False - def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: pass - def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: pass - def usage(self) -> Optional[schemas.StorageUsage]: + def usage(self) -> Optional[_SchemaStorageUsage]: """ 存储使用情况 """ @@ -647,7 +648,7 @@ class Rclone(StorageBase): ) if ret.returncode == 0: items = json.loads(ret.stdout) - return schemas.StorageUsage( + return _SchemaStorageUsage( total=items.get("total"), available=items.get("free") ) diff --git a/app/modules/filemanager/storages/smb.py b/app/modules/filemanager/storages/smb.py index 510d3655c..a33e6b917 100644 --- a/app/modules/filemanager/storages/smb.py +++ b/app/modules/filemanager/storages/smb.py @@ -12,7 +12,8 @@ from smbprotocol.exceptions import ( SMBAuthenticationError, ) -from app import schemas +from app.schemas.file import StorageUsage as _SchemaStorageUsage +from app.schemas.workflow import FileItem as _SchemaFileItem from app.runtime.config import settings, global_vars from app.runtime.log import logger from app.modules.filemanager import StorageBase @@ -172,7 +173,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): def _create_fileitem( self, stat_result, file_path: str, name: str - ) -> schemas.FileItem: + ) -> _SchemaFileItem: """ 创建文件项 """ @@ -195,7 +196,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): modify_time = int(time.time()) if is_directory: - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type="dir", path=relative_path, @@ -204,7 +205,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): modify_time=modify_time, ) else: - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type="file", path=relative_path, @@ -217,7 +218,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): except Exception as e: logger.error(f"【SMB】创建文件项失败:{e}") # 返回基本的文件项信息 - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type="file", path=file_path.replace(self._server_path, "").replace("\\", "/"), @@ -249,7 +250,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): self._connected = False return False - def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: + def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]: """ 浏览文件 """ @@ -295,8 +296,8 @@ class SMB(StorageBase, metaclass=WeakSingleton): return [] def create_folder( - self, fileitem: schemas.FileItem, name: str - ) -> Optional[schemas.FileItem]: + self, fileitem: _SchemaFileItem, name: str + ) -> Optional[_SchemaFileItem]: """ 创建目录 """ @@ -310,7 +311,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): smbclient.mkdir(new_path) # 返回创建的目录信息 - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type="dir", path=f"{fileitem.path.rstrip('/')}/{name}/", @@ -322,7 +323,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): logger.error(f"【SMB】创建目录失败: {e}") return None - def get_folder(self, path: Path) -> Optional[schemas.FileItem]: + def get_folder(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取目录,如目录不存在则创建 """ @@ -349,7 +350,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): return folder - def get_item(self, path: Path) -> Optional[schemas.FileItem]: + def get_item(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取文件或目录,不存在返回None """ @@ -358,7 +359,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): # 处理根目录 if str(path) == "/": - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type="dir", path="/", @@ -382,7 +383,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): logger.debug(f"【SMB】获取文件项失败: {e}") return None - def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]: + def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。 只有 ENOENT/ENOTDIR 才是「确认不存在」,连接中断、认证失败等都无法确认 @@ -393,7 +394,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): # 处理根目录 if str(path) == "/": - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, type="dir", path="/", @@ -415,13 +416,13 @@ class SMB(StorageBase, metaclass=WeakSingleton): except Exception as e: raise StorageQueryError(f"【SMB】查询文件项失败: {path} - {e}") from e - def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ 获取文件详情 """ return self.get_item(Path(fileitem.path)) - def delete(self, fileitem: schemas.FileItem) -> bool: + def delete(self, fileitem: _SchemaFileItem) -> bool: """ 删除文件或目录 """ @@ -523,7 +524,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): logger.error(f"【SMB】递归删除失败: {smb_path} - {e}") raise SMBConnectionError(f"递归删除失败 {smb_path}: {e}") - def rename(self, fileitem: schemas.FileItem, name: str) -> bool: + def rename(self, fileitem: _SchemaFileItem, name: str) -> bool: """ 重命名文件 """ @@ -543,7 +544,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): logger.error(f"【SMB】重命名失败: {e}") return False - def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: + def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]: """ 带实时进度显示的下载 """ @@ -595,8 +596,8 @@ class SMB(StorageBase, metaclass=WeakSingleton): return None def upload( - self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None - ) -> Optional[schemas.FileItem]: + self, fileitem: _SchemaFileItem, path: Path, new_name: Optional[str] = None + ) -> Optional[_SchemaFileItem]: """ 带实时进度显示的上传 """ @@ -643,7 +644,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): logger.error(f"【SMB】上传失败: {target_name} - {e}") return None - def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 复制文件 """ @@ -670,7 +671,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): logger.error(f"【SMB】复制失败: {e}") return False - def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 移动文件 """ @@ -689,7 +690,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): logger.error(f"【SMB】移动失败: {e}") return False - def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: """ 硬链接文件 Samba服务器需要开启 unix extensions 支持 @@ -722,17 +723,17 @@ class SMB(StorageBase, metaclass=WeakSingleton): logger.error(f"【SMB】创建硬链接失败: {e}") return False - def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: pass - def usage(self) -> Optional[schemas.StorageUsage]: + def usage(self) -> Optional[_SchemaStorageUsage]: """ 存储使用情况 """ try: self._check_connection() volume_stat = smbclient.stat_volume(self._server_path) - return schemas.StorageUsage( + return _SchemaStorageUsage( total=volume_stat.total_size, available=volume_stat.caller_available_size, ) diff --git a/app/modules/filemanager/storages/u115.py b/app/modules/filemanager/storages/u115.py index f057d29ec..ea8003411 100644 --- a/app/modules/filemanager/storages/u115.py +++ b/app/modules/filemanager/storages/u115.py @@ -12,7 +12,8 @@ from oss2 import SizedFileAdapter, determine_part_size from oss2.models import PartInfo from cryptography.hazmat.primitives import hashes -from app import schemas +from app.schemas.file import StorageUsage as _SchemaStorageUsage +from app.schemas.workflow import FileItem as _SchemaFileItem from app.runtime.config import settings, global_vars from app.runtime.log import logger from app.modules.filemanager import StorageBase @@ -474,7 +475,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): def init_storage(self): pass - def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: + def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]: """ 目录遍历实现 """ @@ -520,7 +521,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): item_name = item["fn"] full_path = parent_path / item_name items.append( - schemas.FileItem( + _SchemaFileItem( storage=self.schema.value, fileid=str(item["fid"]), parent_fileid=cid, @@ -542,8 +543,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): return items def create_folder( - self, parent_item: schemas.FileItem, name: str - ) -> Optional[schemas.FileItem]: + self, parent_item: _SchemaFileItem, name: str + ) -> Optional[_SchemaFileItem]: """ 创建目录 """ @@ -564,7 +565,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): return self.get_item(new_path) logger.warn(f"【115】创建目录失败: {resp.get('error')}") return None - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, fileid=str(resp["data"]["file_id"]), path=new_path.as_posix() + "/", @@ -576,10 +577,10 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): def upload( self, - target_dir: schemas.FileItem, + target_dir: _SchemaFileItem, local_path: Path, new_name: Optional[str] = None, - ) -> Optional[schemas.FileItem]: + ) -> Optional[_SchemaFileItem]: """ 实现带秒传、断点续传和二次认证的文件上传 """ @@ -678,7 +679,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): params={"file_id": int(file_id)}, ) if info_resp: - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, fileid=str(info_resp["file_id"]), path=target_path.as_posix() @@ -872,11 +873,11 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): def __build_uploaded_fileitem( self, target_path: Path, local_path: Path, file_size: int - ) -> schemas.FileItem: + ) -> _SchemaFileItem: """ 构造已上传文件项,用于兼容 115 上传成功后目录索引延迟刷新。 """ - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, path=target_path.as_posix(), type="file", @@ -887,7 +888,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): modify_time=local_path.stat().st_mtime if local_path.exists() else None, ) - def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: + def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]: """ 带实时进度显示的下载 """ @@ -957,7 +958,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): def check(self) -> bool: return self.access_token is not None - def delete(self, fileitem: schemas.FileItem) -> bool: + def delete(self, fileitem: _SchemaFileItem) -> bool: """ 删除文件/目录 """ @@ -969,7 +970,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): except httpx.HTTPError: return False - def rename(self, fileitem: schemas.FileItem, name: str) -> bool: + def rename(self, fileitem: _SchemaFileItem, name: str) -> bool: """ 重命名文件/目录 """ @@ -984,7 +985,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): return True return False - def __get_info_item(self, path: Path) -> Optional[schemas.FileItem]: + def __get_info_item(self, path: Path) -> Optional[_SchemaFileItem]: """ 查询指定路径的文件/目录项,无法确认状态时抛出 StorageQueryError。 接口业务码 20004(记录不存在)、430004(路径不存在)与 0 一样 @@ -1004,7 +1005,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): if not data or not data.get("file_id"): # 115 对记录不存在和路径不存在返回不同业务码,两者都可确认目标不存在 return None - return schemas.FileItem( + return _SchemaFileItem( storage=self.schema.value, fileid=str(data["file_id"]), path=path.as_posix() + ("/" if data["file_category"] == "0" else ""), @@ -1019,7 +1020,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): modify_time=data["utime"], ) - def get_item(self, path: Path) -> Optional[schemas.FileItem]: + def get_item(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取指定路径的文件/目录项 """ @@ -1029,7 +1030,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): logger.debug(f"【115】获取文件信息失败: {str(e)}") return None - def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]: + def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取指定路径的文件/目录项,无法确认状态时抛出 StorageQueryError。 """ @@ -1040,14 +1041,14 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): except Exception as e: raise StorageQueryError(f"【115】查询文件信息失败: {path} - {e}") from e - def get_folder(self, path: Path) -> Optional[schemas.FileItem]: + def get_folder(self, path: Path) -> Optional[_SchemaFileItem]: """ 获取指定路径的文件夹,如不存在则创建 """ def __find_dir( - _fileitem: schemas.FileItem, _name: str - ) -> Optional[schemas.FileItem]: + _fileitem: _SchemaFileItem, _name: str + ) -> Optional[_SchemaFileItem]: """ 查找下级目录中匹配名称的目录 """ @@ -1063,7 +1064,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): if folder: return folder # 逐级查找和创建目录 - fileitem = schemas.FileItem(storage=self.schema.value, path="/") + fileitem = _SchemaFileItem(storage=self.schema.value, path="/") for part in path.parts[1:]: dir_file = __find_dir(fileitem, part) if dir_file: @@ -1076,13 +1077,13 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): fileitem = dir_file return fileitem - def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ 获取文件/目录详细信息 """ return self.get_item(Path(fileitem.path)) - def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 复制 """ @@ -1115,7 +1116,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): return True return False - def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ 移动 """ @@ -1147,13 +1148,13 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): return True return False - def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: pass - def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: pass - def usage(self) -> Optional[schemas.StorageUsage]: + def usage(self) -> Optional[_SchemaStorageUsage]: """ 存储使用情况 """ @@ -1162,7 +1163,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): if not resp: return None space = resp["rt_space_info"] - return schemas.StorageUsage( + return _SchemaStorageUsage( total=space["all_total"]["size"], available=space["all_remain"]["size"] ) except NoCheckInException: diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index 0d15cac49..57042ddfd 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -15,16 +15,14 @@ from app.application.directory import DirectoryHelper from app.application.messaging.message import TemplateHelper from app.runtime.log import logger from app.modules.filemanager.storages import StorageBase -from app.schemas import ( - TransferInfo, - TmdbEpisode, - TransferDirectoryConf, - FileItem, - TransferInterceptEventData, - TransferOverwriteCheckEventData, - TransferRenameBuildEventData, - TransferRenameEventData, -) +from app.schemas.transfer import TransferInfo +from app.schemas.tmdb import TmdbEpisode +from app.schemas.system import TransferDirectoryConf +from app.schemas.workflow import FileItem +from app.schemas.event import TransferInterceptEventData +from app.schemas.event import TransferOverwriteCheckEventData +from app.schemas.event import TransferRenameBuildEventData +from app.schemas.event import TransferRenameEventData from app.schemas.exception import StorageQueryError from app.schemas.types import MediaType, ChainEventType from app.adapters.system.host import SystemUtils diff --git a/app/modules/indexer/__init__.py b/app/modules/indexer/__init__.py index dde291352..c8ed6d76e 100644 --- a/app/modules/indexer/__init__.py +++ b/app/modules/indexer/__init__.py @@ -19,7 +19,7 @@ from app.modules.indexer.spider.torrentleech import TorrentLeech from app.schemas.types import MediaSource from app.schemas.media import resolve_media_identity from app.modules.indexer.spider.yema import YemaSpider -from app.schemas import SiteUserData +from app.schemas.site import SiteUserData from app.schemas.types import MediaType, ModuleType, OtherModulesType from app.domain import site as site_rules from app.foundation import text as text_tools diff --git a/app/modules/indexer/spider/haidan.py b/app/modules/indexer/spider/haidan.py index f7ff1cd96..6f1bbac54 100644 --- a/app/modules/indexer/spider/haidan.py +++ b/app/modules/indexer/spider/haidan.py @@ -4,7 +4,7 @@ from typing import Tuple, List from app.runtime.config import settings from app.db.oper.systemconfig import SystemConfigOper from app.runtime.log import logger -from app.schemas import MediaType +from app.schemas.types import MediaType from app.adapters.network.http import RequestUtils, AsyncRequestUtils from app.domain import site as site_rules from app.foundation import temporal as time_tools diff --git a/app/modules/indexer/spider/hddolby.py b/app/modules/indexer/spider/hddolby.py index 5a9dfd2d7..9ef06a523 100644 --- a/app/modules/indexer/spider/hddolby.py +++ b/app/modules/indexer/spider/hddolby.py @@ -3,7 +3,7 @@ from typing import Tuple, List, Optional from app.runtime.config import settings from app.db.oper.systemconfig import SystemConfigOper from app.runtime.log import logger -from app.schemas import MediaType +from app.schemas.types import MediaType from app.adapters.network.http import RequestUtils, AsyncRequestUtils from app.domain import site as site_rules diff --git a/app/modules/indexer/spider/mtorrent.py b/app/modules/indexer/spider/mtorrent.py index 4447eaa8b..f19c2616d 100644 --- a/app/modules/indexer/spider/mtorrent.py +++ b/app/modules/indexer/spider/mtorrent.py @@ -7,7 +7,7 @@ from urllib.parse import urlparse from app.runtime.config import settings from app.db.oper.systemconfig import SystemConfigOper from app.runtime.log import logger -from app.schemas import MediaType +from app.schemas.types import MediaType from app.adapters.network.http import RequestUtils, AsyncRequestUtils from app.domain import site as site_rules from app.foundation import temporal as time_tools diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py index 7204c2636..597eafad3 100644 --- a/app/modules/indexer/spider/rousi.py +++ b/app/modules/indexer/spider/rousi.py @@ -5,7 +5,7 @@ from typing import List, Optional, Tuple from app.runtime.config import settings from app.db.oper.systemconfig import SystemConfigOper from app.runtime.log import logger -from app.schemas import MediaType +from app.schemas.types import MediaType from app.adapters.network.http import RequestUtils, AsyncRequestUtils from app.domain import site as site_rules from app.foundation import temporal as time_tools diff --git a/app/modules/indexer/spider/sunnypt.py b/app/modules/indexer/spider/sunnypt.py index 3c1d750d7..b8aaac039 100644 --- a/app/modules/indexer/spider/sunnypt.py +++ b/app/modules/indexer/spider/sunnypt.py @@ -5,7 +5,7 @@ from typing import List, Optional, Tuple from app.runtime.config import settings from app.runtime.log import logger -from app.schemas import MediaType +from app.schemas.types import MediaType from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.foundation import temporal as time_tools diff --git a/app/modules/indexer/spider/torrentleech.py b/app/modules/indexer/spider/torrentleech.py index 142daf80b..b50d8f1e2 100644 --- a/app/modules/indexer/spider/torrentleech.py +++ b/app/modules/indexer/spider/torrentleech.py @@ -3,7 +3,7 @@ from urllib.parse import quote from app.runtime.config import settings from app.runtime.log import logger -from app.schemas import MediaType +from app.schemas.types import MediaType from app.adapters.network.http import RequestUtils, AsyncRequestUtils from app.foundation import temporal as time_tools from app.foundation import text as text_tools diff --git a/app/modules/indexer/spider/yema.py b/app/modules/indexer/spider/yema.py index 2cc360668..60a013cb7 100644 --- a/app/modules/indexer/spider/yema.py +++ b/app/modules/indexer/spider/yema.py @@ -4,7 +4,7 @@ from typing import List, Optional, Tuple from app.runtime.config import settings from app.runtime.log import logger -from app.schemas import MediaType +from app.schemas.types import MediaType from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.foundation import temporal as time_tools diff --git a/app/modules/jellyfin/__init__.py b/app/modules/jellyfin/__init__.py index 517a88edf..15a74c51a 100644 --- a/app/modules/jellyfin/__init__.py +++ b/app/modules/jellyfin/__init__.py @@ -1,6 +1,11 @@ from typing import Any, Dict, Generator, List, Optional, Tuple, Union -from app import schemas +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.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.runtime.log import logger from app.modules._base import _MediaServerModuleBase from app.modules.jellyfin.jellyfin import Jellyfin @@ -50,7 +55,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): def stop(self): pass - def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]: + def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[_SchemaWebhookEventInfo]: """ 解析Webhook报文体 :param body: 请求体 @@ -75,7 +80,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): return result return None - def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: + def media_statistic(self, server: Optional[str] = None) -> Optional[List[_SchemaStatistic]]: """ 媒体数量统计 """ @@ -97,7 +102,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): def mediaserver_librarys(self, server: Optional[str] = None, username: Optional[str] = None, - hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]: + hidden: Optional[bool] = False) -> Optional[List[_SchemaMediaServerLibrary]]: """ 媒体库列表 """ @@ -136,7 +141,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): return server_obj.get_items_count(library_id) return None - def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]: + def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[_SchemaMediaServerItem]: """ 媒体库项目详情 """ @@ -146,7 +151,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): return None def mediaserver_tv_episodes(self, server: str, - item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]: + item_id: Union[str, int]) -> Optional[List[_SchemaMediaServerSeasonInfo]]: """ 获取剧集信息 """ @@ -156,14 +161,14 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id) if not seasoninfo: return [] - return [schemas.MediaServerSeasonInfo( + return [_SchemaMediaServerSeasonInfo( season=season, episodes=episodes ) for season, episodes in seasoninfo.items()] def mediaserver_playing(self, server: str, count: Optional[int] = 20, - username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: + username: Optional[str] = None) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ @@ -197,7 +202,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): return server_obj.get_season_episode_ids(str(item_id), season) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, - username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: + username: Optional[str] = None) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ diff --git a/app/modules/jellyfin/jellyfin.py b/app/modules/jellyfin/jellyfin.py index 21ebf78d4..3ef28ee5f 100644 --- a/app/modules/jellyfin/jellyfin.py +++ b/app/modules/jellyfin/jellyfin.py @@ -5,15 +5,20 @@ from typing import List, Union, Optional, Dict, Generator, Tuple, Any from requests import Response -from app import schemas +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 WebhookEventInfo as _SchemaWebhookEventInfo from app.runtime.config import settings from app.application.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper from app.runtime.log import logger -from app.schemas import MediaType +from app.schemas.types import MediaType from app.schemas.types import MediaSource from app.adapters.network.http import RequestUtils from app.foundation.url import UrlUtils -from app.schemas import MediaServerItem +from app.schemas.mediaserver import MediaServerItem class Jellyfin: @@ -160,7 +165,7 @@ class Jellyfin: self, username: Optional[str] = None, hidden: Optional[bool] = False, - ) -> Optional[List[schemas.MediaServerLibrary]]: + ) -> Optional[List[_SchemaMediaServerLibrary]]: """ 获取媒体服务器所有媒体库列表 """ @@ -192,7 +197,7 @@ class Jellyfin: f"/library.html?topParentId={library.get('Id')}" image = self.__get_local_image_by_id(library.get("Id")) libraries.append( - schemas.MediaServerLibrary( + _SchemaMediaServerLibrary( server="jellyfin", id=library.get("Id"), name=library.get("Name"), @@ -342,7 +347,7 @@ class Jellyfin: logger.error(f"连接System/Info出错:" + str(e)) return None - def get_medias_count(self) -> schemas.Statistic: + def get_medias_count(self) -> _SchemaStatistic: """ 获得电影、电视剧、动漫媒体数量 @@ -353,7 +358,7 @@ class Jellyfin: :return: MovieCount SeriesCount EpisodeCount """ if not self._host or not self._apikey: - return schemas.Statistic() + return _SchemaStatistic() stat = self.__count_medias_by_librarys() if stat is not None: return stat @@ -365,7 +370,7 @@ class Jellyfin: res = self._request().get_res(url, params) if res: result = res.json() - return schemas.Statistic( + return _SchemaStatistic( movie_count=result.get("MovieCount") or 0, tv_count=result.get("SeriesCount") or 0, episode_count=result.get("EpisodeCount") or 0, @@ -374,12 +379,12 @@ class Jellyfin: ) else: logger.error(f"Items/Counts 未获取到返回数据") - return schemas.Statistic() + return _SchemaStatistic() except Exception as e: logger.error(f"连接Items/Counts出错:" + str(e)) - return schemas.Statistic() + return _SchemaStatistic() - def __count_medias_by_librarys(self) -> Optional[schemas.Statistic]: + def __count_medias_by_librarys(self) -> Optional[_SchemaStatistic]: """ 遍历用户媒体库视图逐库统计媒体数量 @@ -392,7 +397,7 @@ class Jellyfin: librarys = self.__get_jellyfin_librarys() if not librarys: return None - stat = schemas.Statistic() + stat = _SchemaStatistic() for library in librarys: library_id = library.get("Id") if not library_id: @@ -439,7 +444,7 @@ class Jellyfin: title: str, year: Optional[str] = None, media_source: Optional[MediaSource] = None, - media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]: + media_id: Optional[str] = None) -> Optional[List[_SchemaMediaServerItem]]: """ 根据标题和年份,检查电影是否在Jellyfin中存在,存在则返回列表 :param title: 标题 @@ -486,7 +491,7 @@ class Jellyfin: def get_music( self, title: Optional[str] = None, artist: Optional[str] = None, album: Optional[str] = None, - ) -> List[schemas.MediaServerItem]: + ) -> List[_SchemaMediaServerItem]: """按歌曲、艺术家或专辑名称查询 Jellyfin 音乐条目。""" if not self._host or not self._apikey or not self.user: return [] @@ -744,7 +749,7 @@ class Jellyfin: logger.error(f"连接Library/Refresh出错:" + str(e)) return False - def get_webhook_message(self, body: Any) -> Optional[schemas.WebhookEventInfo]: + def get_webhook_message(self, body: Any) -> Optional[_SchemaWebhookEventInfo]: """ 解析Jellyfin报文 { @@ -820,7 +825,7 @@ class Jellyfin: eventType = message.get('NotificationType') if not eventType: return None - eventItem = schemas.WebhookEventInfo( + eventItem = _SchemaWebhookEventInfo( event=eventType, channel="jellyfin" ) @@ -881,7 +886,7 @@ class Jellyfin: return eventItem @staticmethod - def __format_item_info(item) -> Optional[schemas.MediaServerItem]: + def __format_item_info(item) -> Optional[_SchemaMediaServerItem]: """ 格式化item """ @@ -895,7 +900,7 @@ class Jellyfin: last_played_date = item.get("UserData", {}).get("LastPlayedDate") if last_played_date is not None and "." in last_played_date: last_played_date = last_played_date.split(".")[0] - user_state = schemas.MediaServerItemUserState( + user_state = _SchemaMediaServerItemUserState( played=item.get("UserData", {}).get("Played"), resume=resume, last_played_date=datetime.strptime(last_played_date, "%Y-%m-%dT%H:%M:%S").strftime( @@ -906,7 +911,7 @@ class Jellyfin: media_source, media_id = MediaServerIdentityHelper.from_provider_ids( item.get("ProviderIds") ) - return schemas.MediaServerItem( + return _SchemaMediaServerItem( server="jellyfin", library=item.get("ParentId"), item_id=item.get("Id"), @@ -926,7 +931,7 @@ class Jellyfin: logger.error(e) return None - def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]: + def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]: """ 获取单个项目详情 """ @@ -1090,7 +1095,7 @@ class Jellyfin: return f"{host_url}Items/{item_id}/" \ f"Images/Backdrop?tag={image_tag}&api_key={self._apikey}" - def get_resume(self, num: Optional[int] = 12, username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: + def get_resume(self, num: Optional[int] = 12, username: Optional[str] = None) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获得继续观看 """ @@ -1143,7 +1148,7 @@ class Jellyfin: else: title = f'{item.get("SeriesName")}' subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}' - ret_resume.append(schemas.MediaServerPlayItem( + ret_resume.append(_SchemaMediaServerPlayItem( id=item.get("Id"), title=title, subtitle=subtitle, @@ -1160,7 +1165,7 @@ class Jellyfin: logger.error(f"连接Users/Items/Resume出错:" + str(e)) return None - def get_latest(self, num=20, username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: + def get_latest(self, num=20, username: Optional[str] = None) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获得最近更新 """ @@ -1198,7 +1203,7 @@ class Jellyfin: item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value link = self.get_play_url(item.get("Id")) image = self.__get_local_image_by_id(item_id=item.get("Id")) - ret_latest.append(schemas.MediaServerPlayItem( + ret_latest.append(_SchemaMediaServerPlayItem( id=item.get("Id"), title=item.get("Name"), subtitle=str(item.get("ProductionYear")) if item.get("ProductionYear") else None, diff --git a/app/modules/navidrome/__init__.py b/app/modules/navidrome/__init__.py index 5ec9527f8..4298d5053 100644 --- a/app/modules/navidrome/__init__.py +++ b/app/modules/navidrome/__init__.py @@ -2,14 +2,20 @@ from typing import Any, Dict, Generator, List, Optional, Tuple, Union -from app import schemas +from app.schemas.dashboard import Statistic as _SchemaStatistic +from app.schemas.mediaserver import ExistMediaInfo as _SchemaExistMediaInfo +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.domain.context import MediaInfo from app.runtime.events import eventmanager from app.application.mediaserver import MusicMediaServerHelper from app.runtime.log import logger from app.modules import _MediaServerBase, _ModuleBase from app.modules.navidrome.navidrome import Navidrome -from app.schemas import AuthCredentials, AuthInterceptCredentials +from app.schemas.event import AuthCredentials +from app.schemas.event import AuthInterceptCredentials from app.schemas.types import ChainEventType, MediaServerType, MediaType, ModuleType @@ -103,7 +109,7 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): def media_exists( self, mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None - ) -> Optional[schemas.ExistMediaInfo]: + ) -> Optional[_SchemaExistMediaInfo]: """判断音乐是否已存在于 Navidrome 音乐库。""" if mediainfo.type != MediaType.MUSIC: return None @@ -117,7 +123,7 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): continue item = service.get_iteminfo(str(itemid)) if itemid else None if item and MusicMediaServerHelper.item_matches(mediainfo, item): - return schemas.ExistMediaInfo( + return _SchemaExistMediaInfo( type=MediaType.MUSIC, server_type="navidrome", server=name, @@ -126,7 +132,7 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): matches = service.search_music(**MusicMediaServerHelper.search_params(mediainfo)) match = MusicMediaServerHelper.find_match(mediainfo, matches) if match: - return schemas.ExistMediaInfo( + return _SchemaExistMediaInfo( type=MediaType.MUSIC, server_type="navidrome", server=name, @@ -134,10 +140,10 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): ) return None - def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: + def media_statistic(self, server: Optional[str] = None) -> Optional[List[_SchemaStatistic]]: """返回 Navidrome 音乐数量统计。""" servers = [self.get_instance(server)] if server else list(self.get_instances().values()) - result: List[schemas.Statistic] = [] + result: List[_SchemaStatistic] = [] for service in servers: if not service: continue @@ -148,7 +154,7 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): def mediaserver_librarys( self, server: str, username: Optional[str] = None, hidden: Optional[bool] = False - ) -> Optional[List[schemas.MediaServerLibrary]]: + ) -> Optional[List[_SchemaMediaServerLibrary]]: """返回 Navidrome 的虚拟音乐库。""" service = self.get_instance(server) return service.get_librarys(hidden=hidden) if service else None @@ -166,18 +172,18 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): service = self.get_instance(server) return service.get_items_count(str(library_id)) if service else None - def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]: + def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[_SchemaMediaServerItem]: """获取 Navidrome 专辑详情。""" service = self.get_instance(server) return service.get_iteminfo(item_id) if service else None - def mediaserver_tv_episodes(self, server: str, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]: + def mediaserver_tv_episodes(self, server: str, item_id: Union[str, int]) -> List[_SchemaMediaServerSeasonInfo]: """音乐服务器没有剧集信息,返回空列表。""" return [] def mediaserver_playing( self, server: str, count: Optional[int] = 20, username: Optional[str] = None - ) -> Optional[List[schemas.MediaServerPlayItem]]: + ) -> Optional[List[_SchemaMediaServerPlayItem]]: """获取 Navidrome 当前播放条目。""" service = self.get_instance(server) return service.get_resume(count) if service else None @@ -190,7 +196,7 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): def mediaserver_latest( self, server: Optional[str] = None, count: Optional[int] = 20, username: Optional[str] = None, - ) -> Optional[List[schemas.MediaServerPlayItem]]: + ) -> Optional[List[_SchemaMediaServerPlayItem]]: """获取 Navidrome 最近新增专辑。""" service = self.get_instance(server) return service.get_latest(count) if service else None diff --git a/app/modules/navidrome/navidrome.py b/app/modules/navidrome/navidrome.py index 874612028..48e847075 100644 --- a/app/modules/navidrome/navidrome.py +++ b/app/modules/navidrome/navidrome.py @@ -6,7 +6,10 @@ import secrets from typing import Any, Dict, Generator, List, Optional from urllib.parse import urlencode -from app import schemas +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.runtime.log import logger from app.schemas.types import MediaType from app.adapters.network.http import RequestUtils @@ -108,10 +111,10 @@ class Navidrome: """从 Subsonic 专辑对象提取稳定标题。""" return album.get("name") or album.get("album") or "" - def _album_to_item(self, album: dict) -> schemas.MediaServerItem: + def _album_to_item(self, album: dict) -> _SchemaMediaServerItem: """将 Subsonic 专辑转换为统一媒体条目。""" album_id = str(album.get("id") or "") - return schemas.MediaServerItem( + return _SchemaMediaServerItem( id=album_id, item_id=album_id, title=self._album_name(album), @@ -123,11 +126,11 @@ class Navidrome: note={"artist": album.get("artist"), "song_count": album.get("songCount")}, ) - def _song_to_item(self, song: dict) -> schemas.MediaServerItem: + def _song_to_item(self, song: dict) -> _SchemaMediaServerItem: """将 Subsonic 单曲转换为统一音乐条目。""" song_id = str(song.get("id") or "") title = song.get("title") or song.get("name") or "" - return schemas.MediaServerItem( + return _SchemaMediaServerItem( id=song_id, item_id=song_id, title=title, @@ -181,16 +184,16 @@ class Navidrome: offset += len(page) return albums - def get_medias_count(self) -> schemas.Statistic: + def get_medias_count(self) -> _SchemaStatistic: """统计 Navidrome 专辑数量并映射为音乐数量。""" - return schemas.Statistic(music_count=len(self._albums())) if self.user else schemas.Statistic() + return _SchemaStatistic(music_count=len(self._albums())) if self.user else _SchemaStatistic() - def get_librarys(self, hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]: + def get_librarys(self, hidden: Optional[bool] = False) -> Optional[List[_SchemaMediaServerLibrary]]: """返回单个虚拟音乐库。""" if not self.user or (hidden and self._sync_libraries and "all" not in self._sync_libraries): return [] count = self.get_items_count("music") - return [schemas.MediaServerLibrary( + return [_SchemaMediaServerLibrary( server="navidrome", id="music", item_id="music", @@ -205,14 +208,14 @@ class Navidrome: """返回虚拟音乐库中的专辑数量。""" return len(self._albums()) if self.user else 0 - def get_items(self, start_index: int = 0, limit: int = -1) -> Generator[schemas.MediaServerItem, None, None]: + def get_items(self, start_index: int = 0, limit: int = -1) -> Generator[_SchemaMediaServerItem, None, None]: """逐条返回 Navidrome 专辑。""" albums = self._albums() end = None if limit is None or limit == -1 else start_index + limit for album in albums[start_index:end]: yield self._album_to_item(album) - def get_iteminfo(self, item_id: str) -> Optional[schemas.MediaServerItem]: + def get_iteminfo(self, item_id: str) -> Optional[_SchemaMediaServerItem]: """获取 Navidrome 专辑详情。""" payload = self._call("getAlbum", id=item_id) album = ((payload or {}).get("album") or {}) @@ -221,7 +224,7 @@ class Navidrome: def search_music( self, title: Optional[str] = None, artist: Optional[str] = None, album: Optional[str] = None, - ) -> List[schemas.MediaServerItem]: + ) -> List[_SchemaMediaServerItem]: """按歌曲或专辑名称精确筛选音乐条目,避免模糊搜索误报已入库。""" target = album or title query = " ".join(dict.fromkeys(filter(None, [target, artist]))).strip() @@ -245,10 +248,10 @@ class Navidrome: and self._same_artist(item, artist) ] - def _to_play_item(self, album: dict) -> schemas.MediaServerPlayItem: + def _to_play_item(self, album: dict) -> _SchemaMediaServerPlayItem: """将专辑转换为仪表盘播放/最新条目。""" album_id = str(album.get("id") or "") - return schemas.MediaServerPlayItem( + return _SchemaMediaServerPlayItem( id=album_id, item_id=album_id, title=self._album_name(album), @@ -259,10 +262,10 @@ class Navidrome: server_type="navidrome", ) - def _song_to_play_item(self, song: dict) -> schemas.MediaServerPlayItem: + def _song_to_play_item(self, song: dict) -> _SchemaMediaServerPlayItem: """将正在播放的单曲转换为仪表盘条目,避免把所属专辑名误作曲名。""" song_id = str(song.get("id") or "") - return schemas.MediaServerPlayItem( + return _SchemaMediaServerPlayItem( id=song_id, item_id=song_id, title=song.get("title") or song.get("name") or "", @@ -273,11 +276,11 @@ class Navidrome: server_type="navidrome", ) - def get_latest(self, count: int = 20) -> List[schemas.MediaServerPlayItem]: + def get_latest(self, count: int = 20) -> List[_SchemaMediaServerPlayItem]: """返回最近新增专辑。""" return [self._to_play_item(album) for album in self._albums("newest")[:count]] - def get_resume(self, count: int = 20) -> List[schemas.MediaServerPlayItem]: + def get_resume(self, count: int = 20) -> List[_SchemaMediaServerPlayItem]: """返回当前用户正在播放的音乐。""" payload = self._call("getNowPlaying") items = ((payload or {}).get("nowPlaying") or {}).get("entry") or [] diff --git a/app/modules/plex/__init__.py b/app/modules/plex/__init__.py index 531879b69..2efd097dd 100644 --- a/app/modules/plex/__init__.py +++ b/app/modules/plex/__init__.py @@ -1,13 +1,20 @@ from typing import Optional, Tuple, Union, Any, List, Generator, Dict -from app import schemas +from app.schemas.dashboard import Statistic as _SchemaStatistic +from app.schemas.mediaserver import ExistMediaInfo as _SchemaExistMediaInfo +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.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.domain.context import MediaInfo from app.runtime.events import eventmanager from app.application.mediaserver import MusicMediaServerHelper from app.runtime.log import logger from app.modules._base import _MediaServerModuleBase from app.modules.plex.plex import Plex -from app.schemas import AuthCredentials, AuthInterceptCredentials +from app.schemas.event import AuthCredentials +from app.schemas.event import AuthInterceptCredentials from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType @@ -109,7 +116,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): return credentials return None - def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]: + def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[_SchemaWebhookEventInfo]: """ 解析Webhook报文体 :param body: 请求体 @@ -135,7 +142,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): return None def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None, - server: Optional[str] = None) -> Optional[schemas.ExistMediaInfo]: + server: Optional[str] = None) -> Optional[_SchemaExistMediaInfo]: """ 判断媒体文件是否存在 :param mediainfo: 识别的媒体信息 @@ -154,7 +161,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): matches = s.get_music(**MusicMediaServerHelper.search_params(mediainfo)) match = MusicMediaServerHelper.find_match(mediainfo, matches) if match: - return schemas.ExistMediaInfo( + return _SchemaExistMediaInfo( type=MediaType.MUSIC, server_type="plex", server=name, @@ -166,7 +173,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): movie = s.get_iteminfo(itemid) if movie: logger.info(f"媒体库 {name} 中找到了 {movie}") - return schemas.ExistMediaInfo( + return _SchemaExistMediaInfo( type=MediaType.MOVIE, server_type="plex", server=name, @@ -182,7 +189,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): continue else: logger.info(f"媒体库 {name} 中找到了 {movies}") - return schemas.ExistMediaInfo( + return _SchemaExistMediaInfo( type=MediaType.MOVIE, server_type="plex", server=name, @@ -200,7 +207,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): continue else: logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集:{tvs}") - return schemas.ExistMediaInfo( + return _SchemaExistMediaInfo( type=MediaType.TV, seasons=tvs, server_type="plex", @@ -209,7 +216,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): ) return None - def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: + def media_statistic(self, server: Optional[str] = None) -> Optional[List[_SchemaStatistic]]: """ 媒体数量统计 """ @@ -230,7 +237,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): return media_statistics def mediaserver_librarys(self, server: Optional[str] = None, hidden: Optional[bool] = False, - **kwargs) -> Optional[List[schemas.MediaServerLibrary]]: + **kwargs) -> Optional[List[_SchemaMediaServerLibrary]]: """ 媒体库列表 """ @@ -269,7 +276,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): return server_obj.get_items_count(library_id) return None - def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]: + def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[_SchemaMediaServerItem]: """ 媒体库项目详情 """ @@ -279,7 +286,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): return None def mediaserver_tv_episodes(self, server: str, - item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]: + item_id: Union[str, int]) -> Optional[List[_SchemaMediaServerSeasonInfo]]: """ 获取剧集信息 """ @@ -289,13 +296,13 @@ class PlexModule(_MediaServerModuleBase[Plex]): _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id) if not seasoninfo: return [] - return [schemas.MediaServerSeasonInfo( + return [_SchemaMediaServerSeasonInfo( season=season, episodes=episodes ) for season, episodes in seasoninfo.items()] def mediaserver_playing(self, server: str, count: Optional[int] = 20, - **kwargs) -> Optional[List[schemas.MediaServerPlayItem]]: + **kwargs) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ @@ -305,7 +312,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): return server_obj.get_resume(num=count) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, - **kwargs) -> Optional[List[schemas.MediaServerPlayItem]]: + **kwargs) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ diff --git a/app/modules/plex/plex.py b/app/modules/plex/plex.py index ee0cd01cf..39b171411 100644 --- a/app/modules/plex/plex.py +++ b/app/modules/plex/plex.py @@ -8,15 +8,20 @@ from plexapi.myplex import MyPlexAccount from plexapi.server import PlexServer from requests import Response, Session -from app import schemas +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.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.runtime.cache import cached from app.application.mediaserver import MediaServerIdentityHelper from app.runtime.log import logger -from app.schemas import MediaType -from app.schemas.types import MediaSource +from app.schemas.types import MediaSource, MediaType from app.adapters.network.http import RequestUtils from app.foundation.url import UrlUtils -from app.schemas import MediaServerItem +from app.schemas.mediaserver import MediaServerItem class Plex: @@ -124,7 +129,7 @@ class Plex: return [f"{self._host.rstrip('/') + url}?X-Plex-Token={self._token}" for url in list(poster_urls.keys())[:total_size]] - def get_librarys(self, hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]: + def get_librarys(self, hidden: Optional[bool] = False) -> Optional[List[_SchemaMediaServerLibrary]]: """ 获取媒体服务器所有媒体库列表 """ @@ -152,7 +157,7 @@ class Plex: else: continue libraries.append( - schemas.MediaServerLibrary( + _SchemaMediaServerLibrary( id=library.key, name=library.title, path=library.locations, @@ -166,13 +171,13 @@ class Plex: ) return libraries - def get_medias_count(self) -> schemas.Statistic: + def get_medias_count(self) -> _SchemaStatistic: """ 获得电影、电视剧、动漫媒体数量 :return: movie_count tv_count episode_count """ if not self._plex: - return schemas.Statistic() + return _SchemaStatistic() sections = self._plex.library.sections() movie_count = tv_count = episode_count = music_count = 0 # 媒体库白名单 @@ -187,7 +192,7 @@ class Plex: episode_count += sec.totalViewSize(libtype="episode") if sec.type in ("artist", "music"): music_count += sec.totalSize - return schemas.Statistic( + return _SchemaStatistic( movie_count=movie_count, tv_count=tv_count, episode_count=episode_count, @@ -199,7 +204,7 @@ class Plex: original_title: Optional[str] = None, year: Optional[str] = None, media_source: Optional[MediaSource] = None, - media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]: + media_id: Optional[str] = None) -> Optional[List[_SchemaMediaServerItem]]: """ 根据标题和年份,检查电影是否在Plex中存在,存在则返回列表 :param title: 标题 @@ -238,7 +243,7 @@ class Plex: if item.locations: path = item.locations[0] ret_movies.append( - schemas.MediaServerItem( + _SchemaMediaServerItem( server="plex", library=item.librarySectionID, item_id=item.key, @@ -256,14 +261,14 @@ class Plex: def get_music( self, title: Optional[str] = None, artist: Optional[str] = None, album: Optional[str] = None, - ) -> List[schemas.MediaServerItem]: + ) -> List[_SchemaMediaServerItem]: """按歌曲、艺术家或专辑名称查询 Plex 音乐条目。""" if not self._plex: return [] query = album or title or artist if not query: return [] - results: List[schemas.MediaServerItem] = [] + results: List[_SchemaMediaServerItem] = [] try: for library in self._plex.library.sections(): if library.type not in ("artist", "music"): @@ -276,7 +281,7 @@ class Plex: else: item_artist = getattr(item, "parentTitle", None) item_album = getattr(item, "title", None) if item_type == "album" else None - results.append(schemas.MediaServerItem( + results.append(_SchemaMediaServerItem( server="plex", library=library.key, item_id=getattr(item, "ratingKey", None) or getattr(item, "key", None), @@ -510,7 +515,7 @@ class Plex: return False return self._plex.library.update() - def refresh_library_by_items(self, items: List[schemas.RefreshMediaItem]) -> Optional[bool]: + def refresh_library_by_items(self, items: List[_SchemaRefreshMediaItem]) -> Optional[bool]: """ 按路径刷新媒体库 item: target_path """ @@ -564,7 +569,7 @@ class Plex: logger.error(f"查找媒体库出错:{str(err)}") return "", None - def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]: + def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]: """ 获取单个项目详情 """ @@ -618,7 +623,7 @@ class Plex: item_id = int(item_id) return self._plex.fetchItem(item_id) - def __build_media_server_item(self, item) -> Optional[schemas.MediaServerItem]: + def __build_media_server_item(self, item) -> Optional[_SchemaMediaServerItem]: """ 构造MediaServerItem :param item: Plex媒体项目 @@ -635,7 +640,7 @@ class Plex: play_count = getattr(item, "viewCount", None) or 0 last_played_date = getattr(item, "lastViewedAt", None) - user_state = schemas.MediaServerItemUserState( + user_state = _SchemaMediaServerItemUserState( played=played, resume=playback_position > 0, last_played_date=last_played_date.isoformat() if last_played_date and hasattr(last_played_date, @@ -644,7 +649,7 @@ class Plex: percentage=percentage, ) - return schemas.MediaServerItem( + return _SchemaMediaServerItem( server="plex", library=item.librarySectionID, item_id=item.key, @@ -706,7 +711,7 @@ class Plex: logger.error(f"获取媒体库列表出错:{str(err)}") return None - def get_webhook_message(self, form: Any) -> Optional[schemas.WebhookEventInfo]: + def get_webhook_message(self, form: Any) -> Optional[_SchemaWebhookEventInfo]: """ 解析Plex报文 eventItem 字段的含义 @@ -824,7 +829,7 @@ class Plex: if not eventType: return None logger.debug(f"接收到plex webhook:{message}") - eventItem = schemas.WebhookEventInfo(event=eventType, channel="plex") + eventItem = _SchemaWebhookEventInfo(event=eventType, channel="plex") if message.get('Metadata'): if message.get('Metadata', {}).get('type') == 'episode': eventItem.item_type = "TV" @@ -884,7 +889,7 @@ class Plex: """ return f'{self._playhost or self._host}web/index.html#!/server/{self._plex.machineIdentifier}/details?key={item_id}&X-Plex-Token={self._token}' - def get_resume(self, num: Optional[int] = 12) -> Optional[List[schemas.MediaServerPlayItem]]: + def get_resume(self, num: Optional[int] = 12) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取继续观看的媒体 """ @@ -912,7 +917,7 @@ class Plex: subtitle = f"S{item.parentIndex}:E{item.index} - {item.title}" link = self.get_play_url(item.key) image = item.artUrl - ret_resume.append(schemas.MediaServerPlayItem( + ret_resume.append(_SchemaMediaServerPlayItem( id=item.key, title=title, subtitle=subtitle, @@ -924,7 +929,7 @@ class Plex: )) return ret_resume[:num] - def get_latest(self, num: Optional[int] = 20) -> Optional[List[schemas.MediaServerPlayItem]]: + def get_latest(self, num: Optional[int] = 20) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取最近添加媒体 """ @@ -985,7 +990,7 @@ class Plex: title = "%s 共%s季" % (item.title, item.seasonCount) image = item.posterUrl link = self.get_play_url(item.key) - ret_resume.append(schemas.MediaServerPlayItem( + ret_resume.append(_SchemaMediaServerPlayItem( id=item.key, title=title, subtitle=str(item.year) if item.year else None, diff --git a/app/modules/qbittorrent/__init__.py b/app/modules/qbittorrent/__init__.py index 178586faf..33d366d75 100644 --- a/app/modules/qbittorrent/__init__.py +++ b/app/modules/qbittorrent/__init__.py @@ -3,13 +3,13 @@ from typing import Set, Tuple, Optional, Union, List, Dict from qbittorrentapi import TorrentFilesList -from app import schemas +from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo from app.runtime.config import settings from app.domain.metainfo import MetaInfo from app.runtime.log import logger from app.modules._base import _DownloaderModuleBase from app.modules.qbittorrent.qbittorrent import Qbittorrent -from app.schemas import DownloaderTorrent +from app.schemas.transfer import DownloaderTorrent from app.schemas.types import ( DownloadTaskState, DownloaderType, @@ -522,7 +522,7 @@ class QbittorrentModule(_DownloaderModuleBase[Qbittorrent]): return None return server.get_files(tid=tid) - def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]: + def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[_SchemaDownloaderInfo]]: """ 下载器信息 """ @@ -539,7 +539,7 @@ class QbittorrentModule(_DownloaderModuleBase[Qbittorrent]): info = server.transfer_info() if not info: continue - ret_info.append(schemas.DownloaderInfo( + ret_info.append(_SchemaDownloaderInfo( download_speed=info.get("dl_info_speed"), upload_speed=info.get("up_info_speed"), download_size=info.get("dl_info_data"), diff --git a/app/modules/qqbot/__init__.py b/app/modules/qqbot/__init__.py index b4cb9e488..899e18c43 100644 --- a/app/modules/qqbot/__init__.py +++ b/app/modules/qqbot/__init__.py @@ -17,7 +17,9 @@ from app.application.messaging.agent import ( from app.runtime.log import logger from app.modules._base import _MessageChannelModuleBase from app.modules.qqbot.qqbot import QQBot -from app.schemas import IncomingMessage, NotificationChannel, Message +from app.schemas.message import IncomingMessage +from app.schemas.notification import NotificationChannel +from app.schemas.message import Message from app.schemas.types import ModuleType from app.adapters.network.http import RequestUtils diff --git a/app/modules/rtorrent/__init__.py b/app/modules/rtorrent/__init__.py index 9d1a655c4..48dc90b06 100644 --- a/app/modules/rtorrent/__init__.py +++ b/app/modules/rtorrent/__init__.py @@ -1,13 +1,13 @@ from pathlib import Path from typing import Set, Tuple, Optional, Union, List, Dict -from app import schemas +from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo from app.runtime.config import settings from app.domain.metainfo import MetaInfo from app.runtime.log import logger from app.modules._base import _DownloaderModuleBase from app.modules.rtorrent.rtorrent import Rtorrent -from app.schemas import DownloaderTorrent +from app.schemas.transfer import DownloaderTorrent from app.schemas.types import ( DownloadTaskState, DownloaderType, @@ -530,7 +530,7 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): def downloader_info( self, downloader: Optional[str] = None - ) -> Optional[List[schemas.DownloaderInfo]]: + ) -> Optional[List[_SchemaDownloaderInfo]]: """ 下载器信息 """ @@ -547,7 +547,7 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): if not info: continue ret_info.append( - schemas.DownloaderInfo( + _SchemaDownloaderInfo( download_speed=info.get("dl_info_speed"), upload_speed=info.get("up_info_speed"), download_size=info.get("dl_info_data"), diff --git a/app/modules/slack/__init__.py b/app/modules/slack/__init__.py index 146702619..a0ce224c7 100644 --- a/app/modules/slack/__init__.py +++ b/app/modules/slack/__init__.py @@ -12,13 +12,11 @@ from app.application.messaging.agent import ( from app.runtime.log import logger from app.modules._base import _MessageChannelModuleBase from app.modules.slack.slack import Slack -from app.schemas import ( - CommandRegisterEventData, - IncomingMessage, - NotificationChannel, - MessageResponse, - Message, -) +from app.schemas.event import CommandRegisterEventData +from app.schemas.message import IncomingMessage +from app.schemas.notification import NotificationChannel +from app.schemas.message import MessageResponse +from app.schemas.message import Message from app.schemas.types import ModuleType diff --git a/app/modules/synologychat/__init__.py b/app/modules/synologychat/__init__.py index 7235088bd..9b5530a88 100644 --- a/app/modules/synologychat/__init__.py +++ b/app/modules/synologychat/__init__.py @@ -11,7 +11,9 @@ from app.application.messaging.agent import ( from app.runtime.log import logger from app.modules._base import _MessageChannelModuleBase from app.modules.synologychat.synologychat import SynologyChat -from app.schemas import NotificationChannel, IncomingMessage, Message +from app.schemas.notification import NotificationChannel +from app.schemas.message import IncomingMessage +from app.schemas.message import Message from app.schemas.types import ModuleType from app.adapters.network.http import RequestUtils diff --git a/app/modules/telegram/__init__.py b/app/modules/telegram/__init__.py index 3437c0c3e..9901793c5 100644 --- a/app/modules/telegram/__init__.py +++ b/app/modules/telegram/__init__.py @@ -11,13 +11,11 @@ from app.application.messaging.agent import ( from app.runtime.log import logger from app.modules._base import _MessageChannelModuleBase from app.modules.telegram.telegram import Telegram -from app.schemas import ( - NotificationChannel, - IncomingMessage, - Message, - NotificationConf, - MessageResponse, -) +from app.schemas.notification import NotificationChannel +from app.schemas.message import IncomingMessage +from app.schemas.message import Message +from app.schemas.system import NotificationConf +from app.schemas.message import MessageResponse from app.schemas.types import ModuleType diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 8e24943c8..224410119 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -3,7 +3,9 @@ from typing import Optional, List, Tuple, Union, Dict import cn2an -from app import schemas +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason +from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode from app.runtime.config import settings from app.domain.context import MediaInfo from app.domain.meta.metabase import MetaBase @@ -880,7 +882,7 @@ class TheMovieDbModule(_ModuleBase): def search_persons( self, name: str, media_source: Optional[MediaSourceSelection] = None - ) -> Optional[List[schemas.MediaPerson]]: + ) -> Optional[List[_SchemaMediaPerson]]: """ 搜索人物信息 :param name: 人物名称 @@ -893,12 +895,12 @@ class TheMovieDbModule(_ModuleBase): return [] results = self.tmdb.search_persons(name) if results: - return [schemas.MediaPerson(source='themoviedb', **person) for person in results] + return [_SchemaMediaPerson(source='themoviedb', **person) for person in results] return [] async def async_search_persons( self, name: str, media_source: Optional[MediaSourceSelection] = None - ) -> Optional[List[schemas.MediaPerson]]: + ) -> Optional[List[_SchemaMediaPerson]]: """ 异步搜索人物信息 :param name: 人物名称 @@ -911,7 +913,7 @@ class TheMovieDbModule(_ModuleBase): return [] results = await self.tmdb.async_search_persons(name) if results: - return [schemas.MediaPerson(source='themoviedb', **person) for person in results] + return [_SchemaMediaPerson(source='themoviedb', **person) for person in results] return [] def search_collections( @@ -1048,7 +1050,7 @@ class TheMovieDbModule(_ModuleBase): return [MediaInfo(tmdb_info=info) for info in trending] return [] - def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]: + def tmdb_seasons(self, tmdbid: int) -> List[_SchemaTmdbSeason]: """ 根据TMDBID查询themoviedb所有季信息 :param tmdbid: TMDBID @@ -1056,10 +1058,10 @@ class TheMovieDbModule(_ModuleBase): tmdb_info = self.tmdb.get_info(tmdbid=tmdbid, mtype=MediaType.TV) if not tmdb_info: return [] - return [schemas.TmdbSeason(**sea) + return [_SchemaTmdbSeason(**sea) for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None] - def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: + def tmdb_group_seasons(self, group_id: str) -> List[_SchemaTmdbSeason]: """ 根据剧集组ID查询themoviedb所有季集信息 :param group_id: 剧集组ID @@ -1067,14 +1069,14 @@ class TheMovieDbModule(_ModuleBase): group_seasons = self.tmdb.get_tv_group_seasons(group_id) if not group_seasons: return [] - return [schemas.TmdbSeason( + return [_SchemaTmdbSeason( season_number=sea.get("order"), name=sea.get("name"), episode_count=len(sea.get("episodes") or []), air_date=sea.get("episodes")[0].get("air_date") if sea.get("episodes") else None, ) for sea in group_seasons] - def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]: + def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[_SchemaTmdbEpisode]: """ 根据TMDBID查询某季的所有集信息 :param tmdbid: TMDBID @@ -1087,7 +1089,7 @@ class TheMovieDbModule(_ModuleBase): season_info = self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season) if not season_info or not season_info.get("episodes"): return [] - return [schemas.TmdbEpisode(**episode) for episode in season_info.get("episodes")] + return [_SchemaTmdbEpisode(**episode) for episode in season_info.get("episodes")] def scheduler_job(self) -> None: """ @@ -1284,7 +1286,7 @@ class TheMovieDbModule(_ModuleBase): return [MediaInfo(tmdb_info=info) for info in recommend] return [] - def tmdb_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]: + def tmdb_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[_SchemaMediaPerson]: """ 根据TMDBID查询电影演职员表 :param tmdbid: TMDBID @@ -1292,10 +1294,10 @@ class TheMovieDbModule(_ModuleBase): """ credit_infos = self.tmdb.get_movie_credits(tmdbid=tmdbid, page=page) if credit_infos: - return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos] + return [_SchemaMediaPerson(source="themoviedb", **info) for info in credit_infos] return [] - def tmdb_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]: + def tmdb_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[_SchemaMediaPerson]: """ 根据TMDBID查询电视剧演职员表 :param tmdbid: TMDBID @@ -1303,18 +1305,18 @@ class TheMovieDbModule(_ModuleBase): """ credit_infos = self.tmdb.get_tv_credits(tmdbid=tmdbid, page=page) if credit_infos: - return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos] + return [_SchemaMediaPerson(source="themoviedb", **info) for info in credit_infos] return [] - def tmdb_person_detail(self, person_id: int) -> schemas.MediaPerson: + def tmdb_person_detail(self, person_id: int) -> _SchemaMediaPerson: """ 根据TMDBID查询人物详情 :param person_id: 人物ID """ detail = self.tmdb.get_person_detail(person_id=person_id) if detail: - return schemas.MediaPerson(source="themoviedb", **detail) - return schemas.MediaPerson() + return _SchemaMediaPerson(source="themoviedb", **detail) + return _SchemaMediaPerson() def tmdb_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]: """ @@ -1440,7 +1442,7 @@ class TheMovieDbModule(_ModuleBase): return [MediaInfo(tmdb_info=info) for info in results] return [] - async def async_tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]: + async def async_tmdb_seasons(self, tmdbid: int) -> List[_SchemaTmdbSeason]: """ 根据TMDBID查询themoviedb所有季信息(异步版本) :param tmdbid: TMDBID @@ -1448,10 +1450,10 @@ class TheMovieDbModule(_ModuleBase): tmdb_info = await self.tmdb.async_get_info(tmdbid=tmdbid, mtype=MediaType.TV) if not tmdb_info: return [] - return [schemas.TmdbSeason(**sea) + return [_SchemaTmdbSeason(**sea) for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None] - async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: + async def async_tmdb_group_seasons(self, group_id: str) -> List[_SchemaTmdbSeason]: """ 根据剧集组ID查询themoviedb所有季集信息(异步版本) :param group_id: 剧集组ID @@ -1459,7 +1461,7 @@ class TheMovieDbModule(_ModuleBase): group_seasons = await self.tmdb.async_get_tv_group_seasons(group_id) if not group_seasons: return [] - return [schemas.TmdbSeason( + return [_SchemaTmdbSeason( season_number=sea.get("order"), name=sea.get("name"), episode_count=len(sea.get("episodes") or []), @@ -1467,7 +1469,7 @@ class TheMovieDbModule(_ModuleBase): ) for sea in group_seasons] async def async_tmdb_episodes(self, tmdbid: int, season: int, - episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]: + episode_group: Optional[str] = None) -> List[_SchemaTmdbEpisode]: """ 根据TMDBID查询某季的所有集信息(异步版本) :param tmdbid: TMDBID @@ -1480,7 +1482,7 @@ class TheMovieDbModule(_ModuleBase): season_info = await self.tmdb.async_get_tv_season_detail(tmdbid=tmdbid, season=season) if not season_info or not season_info.get("episodes"): return [] - return [schemas.TmdbEpisode(**episode) for episode in season_info.get("episodes")] + return [_SchemaTmdbEpisode(**episode) for episode in season_info.get("episodes")] async def async_tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]: """ @@ -1522,7 +1524,7 @@ class TheMovieDbModule(_ModuleBase): return [MediaInfo(tmdb_info=info) for info in recommend] return [] - async def async_tmdb_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]: + async def async_tmdb_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[_SchemaMediaPerson]: """ 根据TMDBID查询电影演职员表(异步版本) :param tmdbid: TMDBID @@ -1530,10 +1532,10 @@ class TheMovieDbModule(_ModuleBase): """ credit_infos = await self.tmdb.async_get_movie_credits(tmdbid=tmdbid, page=page) if credit_infos: - return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos] + return [_SchemaMediaPerson(source="themoviedb", **info) for info in credit_infos] return [] - async def async_tmdb_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]: + async def async_tmdb_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[_SchemaMediaPerson]: """ 根据TMDBID查询电视剧演职员表(异步版本) :param tmdbid: TMDBID @@ -1541,18 +1543,18 @@ class TheMovieDbModule(_ModuleBase): """ credit_infos = await self.tmdb.async_get_tv_credits(tmdbid=tmdbid, page=page) if credit_infos: - return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos] + return [_SchemaMediaPerson(source="themoviedb", **info) for info in credit_infos] return [] - async def async_tmdb_person_detail(self, person_id: int) -> schemas.MediaPerson: + async def async_tmdb_person_detail(self, person_id: int) -> _SchemaMediaPerson: """ 根据TMDBID查询人物详情(异步版本) :param person_id: 人物ID """ detail = await self.tmdb.async_get_person_detail(person_id=person_id) if detail: - return schemas.MediaPerson(source="themoviedb", **detail) - return schemas.MediaPerson() + return _SchemaMediaPerson(source="themoviedb", **detail) + return _SchemaMediaPerson() async def async_tmdb_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]: """ diff --git a/app/modules/transmission/__init__.py b/app/modules/transmission/__init__.py index 118dfb5b3..cafd326a5 100644 --- a/app/modules/transmission/__init__.py +++ b/app/modules/transmission/__init__.py @@ -3,13 +3,13 @@ from typing import Set, Tuple, Optional, Union, List, Dict from transmission_rpc import File -from app import schemas +from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo from app.runtime.config import settings from app.domain.metainfo import MetaInfo from app.runtime.log import logger from app.modules._base import _DownloaderModuleBase from app.modules.transmission.transmission import Transmission -from app.schemas import DownloaderTorrent +from app.schemas.transfer import DownloaderTorrent from app.schemas.types import ( DownloadTaskState, DownloaderType, @@ -534,7 +534,7 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]): return None return server.get_files(tid=tid) - def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]: + def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[_SchemaDownloaderInfo]]: """ 下载器信息 """ @@ -551,7 +551,7 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]): info = server.transfer_info() if not info: continue - ret_info.append(schemas.DownloaderInfo( + ret_info.append(_SchemaDownloaderInfo( download_speed=info.download_speed, upload_speed=info.upload_speed, download_size=info.current_stats.downloaded_bytes, diff --git a/app/modules/trimemedia/__init__.py b/app/modules/trimemedia/__init__.py index 817e4dadb..396341f78 100644 --- a/app/modules/trimemedia/__init__.py +++ b/app/modules/trimemedia/__init__.py @@ -1,6 +1,11 @@ from typing import Any, Generator, List, Optional, Tuple, Union -from app import schemas +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.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.runtime.log import logger from app.modules._base import _MediaServerModuleBase from app.modules.trimemedia.trimemedia import TrimeMedia @@ -74,7 +79,7 @@ class TrimeMediaModule(_MediaServerModuleBase[TrimeMedia]): def webhook_parser( self, body: Any, form: Any, args: Any - ) -> Optional[schemas.WebhookEventInfo]: + ) -> Optional[_SchemaWebhookEventInfo]: """ 解析Webhook报文体 @@ -102,7 +107,7 @@ class TrimeMediaModule(_MediaServerModuleBase[TrimeMedia]): def media_statistic( self, server: Optional[str] = None - ) -> Optional[List[schemas.Statistic]]: + ) -> Optional[List[_SchemaStatistic]]: """ 媒体数量统计 """ @@ -124,7 +129,7 @@ class TrimeMediaModule(_MediaServerModuleBase[TrimeMedia]): def mediaserver_librarys( self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs - ) -> Optional[List[schemas.MediaServerLibrary]]: + ) -> Optional[List[_SchemaMediaServerLibrary]]: """ 媒体库列表 """ @@ -172,7 +177,7 @@ class TrimeMediaModule(_MediaServerModuleBase[TrimeMedia]): def mediaserver_iteminfo( self, server: str, item_id: str - ) -> Optional[schemas.MediaServerItem]: + ) -> Optional[_SchemaMediaServerItem]: """ 媒体库项目详情 """ @@ -183,7 +188,7 @@ class TrimeMediaModule(_MediaServerModuleBase[TrimeMedia]): def mediaserver_tv_episodes( self, server: str, item_id: Union[str, int] - ) -> Optional[List[schemas.MediaServerSeasonInfo]]: + ) -> Optional[List[_SchemaMediaServerSeasonInfo]]: """ 获取剧集信息 """ @@ -196,13 +201,13 @@ class TrimeMediaModule(_MediaServerModuleBase[TrimeMedia]): if not seasoninfo: return [] return [ - schemas.MediaServerSeasonInfo(season=season, episodes=episodes) + _SchemaMediaServerSeasonInfo(season=season, episodes=episodes) for season, episodes in seasoninfo.items() ] def mediaserver_playing( self, server: str, count: Optional[int] = 20, **kwargs - ) -> Optional[List[schemas.MediaServerPlayItem]]: + ) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ @@ -229,7 +234,7 @@ class TrimeMediaModule(_MediaServerModuleBase[TrimeMedia]): server: Optional[str] = None, count: Optional[int] = 20, **kwargs, - ) -> Optional[List[schemas.MediaServerPlayItem]]: + ) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ diff --git a/app/modules/trimemedia/trimemedia.py b/app/modules/trimemedia/trimemedia.py index 937cd232c..1ce921bd6 100644 --- a/app/modules/trimemedia/trimemedia.py +++ b/app/modules/trimemedia/trimemedia.py @@ -2,10 +2,16 @@ from pathlib import Path from typing import Any, Dict, Generator, List, Optional, Tuple, Union import app.modules.trimemedia.api as fnapi -from app import schemas +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.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.application.mediaserver import MediaServerIdentityHelper from app.runtime.log import logger -from app.schemas import MediaType +from app.schemas.types import MediaType from app.schemas.types import MediaSource from app.application.security.url import SecurityUtils from app.foundation.url import UrlUtils @@ -174,7 +180,7 @@ class TrimeMedia: def get_librarys( self, hidden: Optional[bool] = False - ) -> Optional[List[schemas.MediaServerLibrary]]: + ) -> Optional[List[_SchemaMediaServerLibrary]]: """ 获取媒体服务器所有媒体库列表 """ @@ -203,7 +209,7 @@ class TrimeMedia: else: library_type = MediaType.UNKNOWN.value libraries.append( - schemas.MediaServerLibrary( + _SchemaMediaServerLibrary( server="trimemedia", id=library.guid, name=library.name, @@ -231,17 +237,17 @@ class TrimeMedia: return 0 return len(self._api.user_list() or []) - def get_medias_count(self) -> schemas.Statistic: + def get_medias_count(self) -> _SchemaStatistic: """ 获取媒体数量 :return: MovieCount SeriesCount """ if not self.is_authenticated(): - return schemas.Statistic() + return _SchemaStatistic() if (info := self._api.mediadb_sum()) is None: - return schemas.Statistic() - return schemas.Statistic( + return _SchemaStatistic() + return _SchemaStatistic( movie_count=info.movie, tv_count=info.tv, music_count=getattr(info, "music", 0) or getattr(info, "audio", 0) or 0, @@ -270,7 +276,7 @@ class TrimeMedia: self, title: str, year: Optional[str] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - ) -> Optional[List[schemas.MediaServerItem]]: + ) -> Optional[List[_SchemaMediaServerItem]]: """ 根据标题和年份,检查电影是否在飞牛中存在,存在则返回列表 @@ -393,7 +399,7 @@ class TrimeMedia: return self._api.mdb_scanall() def refresh_library_by_items( - self, items: List[schemas.RefreshMediaItem] + self, items: List[_SchemaRefreshMediaItem] ) -> Optional[bool]: """ 按路径刷新所在的媒体库(非管理员不能调用) @@ -443,10 +449,10 @@ class TrimeMedia: return lib return None - def get_webhook_message(self, body: Any) -> Optional[schemas.WebhookEventInfo]: + def get_webhook_message(self, body: Any) -> Optional[_SchemaWebhookEventInfo]: pass - def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]: + def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]: """ 获取单个项目详情 """ @@ -465,7 +471,7 @@ class TrimeMedia: else: year = None - user_state = schemas.MediaServerItemUserState() + user_state = _SchemaMediaServerItemUserState() if item.watched: user_state.played = True if item.duration and item.ts is not None: @@ -481,7 +487,7 @@ class TrimeMedia: "imdb_id": item.imdb_id, "douban_id": item.douban_id, }) - return schemas.MediaServerItem( + return _SchemaMediaServerItem( server="trimemedia", library=item.ancestor_guid, item_id=item.guid, @@ -513,7 +519,7 @@ class TrimeMedia: def __build_media_server_play_item( self, item: fnapi.Item - ) -> schemas.MediaServerPlayItem: + ) -> _SchemaMediaServerPlayItem: if item.type == fnapi.Type.EPISODE: title = item.tv_title subtitle = f"S{item.season_number}:{item.episode_number} - {item.title}" @@ -525,7 +531,7 @@ class TrimeMedia: if item.type in [fnapi.Type.MOVIE, fnapi.Type.VIDEO] else MediaType.TV.value ) - return schemas.MediaServerPlayItem( + return _SchemaMediaServerPlayItem( id=item.guid, title=title, subtitle=subtitle, @@ -560,7 +566,7 @@ class TrimeMedia: parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1, - ) -> Generator[schemas.MediaServerItem | None | Any, Any, None]: + ) -> Generator[_SchemaMediaServerItem | None | Any, Any, None]: """ 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 @@ -606,7 +612,7 @@ class TrimeMedia: def get_resume( self, num: Optional[int] = 12 - ) -> Optional[List[schemas.MediaServerPlayItem]]: + ) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取继续观看列表 @@ -626,7 +632,7 @@ class TrimeMedia: ret_resume.append(self.__build_media_server_play_item(item)) return ret_resume - def get_latest(self, num=20) -> Optional[List[schemas.MediaServerPlayItem]]: + def get_latest(self, num=20) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取最近更新列表 """ diff --git a/app/modules/ugreen/__init__.py b/app/modules/ugreen/__init__.py index 5c05440fa..dda0a8d67 100644 --- a/app/modules/ugreen/__init__.py +++ b/app/modules/ugreen/__init__.py @@ -1,6 +1,11 @@ from typing import Any, Generator, List, Optional, Tuple, Union -from app import schemas +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.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.runtime.log import logger from app.modules._base import _MediaServerModuleBase from app.modules.ugreen.ugreen import Ugreen @@ -74,7 +79,7 @@ class UgreenModule(_MediaServerModuleBase[Ugreen]): def webhook_parser( self, body: Any, form: Any, args: Any - ) -> Optional[schemas.WebhookEventInfo]: + ) -> Optional[_SchemaWebhookEventInfo]: """ 解析Webhook报文体 """ @@ -97,7 +102,7 @@ class UgreenModule(_MediaServerModuleBase[Ugreen]): def media_statistic( self, server: Optional[str] = None - ) -> Optional[List[schemas.Statistic]]: + ) -> Optional[List[_SchemaStatistic]]: """ 媒体数量统计 """ @@ -120,7 +125,7 @@ class UgreenModule(_MediaServerModuleBase[Ugreen]): def mediaserver_librarys( self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs - ) -> Optional[List[schemas.MediaServerLibrary]]: + ) -> Optional[List[_SchemaMediaServerLibrary]]: """ 媒体库列表 """ @@ -161,7 +166,7 @@ class UgreenModule(_MediaServerModuleBase[Ugreen]): def mediaserver_iteminfo( self, server: str, item_id: str - ) -> Optional[schemas.MediaServerItem]: + ) -> Optional[_SchemaMediaServerItem]: """ 媒体库项目详情 """ @@ -172,7 +177,7 @@ class UgreenModule(_MediaServerModuleBase[Ugreen]): def mediaserver_tv_episodes( self, server: str, item_id: Union[str, int] - ) -> Optional[List[schemas.MediaServerSeasonInfo]]: + ) -> Optional[List[_SchemaMediaServerSeasonInfo]]: """ 获取剧集信息 """ @@ -185,13 +190,13 @@ class UgreenModule(_MediaServerModuleBase[Ugreen]): if not seasoninfo: return [] return [ - schemas.MediaServerSeasonInfo(season=season, episodes=episodes) + _SchemaMediaServerSeasonInfo(season=season, episodes=episodes) for season, episodes in seasoninfo.items() ] def mediaserver_playing( self, server: str, count: Optional[int] = 20, **kwargs - ) -> Optional[List[schemas.MediaServerPlayItem]]: + ) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ @@ -218,7 +223,7 @@ class UgreenModule(_MediaServerModuleBase[Ugreen]): server: Optional[str] = None, count: Optional[int] = 20, **kwargs, - ) -> Optional[List[schemas.MediaServerPlayItem]]: + ) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ diff --git a/app/modules/ugreen/ugreen.py b/app/modules/ugreen/ugreen.py index ef92680b9..5f3730c91 100644 --- a/app/modules/ugreen/ugreen.py +++ b/app/modules/ugreen/ugreen.py @@ -5,13 +5,18 @@ from pathlib import Path from typing import Any, Dict, Generator, List, Mapping, Optional, Union from urllib.parse import parse_qs, urlparse -from app import schemas +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.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.db.oper.systemconfig import SystemConfigOper from app.application.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper from app.runtime.log import logger from app.modules.ugreen.api import Api -from app.schemas import MediaType -from app.schemas.types import MediaSource, SystemConfigKey +from app.schemas.types import MediaSource, MediaType, SystemConfigKey from app.foundation.url import UrlUtils @@ -325,7 +330,7 @@ class Ugreen: @staticmethod def __build_media_server_item(video_info: dict, play_status: Optional[dict] = None): - user_state = schemas.MediaServerItemUserState() + user_state = _SchemaMediaServerItemUserState() if isinstance(play_status, dict): progress = play_status.get("progress") watch_status = play_status.get("watch_status") @@ -346,7 +351,7 @@ class Ugreen: if item_id is None: return None - return schemas.MediaServerItem( + return _SchemaMediaServerItem( server="ugreen", library=video_info.get("media_lib_set_id"), item_id=str(item_id), @@ -372,7 +377,7 @@ class Ugreen: # 绿联深链在部分版本会失效,统一回落到 NAS 根地址。 return self.__build_root_url() - def __build_play_item_from_wrapper(self, wrapper: dict) -> Optional[schemas.MediaServerPlayItem]: + def __build_play_item_from_wrapper(self, wrapper: dict) -> Optional[_SchemaMediaServerPlayItem]: video_info = wrapper.get("video_info") if isinstance(wrapper.get("video_info"), dict) else wrapper if not isinstance(video_info, dict): return None @@ -395,7 +400,7 @@ class Ugreen: video_info.get("backdrop_path") ) - return schemas.MediaServerPlayItem( + return _SchemaMediaServerPlayItem( id=str(item_id), title=video_info.get("name"), subtitle=subtitle, @@ -524,7 +529,7 @@ class Ugreen: return paths - def get_librarys(self, hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]: + def get_librarys(self, hidden: Optional[bool] = False) -> Optional[List[_SchemaMediaServerLibrary]]: """ 获取绿联影视媒体库列表 @@ -568,7 +573,7 @@ class Ugreen: } libraries.append( - schemas.MediaServerLibrary( + _SchemaMediaServerLibrary( server="ugreen", id=lib_id, name=lib_name, @@ -591,16 +596,16 @@ class Ugreen: users = self._api.media_lib_users() return len(users) - def get_medias_count(self) -> schemas.Statistic: + def get_medias_count(self) -> _SchemaStatistic: """获取绿联影视的电影和电视剧数量统计""" if not self.is_authenticated() or not self._api: - return schemas.Statistic() + return _SchemaStatistic() movie_data = self._api.video_all(classification=-102, page=1, page_size=1) or {} tv_data = self._api.video_all(classification=-103, page=1, page_size=1) or {} music_data = self._api.video_all(classification=-104, page=1, page_size=1) or {} - return schemas.Statistic( + return _SchemaStatistic( movie_count=int(movie_data.get("total_num") or 0), tv_count=int(tv_data.get("total_num") or 0), # 绿联当前不统计剧集总数,返回 None 由前端展示“未获取”。 @@ -639,7 +644,7 @@ class Ugreen: self, title: str, year: Optional[str] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - ) -> Optional[List[schemas.MediaServerItem]]: + ) -> Optional[List[_SchemaMediaServerItem]]: if not self.is_authenticated() or not self._api or not title: return None @@ -672,7 +677,7 @@ class Ugreen: title: Optional[str] = None, artist: Optional[str] = None, album: Optional[str] = None, - ) -> List[schemas.MediaServerItem]: + ) -> List[_SchemaMediaServerItem]: """按歌曲、艺术家或专辑名称查询绿联影视音乐条目。""" if not self.is_authenticated() or not self._api: return [] @@ -686,7 +691,7 @@ class Ugreen: # 绿联搜索按媒体类型分桶返回,音乐相关桶名在不同固件版本上并不统一 music_buckets = ("music_list", "audio_list", "album_list", "songs_list") - results: List[schemas.MediaServerItem] = [] + results: List[_SchemaMediaServerItem] = [] for bucket in music_buckets: for info in self.__extract_video_info_list(data.get(bucket)): media_item = self.__build_media_server_item(info) @@ -845,7 +850,7 @@ class Ugreen: def refresh_library_by_items( self, - items: List[schemas.RefreshMediaItem], + items: List[_SchemaRefreshMediaItem], scan_mode: Optional[Union[str, int]] = None, ) -> Optional[bool]: if not self.is_authenticated() or not self._api: @@ -872,10 +877,10 @@ class Ugreen: return True @staticmethod - def get_webhook_message(body: Any) -> Optional[schemas.WebhookEventInfo]: + def get_webhook_message(body: Any) -> Optional[_SchemaWebhookEventInfo]: return None - def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]: + def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]: if not self.is_authenticated() or not self._api or not itemid: return None @@ -951,7 +956,7 @@ class Ugreen: parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1, - ) -> Generator[schemas.MediaServerItem | None | Any, Any, None]: + ) -> Generator[_SchemaMediaServerItem | None | Any, Any, None]: """ 获取指定绿联影视媒体库的可同步条目 @@ -1011,7 +1016,7 @@ class Ugreen: media_lib_set_id=video_info.get("media_lib_set_id"), ) - def get_resume(self, num: Optional[int] = 12) -> Optional[List[schemas.MediaServerPlayItem]]: + def get_resume(self, num: Optional[int] = 12) -> Optional[List[_SchemaMediaServerPlayItem]]: if not self.is_authenticated() or not self._api: return None @@ -1036,7 +1041,7 @@ class Ugreen: return ret_resume - def get_latest(self, num: int = 20) -> Optional[List[schemas.MediaServerPlayItem]]: + def get_latest(self, num: int = 20) -> Optional[List[_SchemaMediaServerPlayItem]]: if not self.is_authenticated() or not self._api: return None diff --git a/app/modules/vocechat/__init__.py b/app/modules/vocechat/__init__.py index 0df73fdd8..05436a9bb 100644 --- a/app/modules/vocechat/__init__.py +++ b/app/modules/vocechat/__init__.py @@ -11,7 +11,9 @@ from app.application.messaging.agent import ( from app.runtime.log import logger from app.modules._base import _MessageChannelModuleBase from app.modules.vocechat.vocechat import VoceChat -from app.schemas import NotificationChannel, IncomingMessage, Message +from app.schemas.notification import NotificationChannel +from app.schemas.message import IncomingMessage +from app.schemas.message import Message from app.schemas.types import ModuleType diff --git a/app/modules/webpush/__init__.py b/app/modules/webpush/__init__.py index bb2b13dac..5eff86970 100644 --- a/app/modules/webpush/__init__.py +++ b/app/modules/webpush/__init__.py @@ -6,7 +6,7 @@ from pywebpush import webpush, WebPushException from app.runtime.config import global_vars, settings from app.runtime.log import logger from app.modules import _ModuleBase, _MessageBase -from app.schemas import Message +from app.schemas.message import Message from app.schemas.types import ModuleType, NotificationChannel diff --git a/app/modules/wechat/__init__.py b/app/modules/wechat/__init__.py index 9df75e116..3e9ee7bfe 100644 --- a/app/modules/wechat/__init__.py +++ b/app/modules/wechat/__init__.py @@ -15,7 +15,9 @@ from app.modules._base import _MessageChannelModuleBase from app.adapters.external.wechat_crypt import WXBizMsgCrypt from app.modules.wechat.wechat import WeChat from app.modules.wechat.wechatbot import WeChatBot -from app.schemas import NotificationChannel, IncomingMessage, Message +from app.schemas.notification import NotificationChannel +from app.schemas.message import IncomingMessage +from app.schemas.message import Message from app.schemas.types import ModuleType from app.foundation.dom import DomUtils diff --git a/app/modules/wechat/wechatbot.py b/app/modules/wechat/wechatbot.py index f704e2c8e..1b48648cf 100644 --- a/app/modules/wechat/wechatbot.py +++ b/app/modules/wechat/wechatbot.py @@ -17,7 +17,7 @@ from app.domain.context import MediaInfo, Context from app.domain.metainfo import MetaInfo from app.application.messaging.agent import matches_channel_admin from app.runtime.log import logger -from app.schemas import IncomingMessage +from app.schemas.message import IncomingMessage from app.schemas.types import NotificationChannel from app.adapters.network.http import RequestUtils from app.foundation import size as size_tools diff --git a/app/modules/wechatclawbot/__init__.py b/app/modules/wechatclawbot/__init__.py index 1d4cf7b92..d28475320 100644 --- a/app/modules/wechatclawbot/__init__.py +++ b/app/modules/wechatclawbot/__init__.py @@ -11,7 +11,8 @@ from app.application.messaging.agent import ( from app.runtime.log import logger from app.modules._base import _MessageChannelModuleBase from app.modules.wechatclawbot.wechatclawbot import WechatClawBot -from app.schemas import IncomingMessage, Message +from app.schemas.message import IncomingMessage +from app.schemas.message import Message from app.schemas.types import NotificationChannel, ModuleType, NotificationAction diff --git a/app/modules/zspace/__init__.py b/app/modules/zspace/__init__.py index 1d8e606a3..8cda2508c 100644 --- a/app/modules/zspace/__init__.py +++ b/app/modules/zspace/__init__.py @@ -1,10 +1,16 @@ from typing import Any, Generator, List, Optional, Tuple, Union -from app import schemas +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.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.runtime.log import logger from app.modules._base import _MediaServerModuleBase from app.modules.zspace.zspace import ZSpace -from app.schemas import AuthCredentials, AuthInterceptCredentials +from app.schemas.event import AuthCredentials +from app.schemas.event import AuthInterceptCredentials from app.schemas.types import ChainEventType, MediaServerType, ModuleType @@ -59,7 +65,7 @@ class ZSpaceModule(_MediaServerModuleBase[ZSpace]): def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]: + def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[_SchemaWebhookEventInfo]: """ 解析Webhook报文体 :param body: 请求体 @@ -84,7 +90,7 @@ class ZSpaceModule(_MediaServerModuleBase[ZSpace]): return result return None - def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: + def media_statistic(self, server: Optional[str] = None) -> Optional[List[_SchemaStatistic]]: """ 媒体数量统计 """ @@ -106,7 +112,7 @@ class ZSpaceModule(_MediaServerModuleBase[ZSpace]): def mediaserver_librarys(self, server: str, username: Optional[str] = None, - hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]: + hidden: Optional[bool] = False) -> Optional[List[_SchemaMediaServerLibrary]]: """ 媒体库列表 """ @@ -145,7 +151,7 @@ class ZSpaceModule(_MediaServerModuleBase[ZSpace]): return server_obj.get_items_count(library_id) return None - def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]: + def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[_SchemaMediaServerItem]: """ 媒体库项目详情 """ @@ -155,7 +161,7 @@ class ZSpaceModule(_MediaServerModuleBase[ZSpace]): return None def mediaserver_tv_episodes(self, server: str, - item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]: + item_id: Union[str, int]) -> Optional[List[_SchemaMediaServerSeasonInfo]]: """ 获取剧集信息 """ @@ -165,13 +171,13 @@ class ZSpaceModule(_MediaServerModuleBase[ZSpace]): _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id) if not seasoninfo: return [] - return [schemas.MediaServerSeasonInfo( + return [_SchemaMediaServerSeasonInfo( season=season, episodes=episodes ) for season, episodes in seasoninfo.items()] def mediaserver_playing(self, server: str, count: Optional[int] = 20, - username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: + username: Optional[str] = None) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ @@ -190,7 +196,7 @@ class ZSpaceModule(_MediaServerModuleBase[ZSpace]): return server_obj.get_play_url(item_id) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, - username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: + username: Optional[str] = None) -> Optional[List[_SchemaMediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ diff --git a/app/modules/zspace/zspace.py b/app/modules/zspace/zspace.py index abb1994a7..96866230f 100644 --- a/app/modules/zspace/zspace.py +++ b/app/modules/zspace/zspace.py @@ -7,10 +7,16 @@ from typing import List, Optional, Union, Dict, Generator, Tuple, Any from requests import Response -from app import schemas +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.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.application.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper from app.runtime.log import logger -from app.schemas import MediaServerItem +from app.schemas.mediaserver import MediaServerItem from app.schemas.types import MediaSource, MediaType from app.adapters.network.http import RequestUtils from app.foundation.url import UrlUtils @@ -228,7 +234,7 @@ class ZSpace: self, username: Optional[str] = None, hidden: Optional[bool] = False, - ) -> Optional[List[schemas.MediaServerLibrary]]: + ) -> Optional[List[_SchemaMediaServerLibrary]]: """ 获取媒体服务器所有媒体库列表 """ @@ -252,7 +258,7 @@ class ZSpace: library_type = MediaType.UNKNOWN.value image = self.__get_local_image_by_id(library.get("Id")) libraries.append( - schemas.MediaServerLibrary( + _SchemaMediaServerLibrary( server="zspace", id=library.get("Id"), name=library.get("Name"), @@ -357,7 +363,7 @@ class ZSpace: logger.debug(f"连接Users/Query出错:{e},回退到登录用户兜底") return 1 if self.user else 0 - def get_medias_count(self) -> schemas.Statistic: + def get_medias_count(self) -> _SchemaStatistic: """ 获得电影、电视剧、动漫媒体数量。 @@ -382,13 +388,13 @@ class ZSpace: :return: MovieCount SeriesCount EpisodeCount """ if not self._host or not self._apikey: - return schemas.Statistic() + return _SchemaStatistic() url = f"{self._host}emby/Items/Counts" try: res = self.__request_utils().get_res(url) if res: result = res.json() - return schemas.Statistic( + return _SchemaStatistic( movie_count=result.get("MovieCount") or 0, tv_count=result.get("SeriesCount") or 0, episode_count=result.get("EpisodeCount") or 0, @@ -400,7 +406,7 @@ class ZSpace: logger.debug(f"连接Items/Counts出错:{e},回退到按媒体库累计 TotalRecordCount") return self.__count_medias_by_views() - def __count_medias_by_views(self) -> schemas.Statistic: + def __count_medias_by_views(self) -> _SchemaStatistic: """ 通过遍历媒体库视图累计条目数,兜底实现 `get_medias_count`。 @@ -417,11 +423,11 @@ class ZSpace: 统一计为 0。 """ if not self._host or not self._apikey or not self.user: - return schemas.Statistic() + return _SchemaStatistic() # 与 get_librarys / get_user_library_folders 保持一致的选中库过滤: # _sync_libraries 为空或包含 "all" 视为全部库。 sync_all = (not self._sync_libraries) or ("all" in self._sync_libraries) - stat = schemas.Statistic() + stat = _SchemaStatistic() for view in self.__get_library_views() or []: view_id = view.get("Id") if not view_id: @@ -515,7 +521,7 @@ class ZSpace: title: str, year: Optional[str] = None, media_source: Optional[MediaSource] = None, - media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]: + media_id: Optional[str] = None) -> Optional[List[_SchemaMediaServerItem]]: """ 根据标题和年份,检查电影是否在极影视中存在,存在则返回列表 :param title: 标题 @@ -564,7 +570,7 @@ class ZSpace: title: Optional[str] = None, artist: Optional[str] = None, album: Optional[str] = None, - ) -> List[schemas.MediaServerItem]: + ) -> List[_SchemaMediaServerItem]: """按歌曲、艺术家或专辑名称查询极影视音乐条目。""" if not self._host or not self._apikey: return [] @@ -750,7 +756,7 @@ class ZSpace: logger.debug(f"连接Library/Refresh出错:{e}(极影视当前 Emby 兼容层未实现该端点)") return False - def refresh_library_by_items(self, items: List[schemas.RefreshMediaItem]) -> Optional[bool]: + def refresh_library_by_items(self, items: List[_SchemaRefreshMediaItem]) -> Optional[bool]: """ 按类型、名称、年份来刷新媒体库 :param items: 已识别的需要刷新媒体库的媒体信息列表 @@ -773,7 +779,7 @@ class ZSpace: logger.info("极影视媒体库刷新完成") return success - def __get_library_id_by_item(self, item: schemas.RefreshMediaItem) -> Optional[str]: + def __get_library_id_by_item(self, item: _SchemaRefreshMediaItem) -> Optional[str]: """ 根据媒体信息查询在哪个媒体库,返回要刷新的位置的ID :param item: {title, year, type, category, target_path} @@ -806,7 +812,7 @@ class ZSpace: return "/" @staticmethod - def __format_item_info(item) -> Optional[schemas.MediaServerItem]: + def __format_item_info(item) -> Optional[_SchemaMediaServerItem]: """ 格式化item """ @@ -820,7 +826,7 @@ class ZSpace: last_played_date = item.get("UserData", {}).get("LastPlayedDate") if last_played_date is not None and "." in last_played_date: last_played_date = last_played_date.split(".")[0] - user_state = schemas.MediaServerItemUserState( + user_state = _SchemaMediaServerItemUserState( played=item.get("UserData", {}).get("Played"), resume=resume, last_played_date=datetime.strptime(last_played_date, "%Y-%m-%dT%H:%M:%S").strftime( @@ -831,7 +837,7 @@ class ZSpace: media_source, media_id = MediaServerIdentityHelper.from_provider_ids( item.get("ProviderIds") ) - return schemas.MediaServerItem( + return _SchemaMediaServerItem( server="zspace", library=item.get("ParentId"), item_id=item.get("Id"), @@ -851,7 +857,7 @@ class ZSpace: logger.error(e) return None - def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]: + def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]: """ 获取单个项目详情 """ @@ -969,7 +975,7 @@ class ZSpace: break return None - def get_webhook_message(self, form: Any, args: dict) -> Optional[schemas.WebhookEventInfo]: + def get_webhook_message(self, form: Any, args: dict) -> Optional[_SchemaWebhookEventInfo]: """ 解析极影视 Webhook 报文 """ @@ -988,7 +994,7 @@ class ZSpace: if not event_type: return None logger.debug(f"接收到极影视 webhook:{message}") - event_item = schemas.WebhookEventInfo(event=event_type, channel="zspace") + event_item = _SchemaWebhookEventInfo(event=event_type, channel="zspace") if message.get('Item'): event_item.media_type = message.get('Item', {}).get('Type') if message.get('Item', {}).get('Type') == 'Episode' \ @@ -1135,7 +1141,7 @@ class ZSpace: return f"{self._host}emby/Items/{item_id}/Images/Primary?api_key={self._apikey}" def get_resume(self, num: Optional[int] = 12, username: Optional[str] = None) -> Optional[ - List[schemas.MediaServerPlayItem]]: + List[_SchemaMediaServerPlayItem]]: """ 获得继续观看 """ @@ -1187,7 +1193,7 @@ class ZSpace: image_tag=item.get("SeriesPrimaryImageTag")) if not image: image = self.__get_local_image_by_id(item.get("SeriesId")) - ret_resume.append(schemas.MediaServerPlayItem( + ret_resume.append(_SchemaMediaServerPlayItem( id=item.get("Id"), title=title, subtitle=subtitle, @@ -1205,7 +1211,7 @@ class ZSpace: return None def get_latest(self, num: Optional[int] = 20, username: Optional[str] = None) -> Optional[ - List[schemas.MediaServerPlayItem]]: + List[_SchemaMediaServerPlayItem]]: """ 获得最近更新。 @@ -1254,7 +1260,7 @@ class ZSpace: item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value link = self.get_play_url(item.get("Id")) image = self.__get_local_image_by_id(item_id=item.get("Id")) - ret_latest.append(schemas.MediaServerPlayItem( + ret_latest.append(_SchemaMediaServerPlayItem( id=item.get("Id"), title=item.get("Name"), subtitle=str(item.get("ProductionYear")) if item.get("ProductionYear") else None, diff --git a/app/monitor/dispatcher.py b/app/monitor/dispatcher.py index a4f2073ce..c0608d84c 100644 --- a/app/monitor/dispatcher.py +++ b/app/monitor/dispatcher.py @@ -14,7 +14,7 @@ from app.application.history import (HistoryGateAction, describe_history_gate, max_failed_retries, resolve_history) from app.runtime.log import logger from app.adapters.system.fsproxy import fsproxy -from app.schemas import FileItem +from app.schemas.workflow import FileItem from app.schemas.types import MediaType diff --git a/app/monitor/syslimits.py b/app/monitor/syslimits.py index 6c5f39202..3c1ec9f14 100644 --- a/app/monitor/syslimits.py +++ b/app/monitor/syslimits.py @@ -1,4 +1,3 @@ -import os import platform from pathlib import Path from typing import Any, Dict, List, Optional, Tuple diff --git a/app/runtime/compat/imports.py b/app/runtime/compat/imports.py index c23226510..7954d62e6 100644 --- a/app/runtime/compat/imports.py +++ b/app/runtime/compat/imports.py @@ -5,7 +5,7 @@ import importlib.util import sys import threading from types import ModuleType -from typing import Dict, Optional +from typing import Dict from app.runtime.compat.diagnostics import record_legacy_import from app.runtime.compat.manifest import ( diff --git a/app/runtime/compat/manifest.py b/app/runtime/compat/manifest.py index e33ec7ffd..07071009f 100644 --- a/app/runtime/compat/manifest.py +++ b/app/runtime/compat/manifest.py @@ -757,9 +757,9 @@ SYMBOL_ALIASES: Dict[str, Dict[str, SymbolAlias]] = { replacement="app.chain.agent.AgentChain", ), "ReplyMode": SymbolAlias( - target_module="app.schemas.agent", + target_module="app.schemas.types", target_name="ReplyMode", - replacement="app.schemas.agent.ReplyMode", + replacement="app.schemas.types.ReplyMode", ), }, # 刮削能力从 MediaChain 拆出为独立 ScrapingChain 后, @@ -807,12 +807,59 @@ SYMBOL_ALIASES: Dict[str, Dict[str, SymbolAlias]] = { **_MESSAGE_NOTIFICATION_SYMBOL_ALIASES, }, "app.schemas.transfer": { + **{ + name: SymbolAlias( + target_module="app.sdk._legacy.transfer", + target_name=name, + replacement=f"app.application.transfer.{name}", + ) + for name in ("TransferTask", "TransferQueue") + }, + "DownloadHistory": SymbolAlias( + target_module="app.schemas.history", + target_name="DownloadHistory", + replacement="app.schemas.history.DownloadHistory", + ), + "TransferDirectoryConf": SymbolAlias( + target_module="app.schemas.system", + target_name="TransferDirectoryConf", + replacement="app.schemas.system.TransferDirectoryConf", + ), + "TmdbEpisode": SymbolAlias( + target_module="app.schemas.tmdb", + target_name="TmdbEpisode", + replacement="app.schemas.tmdb.TmdbEpisode", + ), + "MediaType": SymbolAlias( + target_module="app.schemas.types", + target_name="MediaType", + replacement="app.schemas.types.MediaType", + ), + }, + "app.schemas.agent": { + "ReplyMode": SymbolAlias( + target_module="app.schemas.types", + target_name="ReplyMode", + replacement="app.schemas.types.ReplyMode", + ), + }, + "app.sdk.logging": { name: SymbolAlias( - target_module="app.sdk._legacy.transfer", + target_module="app.runtime.log", target_name=name, - replacement=f"app.application.transfer.{name}", + replacement=f"app.runtime.log.{name}", + ) + for name in ( + "CustomFormatter", + "LogConfigModel", + "LogEntry", + "LogSettings", + "LoggerManager", + "NonBlockingFileHandler", + "configure_log_settings", + "configure_log_writer", + "log_settings", ) - for name in ("TransferTask", "TransferQueue") }, # message/notification 命名统一:通知渠道能力归 notification,消息收发归 message "app.schemas.types": { diff --git a/app/runtime/config.py b/app/runtime/config.py index 6287dde96..1ed351457 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -24,7 +24,7 @@ from app.runtime.log import ( log_settings, NonBlockingFileHandler, ) -from app.schemas import MediaType +from app.schemas.types import MediaType from app.adapters.system.host import SystemUtils from app.foundation.url import UrlUtils from version import APP_VERSION diff --git a/app/runtime/event/__init__.py b/app/runtime/event/__init__.py new file mode 100644 index 000000000..48b4081af --- /dev/null +++ b/app/runtime/event/__init__.py @@ -0,0 +1 @@ +"""事件运行时内部组件。""" diff --git a/app/runtime/event/binding.py b/app/runtime/event/binding.py new file mode 100644 index 000000000..a5ff91fc7 --- /dev/null +++ b/app/runtime/event/binding.py @@ -0,0 +1,138 @@ +"""事件处理器声明到运行实例的显式绑定解析。""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Optional, Type + +from app.runtime.event.registry import EventRegistry +from app.runtime.log import logger + + +@dataclass(frozen=True, slots=True) +class EventHandlerBinding: + """描述上层运行时为某个事件处理器提供的实例绑定。""" + + instance: Optional[Any] + owner_name: str + run_sync_in_threadpool: bool = False + + +HandlerInstanceResolver = Callable[ + [Type[Any]], Optional[EventHandlerBinding] +] + + +class EventBindingResolver: + """只通过已登记 resolver 把类处理器绑定到托管运行实例。""" + + def __init__( + self, + *, + lock: Any, + resolvers: Callable[[], dict[str, HandlerInstanceResolver]], + ) -> None: + """绑定 resolver 存储,并记录未命中的处理器用于启动诊断。""" + self._lock = lock + self._resolvers = resolvers + self._unresolved: set[str] = set() + + def register(self, name: str, resolver: HandlerInstanceResolver) -> None: + """注册或替换命名实例解析器。""" + with self._lock: + self._resolvers()[name] = resolver + + def unresolved_handlers(self) -> tuple[str, ...]: + """返回本进程中未被显式 resolver 接管的类处理器。""" + with self._lock: + return tuple(sorted(self._unresolved)) + + @staticmethod + def parse_handler_names(handler: Callable) -> tuple[str, str]: + """解析处理器限定名中的类名和方法名。""" + names = handler.__qualname__.split(".") + if len(names) < 2: + return "", names[0] + return names[0], names[1] + + @staticmethod + def owner_class(handler: Callable) -> Optional[Type[Any]]: + """从处理器对象本身解析声明类,不按字符串动态导入模块。""" + if inspect.ismethod(handler): + owner = handler.__self__ + return owner if isinstance(owner, type) else type(owner) + module = inspect.getmodule(handler) + if not module: + return None + owner: Any = module + for part in handler.__qualname__.split(".")[:-1]: + if part == "": + return None + owner = getattr(owner, part, None) + if owner is None: + return None + return owner if isinstance(owner, type) else None + + def resolve( + self, + handler: Callable, + ) -> Optional[tuple[Callable, EventHandlerBinding, str, str]]: + """通过显式 resolver 解析当前实例方法;自由函数直接返回。""" + owner_class = self.owner_class(handler) + method_name = getattr( + handler, + "__name__", + self.parse_handler_names(handler)[1], + ) + if owner_class is None: + binding = EventHandlerBinding( + instance=None, + owner_name=EventRegistry.handler_identifier(handler), + run_sync_in_threadpool=True, + ) + return handler, binding, "", method_name + + with self._lock: + resolvers = tuple(self._resolvers().items()) + binding = None + resolver_name = "" + for name, resolver in resolvers: + candidate = resolver(owner_class) + if candidate is not None: + binding = candidate + resolver_name = name + break + if binding is None: + identifier = EventRegistry.handler_identifier(handler) + with self._lock: + first_miss = identifier not in self._unresolved + self._unresolved.add(identifier) + if first_miss: + logger.warning( + "事件处理器未绑定显式 resolver,已跳过:%s", + identifier, + ) + return None + logger.debug( + "事件处理器绑定:%s -> %s", + EventRegistry.handler_identifier(handler), + resolver_name, + ) + if binding.instance is None: + return None + method = getattr(binding.instance, method_name, None) + if not callable(method): + fallback_name = self.parse_handler_names(handler)[1] + method = getattr(binding.instance, fallback_name, None) + if fallback_name == method_name or not callable(method): + logger.warning( + "事件处理器 %s 无法解析为实例方法 %s.%s,跳过执行", + EventRegistry.handler_identifier(handler), + owner_class.__name__, + method_name, + ) + return None + method_name = fallback_name + return method, binding, owner_class.__name__, method_name diff --git a/app/runtime/event/dispatch.py b/app/runtime/event/dispatch.py new file mode 100644 index 000000000..31b023364 --- /dev/null +++ b/app/runtime/event/dispatch.py @@ -0,0 +1,196 @@ +"""链式和广播事件的独立调度算法。""" + +from __future__ import annotations + +import asyncio +import inspect +import time +from collections.abc import Callable +from typing import Any + +from fastapi.concurrency import run_in_threadpool + +from app.runtime.event.binding import EventBindingResolver +from app.runtime.event.registry import EventRegistry +from app.runtime.log import logger +from app.schemas.types import EventType + + +class EventDispatcher: + """基于订阅快照执行链式或广播事件,不拥有注册和生命周期状态。""" + + def __init__( + self, + *, + registry: EventRegistry, + binding_resolver: EventBindingResolver, + executor: Callable[[], Any], + event_loop: Callable[[], Any], + event_factory: Callable[..., Any], + error_handler: Callable[..., None], + ) -> None: + """注入注册表、绑定器、执行器和错误策略回调。""" + self._registry = registry + self._binding_resolver = binding_resolver + self._executor = executor + self._event_loop = event_loop + self._event_factory = event_factory + self._error_handler = error_handler + + def dispatch_chain(self, event: Any) -> bool: + """同步按优先级顺序执行链式事件快照。""" + handlers = self._registry.chain_snapshot(event.event_type) + enabled = tuple( + (handler_id, priority, handler) + for handler_id, (priority, handler) in handlers + if self._registry.is_handler_enabled(handler) + ) + if not enabled: + logger.debug("No enabled handlers found for chain event: %s", event) + return False + self._log_lifecycle(event, "Started") + for _handler_id, priority, handler in enabled: + started_at = time.time() + self.invoke_sync(handler, event) + logger.debug( + "%s (Priority: %s), completed in %.3fs for event: %s", + EventRegistry.handler_identifier(handler), + priority, + time.time() - started_at, + event, + ) + self._log_lifecycle(event, "Completed") + return True + + async def async_dispatch_chain(self, event: Any) -> bool: + """异步按优先级顺序执行链式事件快照。""" + handlers = self._registry.chain_snapshot(event.event_type) + enabled = tuple( + (handler_id, priority, handler) + for handler_id, (priority, handler) in handlers + if self._registry.is_handler_enabled(handler) + ) + if not enabled: + logger.debug("No enabled handlers found for chain event: %s", event) + return False + self._log_lifecycle(event, "Started") + for _handler_id, priority, handler in enabled: + started_at = time.time() + await self.invoke_async(handler, event) + logger.debug( + "%s (Priority: %s), completed in %.3fs for event: %s", + EventRegistry.handler_identifier(handler), + priority, + time.time() - started_at, + event, + ) + self._log_lifecycle(event, "Completed") + return True + + def dispatch_broadcast(self, event: Any) -> None: + """按订阅快照把广播事件投递到线程池或主事件循环。""" + handlers = self._registry.broadcast_snapshot(event.event_type) + if not handlers: + logger.debug("No handlers found for broadcast event: %s", event) + return + target_plugin_id = None + if event.event_type == EventType.MessageAction and isinstance( + event.event_data, + dict, + ): + target_plugin_id = event.event_data.get("__mp_target_plugin_id") + for handler_id, handler in handlers: + if target_plugin_id and not self.should_dispatch_to_target_plugin( + handler, + handler_id, + str(target_plugin_id), + ): + continue + if isinstance(event.event_data, dict): + event_data = event.event_data.copy() + event_data.pop("__mp_target_plugin_id", None) + else: + event_data = event.event_data + isolated = self._event_factory( + event_type=event.event_type, + event_data=event_data, + priority=event.priority, + ) + if inspect.iscoroutinefunction(handler): + asyncio.run_coroutine_threadsafe( + self.safe_invoke_async(handler, isolated), + self._event_loop(), + ) + else: + self._executor().submit( + self.safe_invoke_sync, + handler, + isolated, + ) + + def safe_invoke_sync(self, handler: Callable, event: Any) -> None: + """仅在处理器启用时执行同步调用。""" + if self._registry.is_handler_enabled(handler): + self.invoke_sync(handler, event) + + async def safe_invoke_async(self, handler: Callable, event: Any) -> None: + """仅在处理器启用时执行异步调用。""" + if self._registry.is_handler_enabled(handler): + await self.invoke_async(handler, event) + + def invoke_sync(self, handler: Callable, event: Any) -> None: + """解析实例绑定并同步调用处理器。""" + resolved = self._binding_resolver.resolve(handler) + if not resolved: + return + method, binding, class_name, method_name = resolved + try: + method(event) + except Exception as err: + self._error_handler( + event=event, + module_name=binding.owner_name, + class_name=class_name, + method_name=method_name, + e=err, + ) + + async def invoke_async(self, handler: Callable, event: Any) -> None: + """解析实例绑定,并按处理器类型选择协程、线程池或同步调用。""" + resolved = self._binding_resolver.resolve(handler) + if not resolved: + return + method, binding, class_name, method_name = resolved + try: + if inspect.iscoroutinefunction(method): + await method(event) + elif binding.run_sync_in_threadpool or not class_name: + await run_in_threadpool(method, event) + else: + method(event) + except Exception as err: + self._error_handler( + event=event, + module_name=binding.owner_name, + class_name=class_name, + method_name=method_name, + e=err, + ) + + @staticmethod + def should_dispatch_to_target_plugin( + handler: Callable, + handler_identifier: str, + target_plugin_id: str, + ) -> bool: + """只把定向输入事件投递给标识和声明均匹配的目标插件。""" + class_name, method_name = EventBindingResolver.parse_handler_names(handler) + if class_name != target_plugin_id: + return False + parts = (handler_identifier or "").split(".") + return len(parts) >= 2 and parts[-2:] == [class_name, method_name] + + @staticmethod + def _log_lifecycle(event: Any, stage: str) -> None: + """记录事件调度的开始和完成阶段。""" + logger.debug("%s - %s", stage, event) diff --git a/app/runtime/event/errors.py b/app/runtime/event/errors.py new file mode 100644 index 000000000..5d2e9bf16 --- /dev/null +++ b/app/runtime/event/errors.py @@ -0,0 +1,65 @@ +"""事件处理异常的通知、降级和递归保护策略。""" + +from __future__ import annotations + +import traceback +from collections.abc import Callable +from typing import Any, Optional + +from app.runtime.log import logger +from app.schemas.types import EventType + + +EventErrorNotifier = Callable[[str, str], object] + + +class EventErrorPolicy: + """隔离处理器异常,并阻止 SystemError 处理失败再次广播。""" + + def __init__( + self, + *, + notifier: Callable[[], Optional[EventErrorNotifier]], + emit_system_error: Callable[[dict], object], + ) -> None: + """注入通知读取器和 SystemError 发送回调。""" + self._notifier = notifier + self._emit_system_error = emit_system_error + + def handle( + self, + *, + event: Any, + module_name: str, + class_name: str, + method_name: str, + error: Exception, + ) -> None: + """记录并通知异常;SystemError 自身失败时只降级写日志。""" + trace = traceback.format_exc() + logger.error("%s 事件处理出错:%s - %s", module_name, str(error), trace) + notifier = self._notifier() + if notifier: + try: + notifier( + f"{module_name} 处理事件 {event.event_type} 时出错", + f"{class_name}.{method_name}:{str(error)}", + ) + except Exception as notify_error: + logger.error("发送事件错误通知失败:%s", str(notify_error)) + if event.event_type == EventType.SystemError: + logger.error( + "SystemError 处理器再次失败,停止错误事件递归广播:%s.%s", + class_name, + method_name, + ) + return + self._emit_system_error( + { + "type": "event", + "event_type": event.event_type, + "event_handle": f"{class_name}.{method_name}", + "error": str(error), + "traceback": trace, + } + ) diff --git a/app/runtime/event/registry.py b/app/runtime/event/registry.py new file mode 100644 index 000000000..554145dbd --- /dev/null +++ b/app/runtime/event/registry.py @@ -0,0 +1,200 @@ +"""事件订阅、禁用状态和调度快照注册表。""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import Any + +from app.runtime.log import logger +from app.schemas.types import ChainEventType, EventType + + +class EventRegistry: + """集中管理事件处理器注册、启停和不可变调度快照。""" + + def __init__( + self, + *, + lock: Any, + broadcast_subscribers: Callable[[], dict], + chain_subscribers: Callable[[], dict], + disabled_handlers: Callable[[], set], + disabled_classes: Callable[[], set], + ) -> None: + """绑定由兼容门面持有的存储,便于热重载和旧测试替换快照。""" + self._lock = lock + self._broadcast_subscribers = broadcast_subscribers + self._chain_subscribers = chain_subscribers + self._disabled_handlers = disabled_handlers + self._disabled_classes = disabled_classes + + @staticmethod + def handler_identifier(target: Callable | type) -> str: + """返回包含模块和限定名的稳定处理器标识。""" + module = inspect.getmodule(target) + module_name = module.__name__ if module else "unknown_module" + return f"{module_name}.{target.__qualname__}" + + @classmethod + def handler_class_identifier(cls, handler: Callable) -> str | None: + """返回可调用对象所属类的稳定标识;自由函数返回空值。""" + if inspect.ismethod(handler) and hasattr(handler, "__self__"): + return cls.handler_identifier(handler.__self__.__class__) + if not inspect.isfunction(handler) and hasattr(handler, "__call__"): + return cls.handler_identifier(handler.__class__) + qualname_parts = handler.__qualname__.split(".") + if len(qualname_parts) <= 1: + return None + module = inspect.getmodule(handler) + module_name = module.__name__ if module else "unknown_module" + return f"{module_name}.{'.'.join(qualname_parts[:-1])}" + + def is_handler_enabled(self, handler: Callable) -> bool: + """判断处理器及其所属类是否均处于启用状态。""" + handler_id = self.handler_identifier(handler) + class_id = self.handler_class_identifier(handler) + return not ( + handler_id in self._disabled_handlers() + or ( + class_id is not None + and class_id in self._disabled_classes() + ) + ) + + def check(self, event_type: EventType | ChainEventType) -> bool: + """检查指定事件是否存在启用的处理器。""" + if isinstance(event_type, ChainEventType): + handlers = self._chain_subscribers().get(event_type, {}) + return any( + self.is_handler_enabled(handler) + for _, handler in handlers.values() + ) + handlers = self._broadcast_subscribers().get(event_type, {}) + return any(self.is_handler_enabled(handler) for handler in handlers.values()) + + def add( + self, + event_type: EventType | ChainEventType, + handler: Callable, + priority: int, + ) -> None: + """注册处理器,并为链式事件按优先级维护稳定顺序。""" + with self._lock: + handler_id = self.handler_identifier(handler) + if isinstance(event_type, ChainEventType): + subscribers = self._chain_subscribers() + handlers = subscribers.setdefault(event_type, {}) + existed = handler_id in handlers + handlers.pop(handler_id, None) + if not existed: + logger.debug( + "Subscribed to chain event: %s, Priority: %s - %s", + event_type.value, + priority, + handler_id, + ) + handlers[handler_id] = (priority, handler) + subscribers[event_type] = dict( + sorted(handlers.items(), key=lambda item: item[1][0]) + ) + return + subscribers = self._broadcast_subscribers() + handlers = subscribers.setdefault(event_type, {}) + existed = handler_id in handlers + handlers.pop(handler_id, None) + if not existed: + logger.debug( + "Subscribed to broadcast event: %s - %s", + event_type.value, + handler_id, + ) + handlers[handler_id] = handler + + def remove( + self, + event_type: EventType | ChainEventType, + handler: Callable, + ) -> None: + """从指定事件中移除处理器。""" + with self._lock: + handler_id = self.handler_identifier(handler) + if isinstance(event_type, ChainEventType): + self._chain_subscribers().get(event_type, {}).pop( + handler_id, + None, + ) + logger.debug( + "Unsubscribed from chain event: %s - %s", + event_type.value, + handler_id, + ) + return + self._broadcast_subscribers().get(event_type, {}).pop( + handler_id, + None, + ) + logger.debug( + "Unsubscribed from broadcast event: %s - %s", + event_type.value, + handler_id, + ) + + def disable(self, target: Callable | type) -> None: + """禁用单个处理器或整个处理器类。""" + identifier = self.handler_identifier(target) + if isinstance(target, type): + self._disabled_classes().add(identifier) + logger.debug("Disabled event handler class - %s", identifier) + else: + self._disabled_handlers().add(identifier) + logger.debug("Disabled event handler - %s", identifier) + + def enable(self, target: Callable | type) -> None: + """重新启用单个处理器或整个处理器类。""" + identifier = self.handler_identifier(target) + if isinstance(target, type): + self._disabled_classes().discard(identifier) + logger.debug("Enabled event handler class - %s", identifier) + else: + self._disabled_handlers().discard(identifier) + logger.debug("Enabled event handler - %s", identifier) + + def chain_snapshot(self, event_type: ChainEventType) -> tuple: + """返回当前链式订阅快照,运行期变更从下一次事件生效。""" + with self._lock: + return tuple(self._chain_subscribers().get(event_type, {}).items()) + + def broadcast_snapshot(self, event_type: EventType) -> tuple: + """返回当前广播订阅快照,运行期变更从下一次事件生效。""" + with self._lock: + return tuple( + self._broadcast_subscribers().get(event_type, {}).items() + ) + + def visualize(self) -> list[dict]: + """导出所有订阅处理器的事件、优先级和启停状态。""" + result = [] + combined = { + **self._broadcast_subscribers(), + **self._chain_subscribers(), + } + for event_type, subscribers in combined.items(): + for handler_id, handler_data in subscribers.items(): + if isinstance(handler_data, tuple) and len(handler_data) == 2: + priority, handler = handler_data + else: + priority, handler = None, handler_data + item = { + "event_type": event_type.value, + "handler_identifier": handler_id, + "status": ( + "enabled" + if self.is_handler_enabled(handler) + else "disabled" + ), + } + if priority is not None: + item["priority"] = priority + result.append(item) + return result diff --git a/app/runtime/events.py b/app/runtime/events.py index 983ca1ec4..d8e5620ec 100644 --- a/app/runtime/events.py +++ b/app/runtime/events.py @@ -1,23 +1,25 @@ -import asyncio -import inspect import random import threading -import time import traceback import uuid -from dataclasses import dataclass from queue import Empty, PriorityQueue from typing import Callable, Dict, List, Optional, Tuple, Union, Any, Type -from fastapi.concurrency import run_in_threadpool - from app.runtime.config import global_vars from app.runtime.thread import ThreadHelper from app.runtime.log import logger -from app.schemas import ChainEventData +from app.schemas.event import ChainEventData from app.schemas.types import ChainEventType, EventType from app.runtime.rate import ExponentialBackoffRateLimiter from app.foundation.singleton import Singleton +from app.runtime.event.binding import ( + EventBindingResolver, + EventHandlerBinding, + HandlerInstanceResolver, +) +from app.runtime.event.dispatch import EventDispatcher +from app.runtime.event.errors import EventErrorNotifier, EventErrorPolicy +from app.runtime.event.registry import EventRegistry DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级 MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数 @@ -25,21 +27,6 @@ INITIAL_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 1 # 事件队列空闲时的初始 MAX_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 5 # 事件队列空闲时的最大超时时间(秒) -@dataclass(frozen=True, slots=True) -class EventHandlerBinding: - """描述上层运行时为某个事件处理器提供的实例绑定。""" - - instance: Optional[Any] - owner_name: str - run_sync_in_threadpool: bool = False - - -HandlerInstanceResolver = Callable[ - [Type[Any]], Optional[EventHandlerBinding] -] -EventErrorNotifier = Callable[[str, str], object] - - class Event: """ 事件类,封装事件的基本信息 @@ -111,6 +98,32 @@ class EventManager(metaclass=Singleton): self.__handler_instance_resolvers: Dict[str, HandlerInstanceResolver] = {} # 由启动组合层注入的错误通知回调 self.__error_notifier: Optional[EventErrorNotifier] = None + self.__registry = EventRegistry( + lock=self.__lock, + broadcast_subscribers=lambda: self.__broadcast_subscribers, + chain_subscribers=lambda: self.__chain_subscribers, + disabled_handlers=lambda: self.__disabled_handlers, + disabled_classes=lambda: self.__disabled_classes, + ) + self.__binding_resolver = EventBindingResolver( + lock=self.__lock, + resolvers=lambda: self.__handler_instance_resolvers, + ) + self.__error_policy = EventErrorPolicy( + notifier=lambda: self.__error_notifier, + emit_system_error=lambda payload: self.send_event( + EventType.SystemError, + payload, + ), + ) + self.__dispatcher = EventDispatcher( + registry=self.__registry, + binding_resolver=self.__binding_resolver, + executor=lambda: self.__executor, + event_loop=lambda: global_vars.loop, + event_factory=Event, + error_handler=lambda **kwargs: self.__handle_event_error(**kwargs), + ) def register_handler_instance_resolver( self, @@ -122,8 +135,11 @@ class EventManager(metaclass=Singleton): 同名解析器会被替换,避免测试重建单例或热重载后保留旧实例引用。 """ - with self.__lock: - self.__handler_instance_resolvers[name] = resolver + self.__binding_resolver.register(name, resolver) + + def unresolved_handler_bindings(self) -> tuple[str, ...]: + """返回未命中显式 resolver 的类处理器诊断清单。""" + return self.__binding_resolver.unresolved_handlers() def set_error_notifier(self, notifier: Optional[EventErrorNotifier]) -> None: """设置事件处理异常的外部通知回调。""" @@ -161,18 +177,7 @@ class EventManager(metaclass=Singleton): :param etype: 事件类型 (EventType 或 ChainEventType) :return: 返回是否存在可用的处理器 """ - if isinstance(etype, ChainEventType): - handlers = self.__chain_subscribers.get(etype, {}) - return any( - self.__is_handler_enabled(handler) - for _, handler in handlers.values() - ) - else: - handlers = self.__broadcast_subscribers.get(etype, {}) - return any( - self.__is_handler_enabled(handler) - for handler in handlers.values() - ) + return self.__registry.check(etype) def send_event(self, etype: Union[EventType, ChainEventType], data: Optional[Union[Dict, ChainEventData]] = None, priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]: @@ -219,35 +224,7 @@ class EventManager(metaclass=Singleton): :param handler: 处理器 :param priority: 可选,链式事件的优先级,默认为 10;广播事件不需要优先级 """ - with self.__lock: - handler_identifier = self.__get_handler_identifier(handler) - - if isinstance(event_type, ChainEventType): - # 链式事件,按优先级排序 - if event_type not in self.__chain_subscribers: - self.__chain_subscribers[event_type] = {} - handlers = self.__chain_subscribers[event_type] - if handler_identifier in handlers: - handlers.pop(handler_identifier) - else: - logger.debug( - f"Subscribed to chain event: {event_type.value}, " - f"Priority: {priority} - {handler_identifier}") - handlers[handler_identifier] = (priority, handler) - # 根据优先级排序 - self.__chain_subscribers[event_type] = dict( - sorted(self.__chain_subscribers[event_type].items(), key=lambda x: x[1][0]) - ) - else: - # 广播事件 - if event_type not in self.__broadcast_subscribers: - self.__broadcast_subscribers[event_type] = {} - handlers = self.__broadcast_subscribers[event_type] - if handler_identifier in handlers: - handlers.pop(handler_identifier) - else: - logger.debug(f"Subscribed to broadcast event: {event_type.value} - {handler_identifier}") - handlers[handler_identifier] = handler + self.__registry.add(event_type, handler, priority or DEFAULT_EVENT_PRIORITY) def remove_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable): """ @@ -255,43 +232,21 @@ class EventManager(metaclass=Singleton): :param event_type: 事件类型 (EventType 或 ChainEventType) :param handler: 要移除的处理器 """ - with self.__lock: - handler_identifier = self.__get_handler_identifier(handler) - - if isinstance(event_type, ChainEventType) and event_type in self.__chain_subscribers: - self.__chain_subscribers[event_type].pop(handler_identifier, None) - logger.debug(f"Unsubscribed from chain event: {event_type.value} - {handler_identifier}") - elif event_type in self.__broadcast_subscribers: - self.__broadcast_subscribers[event_type].pop(handler_identifier, None) - logger.debug(f"Unsubscribed from broadcast event: {event_type.value} - {handler_identifier}") + self.__registry.remove(event_type, handler) def disable_event_handler(self, target: Union[Callable, type]): """ 禁用指定的事件处理器或事件处理器类 :param target: 处理器函数或类 """ - identifier = self.__get_handler_identifier(target) - if identifier in self.__disabled_handlers or identifier in self.__disabled_classes: - return - if isinstance(target, type): - self.__disabled_classes.add(identifier) - logger.debug(f"Disabled event handler class - {identifier}") - else: - self.__disabled_handlers.add(identifier) - logger.debug(f"Disabled event handler - {identifier}") + self.__registry.disable(target) def enable_event_handler(self, target: Union[Callable, type]): """ 启用指定的事件处理器或事件处理器类 :param target: 处理器函数或类 """ - identifier = self.__get_handler_identifier(target) - if isinstance(target, type): - self.__disabled_classes.discard(identifier) - logger.debug(f"Enabled event handler class - {identifier}") - else: - self.__disabled_handlers.discard(identifier) - logger.debug(f"Enabled event handler - {identifier}") + self.__registry.enable(target) def visualize_handlers(self) -> List[Dict]: """ @@ -299,34 +254,7 @@ class EventManager(metaclass=Singleton): :return: 处理器列表,包含事件类型、处理器标识符、优先级(如果有)和状态 """ - def parse_handler_data(data): - """ - 解析处理器数据,判断是否包含优先级 - :param data: 订阅者数据,可能是元组或单一值 - :return: (priority, handler),若没有优先级则返回 (None, handler) - """ - if isinstance(data, tuple) and len(data) == 2: - return data - return None, data - - handler_info = [] - # 统一处理广播事件和链式事件 - for event_type, subscribers in {**self.__broadcast_subscribers, **self.__chain_subscribers}.items(): - for handler_identifier, handler_data in subscribers.items(): - # 解析优先级和处理器 - priority, handler = parse_handler_data(handler_data) - # 检查处理器的启用状态 - status = "enabled" if self.__is_handler_enabled(handler) else "disabled" - # 构建处理器信息字典 - handler_dict = { - "event_type": event_type.value, - "handler_identifier": handler_identifier, - "status": status - } - if priority is not None: - handler_dict["priority"] = priority - handler_info.append(handler_dict) - return handler_info + return self.__registry.visualize() @classmethod def __get_handler_identifier(cls, target: Union[Callable, type]) -> Optional[str]: @@ -335,13 +263,7 @@ class EventManager(metaclass=Singleton): :param target: 处理器函数或类 :return: 唯一标识符 """ - # 统一使用 inspect.getmodule 来获取模块名 - module = inspect.getmodule(target) - module_name = module.__name__ if module else "unknown_module" - - # 使用 __qualname__ 获取目标的限定名 - qualname = target.__qualname__ - return f"{module_name}.{qualname}" + return EventRegistry.handler_identifier(target) @classmethod def __get_class_from_callable(cls, handler: Callable) -> Optional[str]: @@ -350,23 +272,7 @@ class EventManager(metaclass=Singleton): :param handler: 可调用对象(函数、方法等) :return: 类的唯一标识符 """ - # 对于绑定方法,通过 __self__.__class__ 获取类 - if inspect.ismethod(handler) and hasattr(handler, "__self__"): - return cls.__get_handler_identifier(handler.__self__.__class__) - - # 对于类实例(实现了 __call__ 方法) - if not inspect.isfunction(handler) and hasattr(handler, "__call__"): - handler_cls = handler.__class__ # noqa - return cls.__get_handler_identifier(handler_cls) - - # 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息 - qualname_parts = handler.__qualname__.split(".") - if len(qualname_parts) > 1: - class_name = ".".join(qualname_parts[:-1]) - module = inspect.getmodule(handler) - module_name = module.__name__ if module else "unknown_module" - return f"{module_name}.{class_name}" - return None + return EventRegistry.handler_class_identifier(handler) def __is_handler_enabled(self, handler: Callable) -> bool: """ @@ -374,17 +280,7 @@ class EventManager(metaclass=Singleton): :param handler: 处理器函数 :return: 如果处理器启用则返回 True,否则返回 False """ - # 获取处理器的唯一标识符 - handler_id = self.__get_handler_identifier(handler) - - # 获取处理器所属类的唯一标识符 - class_id = self.__get_class_from_callable(handler) - - # 检查处理器或类是否被禁用,只要其中之一被禁用则返回 False - if handler_id in self.__disabled_handlers or (class_id is not None and class_id in self.__disabled_classes): - return False - - return True + return self.__registry.is_handler_enabled(handler) def __trigger_chain_event(self, event: Event) -> Optional[Event]: """ @@ -415,113 +311,21 @@ class EventManager(metaclass=Singleton): 同步方式调度链式事件,按优先级顺序逐个调用事件处理器,并记录每个处理器的处理时间 :param event: 要调度的事件对象 """ - # 运行期可以动态注册或移除处理器;当前事件始终使用调度开始时的快照。 - with self.__lock: - handlers = tuple( - self.__chain_subscribers.get(event.event_type, {}).items() - ) - if not handlers: - logger.debug(f"No handlers found for chain event: {event}") - return False - - # 过滤出启用的处理器 - enabled_handlers = tuple( - (handler_id, priority, handler) - for handler_id, (priority, handler) in handlers - if self.__is_handler_enabled(handler) - ) - - if not enabled_handlers: - logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.") - return False - - self.__log_event_lifecycle(event, "Started") - for handler_id, priority, handler in enabled_handlers: - start_time = time.time() - self.__safe_invoke_handler(handler, event) - logger.debug( - f"{self.__get_handler_identifier(handler)} (Priority: {priority}), " - f"completed in {time.time() - start_time:.3f}s for event: {event}" - ) - self.__log_event_lifecycle(event, "Completed") - return True + return self.__dispatcher.dispatch_chain(event) async def __dispatch_chain_event_async(self, event: Event) -> bool: """ 异步方式调度链式事件,按优先级顺序逐个调用事件处理器,并记录每个处理器的处理时间 :param event: 要调度的事件对象 """ - # 快照在锁内建立、在锁外执行,处理器可以安全地修改后续订阅。 - with self.__lock: - handlers = tuple( - self.__chain_subscribers.get(event.event_type, {}).items() - ) - if not handlers: - logger.debug(f"No handlers found for chain event: {event}") - return False - - # 过滤出启用的处理器 - enabled_handlers = tuple( - (handler_id, priority, handler) - for handler_id, (priority, handler) in handlers - if self.__is_handler_enabled(handler) - ) - - if not enabled_handlers: - logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.") - return False - - self.__log_event_lifecycle(event, "Started") - for handler_id, priority, handler in enabled_handlers: - start_time = time.time() - await self.__safe_invoke_handler_async(handler, event) - logger.debug( - f"{self.__get_handler_identifier(handler)} (Priority: {priority}), " - f"completed in {time.time() - start_time:.3f}s for event: {event}" - ) - self.__log_event_lifecycle(event, "Completed") - return True + return await self.__dispatcher.async_dispatch_chain(event) def __dispatch_broadcast_event(self, event: Event): """ 异步方式调度广播事件,通过线程池逐个调用事件处理器 :param event: 要调度的事件对象 """ - # 快照隔离当前调度与运行期订阅变更;变更从下一个事件开始生效。 - with self.__lock: - handlers = tuple( - self.__broadcast_subscribers.get(event.event_type, {}).items() - ) - if not handlers: - logger.debug(f"No handlers found for broadcast event: {event}") - return - target_plugin_id = None - if event.event_type == EventType.MessageAction and isinstance(event.event_data, dict): - target_plugin_id = event.event_data.get("__mp_target_plugin_id") - # 为每个处理器提供独立的事件实例,防止某个处理器对 event_data 的修改影响其他处理器 - for handler_id, handler in handlers: - if target_plugin_id and not self.__should_dispatch_to_target_plugin( - handler, handler_id, str(target_plugin_id) - ): - continue - # 仅浅拷贝顶层字典,避免不必要的深拷贝开销;这样可以隔离键级别的替换/赋值 - if isinstance(event.event_data, dict): - event_data_copy = event.event_data.copy() - event_data_copy.pop("__mp_target_plugin_id", None) - else: - event_data_copy = event.event_data - isolated_event = Event(event_type=event.event_type, - event_data=event_data_copy, - priority=event.priority) - if inspect.iscoroutinefunction(handler): - # 对于异步函数,直接在事件循环中运行 - asyncio.run_coroutine_threadsafe( - self.__safe_invoke_handler_async(handler, isolated_event), - global_vars.loop - ) - else: - # 对于同步函数,在线程池中运行 - self.__executor.submit(self.__safe_invoke_handler, handler, isolated_event) + self.__dispatcher.dispatch_broadcast(event) @classmethod def __should_dispatch_to_target_plugin( @@ -533,23 +337,11 @@ class EventManager(metaclass=Singleton): """ 限定插件输入事件只投递给目标插件,避免自由文本被其他插件观察到。 """ - class_name, method_name = cls.__parse_handler_names(handler) - if class_name != target_plugin_id: - return False - identifier_parts = (handler_identifier or "").split(".") - if len(identifier_parts) < 2: - logger.debug( - "Target plugin dispatch skipped because handler identifier is invalid: " - f"target={target_plugin_id}, handler={handler_identifier}" - ) - return False - if identifier_parts[-2:] != [class_name, method_name]: - logger.debug( - "Target plugin dispatch skipped because handler identifier does not match handler: " - f"target={target_plugin_id}, handler={handler_identifier}, parsed={class_name}.{method_name}" - ) - return False - return True + return EventDispatcher.should_dispatch_to_target_plugin( + handler, + handler_identifier, + target_plugin_id, + ) def __safe_invoke_handler(self, handler: Callable, event: Event): """ @@ -557,11 +349,7 @@ class EventManager(metaclass=Singleton): :param handler: 处理器 :param event: 事件对象 """ - if not self.__is_handler_enabled(handler): - logger.debug(f"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution") - return - - self.__invoke_handler_by_type_sync(handler, event) + self.__dispatcher.safe_invoke_sync(handler, event) async def __safe_invoke_handler_async(self, handler: Callable, event: Event): """ @@ -569,11 +357,7 @@ class EventManager(metaclass=Singleton): :param handler: 处理器 :param event: 事件对象 """ - if not self.__is_handler_enabled(handler): - logger.debug(f"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution") - return - - await self.__invoke_handler_by_type_async(handler, event) + await self.__dispatcher.safe_invoke_async(handler, event) def __invoke_handler_by_type_sync(self, handler: Callable, event: Event): """ @@ -581,20 +365,7 @@ class EventManager(metaclass=Singleton): :param handler: 处理器 :param event: 要处理的事件对象 """ - resolved = self.__resolve_handler(handler) - if not resolved: - return - method, binding, class_name, method_name = resolved - try: - method(event) - except Exception as e: - self.__handle_event_error( - event=event, - module_name=binding.owner_name, - class_name=class_name, - method_name=method_name, - e=e, - ) + self.__dispatcher.invoke_sync(handler, event) async def __invoke_handler_by_type_async(self, handler: Callable, event: Event): """ @@ -602,25 +373,7 @@ class EventManager(metaclass=Singleton): :param handler: 处理器 :param event: 要处理的事件对象 """ - resolved = self.__resolve_handler(handler) - if not resolved: - return - method, binding, class_name, method_name = resolved - try: - if inspect.iscoroutinefunction(method): - await method(event) - elif binding.run_sync_in_threadpool or not class_name: - await run_in_threadpool(method, event) - else: - method(event) - except Exception as e: - self.__handle_event_error( - event=event, - module_name=binding.owner_name, - class_name=class_name, - method_name=method_name, - e=e, - ) + await self.__dispatcher.invoke_async(handler, event) @staticmethod def __parse_handler_names(handler: Callable) -> Tuple[str, str]: @@ -629,82 +382,19 @@ class EventManager(metaclass=Singleton): :param handler: 处理器 :return: (class_name, method_name) """ - names = handler.__qualname__.split(".") - if len(names) < 2: - return "", names[0] - return names[0], names[1] + return EventBindingResolver.parse_handler_names(handler) @staticmethod def __get_handler_owner_class(handler: Callable) -> Optional[Type[Any]]: """从处理器对象本身解析声明它的类,不按命名约定动态导入模块。""" - if inspect.ismethod(handler): - owner = handler.__self__ - return owner if isinstance(owner, type) else type(owner) - module = inspect.getmodule(handler) - if not module: - return None - owner: Any = module - for part in handler.__qualname__.split(".")[:-1]: - if part == "": - return None - owner = getattr(owner, part, None) - if owner is None: - return None - return owner if isinstance(owner, type) else None + return EventBindingResolver.owner_class(handler) def __resolve_handler( self, handler: Callable, ) -> Optional[Tuple[Callable, EventHandlerBinding, str, str]]: """将装饰阶段保存的函数解析为当前运行实例上的可调用方法。""" - owner_class = self.__get_handler_owner_class(handler) - method_name = getattr(handler, "__name__", self.__parse_handler_names(handler)[1]) - if owner_class is None: - binding = EventHandlerBinding( - instance=None, - owner_name=self.__get_handler_identifier(handler), - run_sync_in_threadpool=True, - ) - return handler, binding, "", method_name - - with self.__lock: - resolvers = list(self.__handler_instance_resolvers.values()) - binding = next( - (result for resolver in resolvers if (result := resolver(owner_class)) is not None), - None, - ) - if binding is None: - try: - get_existing = getattr(owner_class, "get_existing_instance", None) - instance = get_existing() if callable(get_existing) else None - if instance is None: - instance = owner_class() - binding = EventHandlerBinding( - instance=instance, - owner_name=owner_class.__name__, - ) - except Exception as e: - logger.error( - f"事件处理出错:创建 {owner_class.__name__} 实例失败:" - f"{str(e)} - {traceback.format_exc()}" - ) - return None - if binding.instance is None: - return None - method = getattr(binding.instance, method_name, None) - if not callable(method): - # 动态生成的处理器可能只同步了 __qualname__,__name__ 与类上方法名不一致时 - # 回退到限定名末段重试;仍无法解析时记录告警,避免静默跳过 - fallback_name = self.__parse_handler_names(handler)[1] - method = getattr(binding.instance, fallback_name, None) - if fallback_name == method_name or not callable(method): - logger.warning( - f"事件处理器 {self.__get_handler_identifier(handler)} " - f"无法解析为实例方法 {owner_class.__name__}.{method_name},跳过执行" - ) - return None - method_name = fallback_name - return method, binding, owner_class.__name__, method_name + return self.__binding_resolver.resolve(handler) def __broadcast_consumer_loop(self): """ @@ -737,28 +427,12 @@ class EventManager(metaclass=Singleton): """ 全局错误处理器,用于处理事件处理中的异常 """ - logger.error(f"{module_name} 事件处理出错:{str(e)} - {traceback.format_exc()}") - - # 消息实现由启动组合层注入,事件总线不反向依赖消息模块。 - with self.__lock: - notifier = self.__error_notifier - if notifier: - try: - notifier( - f"{module_name} 处理事件 {event.event_type} 时出错", - f"{class_name}.{method_name}:{str(e)}", - ) - except Exception as notify_error: - logger.error(f"发送事件错误通知失败:{str(notify_error)}") - self.send_event( - EventType.SystemError, - { - "type": "event", - "event_type": event.event_type, - "event_handle": f"{class_name}.{method_name}", - "error": str(e), - "traceback": traceback.format_exc() - } + self.__error_policy.handle( + event=event, + module_name=module_name, + class_name=class_name, + method_name=method_name, + error=e, ) def register(self, etype: Union[EventType, ChainEventType, List[Union[EventType, ChainEventType]], type], @@ -797,5 +471,9 @@ class EventManager(metaclass=Singleton): return decorator -# 全局实例定义 +# 模块热重载时类对象会重新创建,但插件和 SDK 可能仍持有旧全局实例。把旧实例登记到 +# 新 EventManager 类的单例键,确保所有公开入口继续共享同一个事件总线。 +_existing_eventmanager = globals().get("eventmanager") +if _existing_eventmanager is not None: + Singleton._instances[(EventManager, (), frozenset())] = _existing_eventmanager eventmanager = EventManager() diff --git a/app/runtime/execution.py b/app/runtime/execution.py index 39640b6a1..059259a8c 100644 --- a/app/runtime/execution.py +++ b/app/runtime/execution.py @@ -4,7 +4,7 @@ import time from functools import wraps from typing import Any, Callable -from app.schemas import ImmediateException +from app.schemas.exception import ImmediateException def retry(ExceptionToCheck: Any, diff --git a/app/runtime/extensions/module/__init__.py b/app/runtime/extensions/module/__init__.py new file mode 100644 index 000000000..770454cb6 --- /dev/null +++ b/app/runtime/extensions/module/__init__.py @@ -0,0 +1 @@ +"""模块调用契约与调度实现。""" diff --git a/app/runtime/extensions/module/contracts.py b/app/runtime/extensions/module/contracts.py new file mode 100644 index 000000000..1d62cd910 --- /dev/null +++ b/app/runtime/extensions/module/contracts.py @@ -0,0 +1,82 @@ +"""字符串模块方法协议的可检查契约清单。""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class ModuleResultAggregation(StrEnum): + """描述多模块结果沿调用链的兼容聚合方式。""" + + LEGACY = "legacy" + + +@dataclass(frozen=True, slots=True) +class ModuleMethodContract: + """记录一个模块方法族的调用模式和结果规则。""" + + family: str + aggregation: ModuleResultAggregation = ModuleResultAggregation.LEGACY + supports_sync: bool = True + supports_async: bool = True + plugin_short_circuit: bool = True + + +_DEFAULT_CONTRACT = ModuleMethodContract(family="legacy") + +# 首批登记高频能力族。方法名仍保持开放字符串,以兼容第三方插件自定义模块能力; +# 未命中项继续使用冻结的 legacy 规则,并由架构快照记录新增调用位置。 +_METHOD_CONTRACTS = { + "recognize_media": ModuleMethodContract(family="media-recognition"), + "search_medias": ModuleMethodContract(family="media-recognition"), + "obtain_images": ModuleMethodContract(family="media-recognition"), + "media_category": ModuleMethodContract(family="media-recognition"), + "mediaserver_items": ModuleMethodContract(family="media-server"), + "mediaserver_iteminfo": ModuleMethodContract(family="media-server"), + "mediaserver_play_url": ModuleMethodContract(family="media-server"), + "mediaserver_tv_episodes": ModuleMethodContract(family="media-server"), + "download_file": ModuleMethodContract(family="storage"), + "upload_file": ModuleMethodContract(family="storage"), + "list_files": ModuleMethodContract(family="storage"), + "get_file_item": ModuleMethodContract(family="storage"), + "get_folder": ModuleMethodContract(family="storage"), + "get_parent_item": ModuleMethodContract(family="storage"), + "rename_file": ModuleMethodContract(family="storage"), + "storage_manage": ModuleMethodContract(family="storage"), + "snapshot_storage": ModuleMethodContract(family="storage"), + "send_message": ModuleMethodContract(family="messaging"), + "finalize_message": ModuleMethodContract(family="messaging"), + "register_commands": ModuleMethodContract(family="messaging"), + "scheduler_job": ModuleMethodContract(family="scheduling"), + "webhook_parser": ModuleMethodContract(family="integration"), +} + +_PREFIX_CONTRACTS = ( + ("async_tmdb_", ModuleMethodContract(family="tmdb")), + ("tmdb_", ModuleMethodContract(family="tmdb")), + ("async_douban_", ModuleMethodContract(family="douban")), + ("douban_", ModuleMethodContract(family="douban")), + ("async_bangumi_", ModuleMethodContract(family="bangumi")), + ("bangumi_", ModuleMethodContract(family="bangumi")), + ("async_anilist_", ModuleMethodContract(family="anilist")), + ("anilist_", ModuleMethodContract(family="anilist")), + ("tvdb_", ModuleMethodContract(family="tvdb")), + ("music_", ModuleMethodContract(family="music")), + ("torrent_", ModuleMethodContract(family="downloader")), +) + + +def get_module_method_contract(method: str) -> ModuleMethodContract: + """返回方法的显式能力族契约,未知方法保持既有 legacy 协议。""" + if contract := _METHOD_CONTRACTS.get(method): + return contract + for prefix, contract in _PREFIX_CONTRACTS: + if method.startswith(prefix): + return contract + return _DEFAULT_CONTRACT + + +def is_explicit_module_method(method: str) -> bool: + """判断方法是否已进入首批显式能力族清单。""" + return get_module_method_contract(method) is not _DEFAULT_CONTRACT diff --git a/app/runtime/extensions/module/dispatcher.py b/app/runtime/extensions/module/dispatcher.py new file mode 100644 index 000000000..b586942d2 --- /dev/null +++ b/app/runtime/extensions/module/dispatcher.py @@ -0,0 +1,285 @@ +"""宿主模块与插件模块的统一调用算法。""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable, Mapping +from typing import Any, Protocol + +from fastapi.concurrency import run_in_threadpool + +from app.foundation.reflection import ObjectUtils +from app.runtime.log import logger +from app.runtime.extensions.module.contracts import get_module_method_contract +from app.schemas.exception import RateLimitExceededException + + +class ModuleCatalog(Protocol): + """声明模块调度器消费的最小模块目录能力。""" + + def get_running_modules(self, method: str) -> Any: + """返回实现指定方法的运行中宿主模块。""" + + +class PluginModuleCatalog(Protocol): + """声明模块调度器消费的最小插件模块目录能力。""" + + def get_plugin_modules( + self, + ) -> Mapping[tuple[str, str], Mapping[str, Callable[..., Any]]]: + """返回插件标识到模块方法表的当前快照。""" + + +ModuleErrorHandler = Callable[..., None] +AsyncFunctionRunner = Callable[..., Any] + + +class ModuleInvocationDispatcher: + """按既有聚合、短路和异常规则执行插件与宿主模块。""" + + def __init__( + self, + *, + module_catalog: ModuleCatalog, + plugin_catalog: PluginModuleCatalog, + plugin_error_handler: ModuleErrorHandler, + system_error_handler: ModuleErrorHandler, + rate_limit_handler: ModuleErrorHandler, + async_function_runner: AsyncFunctionRunner = run_in_threadpool, + ) -> None: + """保存模块目录和策略回调,不主动发现或创建任何运行时资源。""" + self._module_catalog = module_catalog + self._plugin_catalog = plugin_catalog + self._plugin_error_handler = plugin_error_handler + self._system_error_handler = system_error_handler + self._rate_limit_handler = rate_limit_handler + self._async_function_runner = async_function_runner + + @staticmethod + def is_valid_empty(result: Any) -> bool: + """保持旧协议中 ``None`` 与全 ``None`` 元组的空结果定义。""" + if isinstance(result, tuple): + return all(value is None for value in result) + return result is None + + def dispatch(self, method: str, *args: Any, **kwargs: Any) -> Any: + """先执行插件模块,再按优先级执行宿主模块。""" + contract = get_module_method_contract(method) + logger.debug("模块方法契约:%s -> %s", method, contract.family) + result = self.execute_plugin_modules(method, None, *args, **kwargs) + if not self.is_valid_empty(result) and not isinstance(result, list): + return result + return self.execute_system_modules(method, result, *args, **kwargs) + + async def async_dispatch(self, method: str, *args: Any, **kwargs: Any) -> Any: + """以与同步路径相同的聚合规则执行同步或异步模块方法。""" + contract = get_module_method_contract(method) + logger.debug("异步模块方法契约:%s -> %s", method, contract.family) + result = await self.async_execute_plugin_modules( + method, + None, + *args, + **kwargs, + ) + if not self.is_valid_empty(result) and not isinstance(result, list): + return result + return await self.async_execute_system_modules( + method, + result, + *args, + **kwargs, + ) + + def execute_plugin_modules( + self, + method: str, + result: Any, + *args: Any, + **kwargs: Any, + ) -> Any: + """同步执行插件方法,保留插件顺序、短路和列表合并语义。""" + for plugin, module_dict in self._plugin_catalog.get_plugin_modules().items(): + plugin_id, plugin_name = plugin + func = module_dict.get(method) + if not func: + continue + try: + logger.info("请求插件 %s 执行:%s ...", plugin_name, method) + if self.is_valid_empty(result): + result = func(*args, **kwargs) + elif isinstance(result, list): + temp = func(*args, **kwargs) + if isinstance(temp, list): + result.extend(temp) + else: + break + except RateLimitExceededException as err: + self._rate_limit_handler( + err, + "插件", + plugin_id, + method, + **kwargs, + ) + except Exception as err: + self._plugin_error_handler( + err, + plugin_id, + plugin_name, + method, + **kwargs, + ) + return result + + async def async_execute_plugin_modules( + self, + method: str, + result: Any, + *args: Any, + **kwargs: Any, + ) -> Any: + """异步执行插件方法,并把同步函数移入线程池。""" + for plugin, module_dict in self._plugin_catalog.get_plugin_modules().items(): + plugin_id, plugin_name = plugin + func = module_dict.get(method) + if not func: + continue + try: + logger.info("请求插件 %s 执行:%s ...", plugin_name, method) + if self.is_valid_empty(result): + result = await self._async_call(func, *args, **kwargs) + elif isinstance(result, list): + temp = await self._async_call(func, *args, **kwargs) + if isinstance(temp, list): + result.extend(temp) + else: + break + except RateLimitExceededException as err: + self._rate_limit_handler( + err, + "插件", + plugin_id, + method, + **kwargs, + ) + except Exception as err: + self._plugin_error_handler( + err, + plugin_id, + plugin_name, + method, + **kwargs, + ) + return result + + def execute_system_modules( + self, + method: str, + result: Any, + *args: Any, + **kwargs: Any, + ) -> Any: + """同步执行按优先级排序的宿主模块,并支持签名接力。""" + logger.debug("请求系统模块执行:%s ...", method) + modules = sorted( + self._module_catalog.get_running_modules(method), + key=lambda module: module.get_priority(), + ) + for module in modules: + module_id = module.__class__.__name__ + module_name = self._module_name(module, module_id) + try: + func = getattr(module, method) + if self.is_valid_empty(result): + result = func(*args, **kwargs) + elif ObjectUtils.check_signature(func, result): + result = func(result) + elif isinstance(result, list): + temp = func(*args, **kwargs) + if isinstance(temp, list): + result.extend(temp) + else: + break + except RateLimitExceededException as err: + self._rate_limit_handler( + err, + "模块", + module_id, + method, + **kwargs, + ) + except Exception as err: + self._system_error_handler( + err, + module_id, + module_name, + method, + **kwargs, + ) + return result + + async def async_execute_system_modules( + self, + method: str, + result: Any, + *args: Any, + **kwargs: Any, + ) -> Any: + """异步执行宿主模块,并保持同步路径的签名接力与聚合顺序。""" + logger.debug("请求系统模块执行:%s ...", method) + modules = sorted( + self._module_catalog.get_running_modules(method), + key=lambda module: module.get_priority(), + ) + for module in modules: + module_id = module.__class__.__name__ + module_name = self._module_name(module, module_id) + try: + func = getattr(module, method) + if self.is_valid_empty(result): + result = await self._async_call(func, *args, **kwargs) + elif ObjectUtils.check_signature(func, result): + result = await self._async_call(func, result) + elif isinstance(result, list): + temp = await self._async_call(func, *args, **kwargs) + if isinstance(temp, list): + result.extend(temp) + else: + break + except RateLimitExceededException as err: + self._rate_limit_handler( + err, + "模块", + module_id, + method, + **kwargs, + ) + except Exception as err: + self._system_error_handler( + err, + module_id, + module_name, + method, + **kwargs, + ) + return result + + async def _async_call( + self, + func: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> Any: + """调用协程函数,或通过注入的线程池执行器运行同步函数。""" + if inspect.iscoroutinefunction(func): + return await func(*args, **kwargs) + return await self._async_function_runner(func, *args, **kwargs) + + @staticmethod + def _module_name(module: Any, fallback: str) -> str: + """读取模块展示名,失败时回退到稳定类名。""" + try: + return module.get_name() + except Exception as err: + logger.debug("获取模块名称出错:%s", str(err)) + return fallback diff --git a/app/runtime/extensions/plugin/__init__.py b/app/runtime/extensions/plugin/__init__.py new file mode 100644 index 000000000..716988604 --- /dev/null +++ b/app/runtime/extensions/plugin/__init__.py @@ -0,0 +1 @@ +"""插件运行时内部组件。""" diff --git a/app/runtime/extensions/plugin/contracts.py b/app/runtime/extensions/plugin/contracts.py new file mode 100644 index 000000000..ab9dbd463 --- /dev/null +++ b/app/runtime/extensions/plugin/contracts.py @@ -0,0 +1,41 @@ +"""插件运行时钩子契约。""" + +from dataclasses import dataclass +from typing import Any + +from app.foundation.reflection import ObjectUtils + + +@dataclass(frozen=True) +class PluginHookContract: + """描述宿主识别一个插件钩子时必须保持的运行语义。""" + + name: str + requires_enabled: bool = False + isolates_errors: bool = True + + +PLUGIN_HOOK_CONTRACTS = { + contract.name: contract + for contract in ( + PluginHookContract("get_command", requires_enabled=True), + PluginHookContract("get_api"), + PluginHookContract("get_service", requires_enabled=True), + PluginHookContract("get_module", requires_enabled=True), + PluginHookContract("get_actions", requires_enabled=True), + PluginHookContract("get_agent_tools", requires_enabled=True), + PluginHookContract("get_auth_providers", requires_enabled=True), + PluginHookContract("get_sidebar_nav", requires_enabled=True), + PluginHookContract("get_dashboard", requires_enabled=True), + PluginHookContract("get_dashboard_meta", requires_enabled=True), + PluginHookContract("get_form"), + PluginHookContract("get_page"), + PluginHookContract("get_render_mode", isolates_errors=False), + ) +} + + +def supports_plugin_hook(plugin: Any, name: str) -> bool: + """按旧插件的方法判定规则检查实例是否实现指定钩子。""" + method = getattr(plugin, name, None) + return bool(method and ObjectUtils.check_method(method)) diff --git a/app/runtime/extensions/plugin/projection.py b/app/runtime/extensions/plugin/projection.py new file mode 100644 index 000000000..1488d2f25 --- /dev/null +++ b/app/runtime/extensions/plugin/projection.py @@ -0,0 +1,262 @@ +"""插件公开能力投影。""" + +from typing import Any, Callable, Dict, List, Mapping, Optional + +from app.runtime.extensions.plugin.contracts import supports_plugin_hook +from app.runtime.log import logger as default_logger + + +class PluginProjection: + """把运行态插件投影为宿主命令、API、服务、模块和动作清单。""" + + def __init__( + self, + running_plugins: Mapping[str, Any], + log: Any = default_logger, + remote_entry_factory: Optional[Callable[[str, str], str]] = None, + ) -> None: + """保存运行态插件映射和错误日志端口。""" + self._running_plugins = running_plugins + self._logger = log + self._remote_entry_factory = remote_entry_factory + + def _items(self, pid: Optional[str]) -> list[tuple[str, Any]]: + """返回指定插件或运行态插件的稳定快照。""" + snapshot = dict(self._running_plugins) + if pid: + plugin = snapshot.get(pid) + return [(pid, plugin)] if plugin is not None else [] + return list(snapshot.items()) + + def commands(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: + """聚合插件命令并补充插件 ID。""" + commands: list[dict] = [] + for plugin_id, plugin in self._items(pid): + if not supports_plugin_hook(plugin, "get_command"): + continue + try: + if not plugin.get_state(): + continue + for command in plugin.get_command() or []: + command["pid"] = plugin_id + commands.append(command) + except Exception as error: + self._logger.error(f"获取插件命令出错:{str(error)}") + return commands + + def apis(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: + """聚合插件 API 并补充宿主路径和默认认证方式。""" + apis: list[dict] = [] + for plugin_id, plugin in self._items(pid): + if not supports_plugin_hook(plugin, "get_api"): + continue + try: + for api in plugin.get_api() or []: + api["path"] = f"/{plugin_id}{api['path']}" + if not api.get("auth"): + api["auth"] = "apikey" + apis.append(api) + except Exception as error: + self._logger.error(f"获取插件 {plugin_id} API出错:{str(error)}") + return apis + + def services(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: + """聚合启用插件的定时服务。""" + services: list[dict] = [] + for plugin_id, plugin in self._items(pid): + if not supports_plugin_hook(plugin, "get_service"): + continue + try: + if plugin.get_state(): + services.extend(plugin.get_service() or []) + except Exception as error: + self._logger.error(f"获取插件 {plugin_id} 服务出错:{str(error)}") + return services + + def modules(self, pid: Optional[str] = None) -> Dict[tuple, Dict[str, Any]]: + """聚合启用插件的模块方法清单。""" + modules: dict[tuple, dict] = {} + for plugin_id, plugin in self._items(pid): + if not supports_plugin_hook(plugin, "get_module"): + continue + try: + if plugin.get_state(): + modules[(plugin_id, plugin.get_name())] = plugin.get_module() or [] + except Exception as error: + self._logger.error(f"获取插件 {plugin_id} 模块出错:{str(error)}") + return modules + + def actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: + """聚合启用插件的工作流动作。""" + actions: list[dict] = [] + for plugin_id, plugin in self._items(pid): + if not supports_plugin_hook(plugin, "get_actions"): + continue + try: + if not plugin.get_state(): + continue + plugin_actions = plugin.get_actions() + if plugin_actions: + actions.append({ + "plugin_id": plugin_id, + "plugin_name": plugin.plugin_name, + "actions": plugin_actions, + }) + except Exception as error: + self._logger.error(f"获取插件 {plugin_id} 动作出错:{str(error)}") + return actions + + def remotes(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: + """投影插件联邦远程入口,并保持旧渲染模式筛选语义。""" + remotes = [] + for plugin_id, plugin in self._items(pid): + if not supports_plugin_hook(plugin, "get_render_mode"): + continue + render_mode, dist_path = plugin.get_render_mode() + if render_mode != "vue": + continue + if not self._remote_entry_factory: + raise RuntimeError("插件联邦入口生成器尚未配置") + remotes.append({ + "id": plugin_id, + "url": self._remote_entry_factory(plugin_id, dist_path), + "name": plugin.plugin_name, + }) + return remotes + + def auth_providers(self) -> List[Dict[str, Any]]: + """投影启用插件声明的登录认证提供方。""" + providers = [] + for plugin_id, plugin in self._items(None): + if not plugin.get_state() or not supports_plugin_hook( + plugin, "get_auth_providers" + ): + continue + try: + plugin_providers = plugin.get_auth_providers() or [] + except Exception as error: + self._logger.error( + f"获取插件 {plugin_id} 登录认证提供方出错:{str(error)}" + ) + continue + render_mode = None + dist_path = None + if supports_plugin_hook(plugin, "get_render_mode"): + render_mode, dist_path = plugin.get_render_mode() + for raw_provider in plugin_providers: + if not raw_provider or not isinstance(raw_provider, dict): + continue + provider = raw_provider.copy() + provider["type"] = "plugin" + provider["plugin_id"] = plugin_id + provider.setdefault("id", f"plugin:{plugin_id}") + provider.setdefault("name", plugin.plugin_name) + provider.setdefault("enabled", True) + if render_mode == "vue" and dist_path: + if not self._remote_entry_factory: + raise RuntimeError("插件联邦入口生成器尚未配置") + provider.setdefault("component", "AuthPage") + provider["remote"] = { + "id": plugin_id, + "url": self._remote_entry_factory(plugin_id, dist_path), + "name": plugin.plugin_name, + } + providers.append(provider) + return providers + + def sidebar(self) -> List[Dict[str, Any]]: + """投影启用 Vue 插件的侧栏导航,并规整权限、分区和顺序。""" + valid_sections = {"start", "discovery", "subscribe", "organize", "system"} + valid_permissions = {"subscribe", "discovery", "search", "manage", "admin"} + items = [] + for plugin_id, plugin in self._items(None): + if not plugin.get_state() or not supports_plugin_hook( + plugin, "get_sidebar_nav" + ): + continue + if not supports_plugin_hook(plugin, "get_render_mode"): + continue + render_mode, _ = plugin.get_render_mode() + if render_mode != "vue": + continue + try: + nav_list = plugin.get_sidebar_nav() + if not nav_list: + continue + for raw in nav_list: + if not raw or not isinstance(raw, dict): + continue + nav_key = str( + raw.get("nav_key") or raw.get("key") or "main" + ).strip() + if not nav_key or any( + character in nav_key for character in ["/", "?", "#", " "] + ): + self._logger.warning( + f"插件[{plugin_id}]侧栏项 nav_key 无效,已跳过: " + f"{nav_key!r}" + ) + continue + section = str(raw.get("section") or "system").lower() + if section not in valid_sections: + section = "system" + permission = raw.get("permission") + if permission is not None and str(permission) not in valid_permissions: + permission = None + elif permission is not None: + permission = str(permission) + try: + order = int(raw.get("order", 0)) + except (TypeError, ValueError): + order = 0 + items.append({ + "plugin_id": plugin_id, + "nav_key": nav_key, + "title": raw.get("title") or plugin.plugin_name, + "icon": raw.get("icon") or "mdi-puzzle", + "section": section, + "permission": permission, + "order": order, + }) + except Exception as error: + self._logger.error( + f"获取插件[{plugin_id}]侧栏导航出错:{str(error)}" + ) + items.sort( + key=lambda item: ( + item["section"], + item["order"], + item["plugin_id"], + item["nav_key"], + ) + ) + return items + + def dashboard_metadata(self) -> List[Dict[str, str]]: + """投影启用插件的单仪表板或多仪表板元信息。""" + metadata = [] + for plugin_id, plugin in self._items(None): + if not supports_plugin_hook(plugin, "get_dashboard"): + continue + try: + if not plugin.get_state(): + continue + if supports_plugin_hook(plugin, "get_dashboard_meta"): + plugin_metadata = plugin.get_dashboard_meta() + if plugin_metadata: + metadata.extend({ + "id": plugin_id, + "name": item.get("name"), + "key": item.get("key"), + } for item in plugin_metadata if item) + else: + metadata.append({ + "id": plugin_id, + "name": plugin.plugin_name, + "key": "", + }) + except Exception as error: + self._logger.error( + f"获取插件[{plugin_id}]仪表盘元数据出错:{str(error)}" + ) + return metadata diff --git a/app/runtime/extensions/plugin/registry.py b/app/runtime/extensions/plugin/registry.py new file mode 100644 index 000000000..61a0fea1b --- /dev/null +++ b/app/runtime/extensions/plugin/registry.py @@ -0,0 +1,56 @@ +"""插件类与运行实例注册表。""" + +from typing import Any, Dict, Optional + + +class PluginRegistry: + """集中持有插件类和运行实例,并为读取方提供稳定快照。""" + + def __init__(self) -> None: + """创建彼此独立但生命周期一致的类表和实例表。""" + self._classes: Dict[str, Any] = {} + self._running: Dict[str, Any] = {} + + @property + def classes(self) -> Dict[str, Any]: + """返回兼容旧调用方可变访问语义的插件类表。""" + return self._classes + + @property + def running(self) -> Dict[str, Any]: + """返回兼容旧调用方可变访问语义的运行实例表。""" + return self._running + + def has_class(self, plugin_id: str) -> bool: + """判断插件类是否已经登记。""" + return plugin_id in self._classes + + def plugin_class(self, plugin_id: str) -> Optional[Any]: + """读取指定插件类,未登记时返回空。""" + return self._classes.get(plugin_id) + + def instance(self, plugin_id: str) -> Optional[Any]: + """读取指定运行实例,未运行时返回空。""" + return self._running.get(plugin_id) + + def plugin_ids(self) -> list[str]: + """返回保持登记顺序的插件类 ID 快照。""" + return list(self._classes) + + def running_ids(self) -> list[str]: + """返回保持登记顺序的运行实例 ID 快照。""" + return list(self._running) + + def running_snapshot(self) -> Dict[str, Any]: + """复制运行实例表,避免插件重载期间迭代失效。""" + return dict(self._running) + + def remove(self, plugin_id: str) -> None: + """同时移除指定插件类和运行实例。""" + self._classes.pop(plugin_id, None) + self._running.pop(plugin_id, None) + + def clear(self) -> None: + """原地清空注册表,保持外部持有的兼容字典引用有效。""" + self._classes.clear() + self._running.clear() diff --git a/app/runtime/extensions/plugin/storage.py b/app/runtime/extensions/plugin/storage.py new file mode 100644 index 000000000..9dfc8b4da --- /dev/null +++ b/app/runtime/extensions/plugin/storage.py @@ -0,0 +1,89 @@ +"""插件运行时持久化端口。""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + + +ConfigReader = Callable[[Any], Any] +ConfigWriter = Callable[[Any, Any], Any] +AsyncConfigWriter = Callable[[Any, Any], Awaitable[Any]] +ConfigDeleter = Callable[[Any], bool] +PluginDataDeleter = Callable[[str], Any] + + +def _empty_read(_key: Any) -> Any: + """组合根尚未装配时返回空配置。""" + return None + + +def _ignore_write(_key: Any, _value: Any) -> None: + """组合根尚未装配时忽略同步配置写入。""" + + +async def _ignore_async_write(_key: Any, _value: Any) -> None: + """组合根尚未装配时忽略异步配置写入。""" + + +def _ignore_delete(_key: Any) -> bool: + """组合根尚未装配时报告配置未删除。""" + return False + + +def _ignore_plugin_data_delete(_plugin_id: str) -> None: + """组合根尚未装配时忽略插件数据删除。""" + + +class PluginStorage: + """封装插件运行时所需的最小持久化能力。""" + + def __init__( + self, + *, + read: ConfigReader = _empty_read, + write: ConfigWriter = _ignore_write, + async_write: AsyncConfigWriter = _ignore_async_write, + delete: ConfigDeleter = _ignore_delete, + delete_data: PluginDataDeleter = _ignore_plugin_data_delete, + ) -> None: + """保存由启动组合根提供的读写函数。""" + self._read = read + self._write = write + self._async_write = async_write + self._delete = delete + self._delete_data = delete_data + + def read(self, key: Any) -> Any: + """读取插件运行时配置。""" + return self._read(key) + + def write(self, key: Any, value: Any) -> Any: + """同步保存插件运行时配置。""" + return self._write(key, value) + + async def async_write(self, key: Any, value: Any) -> Any: + """异步保存插件运行时配置。""" + return await self._async_write(key, value) + + def delete(self, key: Any) -> bool: + """删除插件运行时配置。""" + return self._delete(key) + + def delete_data(self, plugin_id: str) -> Any: + """删除指定插件的业务数据。""" + return self._delete_data(plugin_id) + + +_plugin_storage = PluginStorage() + + +def configure_plugin_storage(storage: PluginStorage) -> None: + """由启动组合根替换插件运行时持久化实现。""" + global _plugin_storage + _plugin_storage = storage + + +def get_plugin_storage() -> PluginStorage: + """返回当前插件运行时持久化端口。""" + return _plugin_storage diff --git a/app/runtime/extensions/plugin/system.py b/app/runtime/extensions/plugin/system.py new file mode 100644 index 000000000..e212c813a --- /dev/null +++ b/app/runtime/extensions/plugin/system.py @@ -0,0 +1,86 @@ +"""插件市场、包和依赖系统能力的运行时注入端口。""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any, Optional + + +class PluginSystemServices: + """保存由启动组合根注入的插件外部系统适配器。""" + + def __init__( + self, + *, + market: Any, + package: Any, + dependency: Any, + compatible_flags: Callable[[Optional[str]], list[str]], + frozen: Callable[[], bool], + ) -> None: + """记录市场、包、依赖和代际兼容计算端口。""" + self.market = market + self.package = package + self.dependency = dependency + self.compatible_flags = compatible_flags + self.frozen = frozen + + def local_repo_paths(self) -> list[Path]: + """返回可监测的本地插件仓库路径。""" + return self.market.get_local_repo_paths() + + def local_candidate(self, plugin_id: str, **kwargs: Any) -> Optional[dict]: + """读取指定本地插件候选。""" + return self.market.get_local_candidate(plugin_id, **kwargs) + + def local_candidates(self) -> dict[str, dict]: + """读取全部本地插件候选。""" + return self.market.get_local_candidates() + + def local_repo_url( + self, + plugin_id: str, + repo_path: Optional[object] = None, + package_version: Optional[str] = None, + ) -> str: + """构造本地插件来源标识。""" + return self.market.make_local_repo_url( + plugin_id, + repo_path, + package_version, + ) + + def annotate_system_version(self, plugin_info: dict) -> dict: + """补充插件条目的主程序版本兼容信息。""" + return self.market.annotate_system_version(plugin_info) + + def is_package_compatible(self, plugin_info: dict, package_version: str) -> bool: + """判断插件条目是否兼容指定代际。""" + return self.market.is_package_compatible(plugin_info, package_version) + + def is_frozen(self) -> bool: + """判断当前宿主是否为不可写的冻结运行模式。""" + return self.frozen() + + +_services: Optional[PluginSystemServices] = None + + +def configure_plugin_system(services: PluginSystemServices) -> None: + """由启动组合根装配插件外部系统能力。""" + global _services + _services = services + + +def reset_plugin_system() -> None: + """清除已装配服务,仅供隔离测试恢复进程状态。""" + global _services + _services = None + + +def get_plugin_system() -> PluginSystemServices: + """返回已装配的插件外部系统端口。""" + if _services is None: + raise RuntimeError("插件外部系统服务尚未由启动组合根装配") + return _services diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index 7ae04c49a..870288726 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -1,12 +1,9 @@ import ast import asyncio -import concurrent -import concurrent.futures import importlib.util import inspect import os import posixpath -import shutil import sys import threading import time @@ -19,20 +16,20 @@ from fastapi import HTTPException from starlette import status from watchfiles import watch -from app import schemas -from app.db.oper.plugindata import PluginDataOper -from app.db.oper.systemconfig import SystemConfigOper +from app.schemas.plugin import Plugin as _SchemaPlugin +from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard from app.foundation.crypto import RSAUtils -from app.foundation.reflection import ObjectUtils from app.foundation.singleton import Singleton from app.foundation.version import compare_version -from app.adapters.system.host import SystemUtils -from app.adapters.external.market import PluginHelper, VERSION_BACKWARD_COMPATIBLE_FLAGS from app.runtime.log import logger -from app.runtime.cache import fresh, async_fresh from app.runtime.config import settings from app.runtime.events import EventHandlerBinding, eventmanager from app.runtime.reload import ConfigReloadMixin +from app.runtime.extensions.plugin.contracts import supports_plugin_hook +from app.runtime.extensions.plugin.projection import PluginProjection +from app.runtime.extensions.plugin.registry import PluginRegistry +from app.runtime.extensions.plugin.storage import get_plugin_storage +from app.runtime.extensions.plugin.system import get_plugin_system from app.schemas.types import EventType, SystemConfigKey LegacyDiagnosticsConfigurator = Callable[..., None] @@ -40,6 +37,7 @@ LegacyImportScanner = Callable[..., None] LegacyPluginImportPreparer = Callable[..., None] PluginInstallReporter = Callable[..., None] SiteAuthLevelProvider = Callable[[], int] +PluginCatalogFactory = Callable[["PluginManager"], Any] def _ignore_legacy_diagnostics(**_kwargs) -> None: @@ -55,6 +53,11 @@ def _unavailable_site_auth_level() -> int: return 0 +def _unavailable_plugin_catalog_factory(_manager: "PluginManager") -> Any: + """在启动组合根尚未装配目录用例时拒绝隐式跨层构造。""" + raise RuntimeError("插件目录应用服务尚未由启动组合根装配") + + _legacy_diagnostics_configurator: LegacyDiagnosticsConfigurator = ( _ignore_legacy_diagnostics ) @@ -64,6 +67,7 @@ _legacy_plugin_import_preparer: LegacyPluginImportPreparer = ( ) _plugin_install_reporter: PluginInstallReporter = _ignore_legacy_diagnostics _site_auth_level_provider: SiteAuthLevelProvider = _unavailable_site_auth_level +_plugin_catalog_factory: PluginCatalogFactory = _unavailable_plugin_catalog_factory def configure_plugin_legacy_import_services( @@ -97,6 +101,12 @@ def configure_site_auth_level_provider(provider: SiteAuthLevelProvider) -> None: _site_auth_level_provider = provider +def configure_plugin_catalog_factory(factory: PluginCatalogFactory) -> None: + """由启动组合根注入插件目录应用服务工厂,消除 Runtime 反向依赖。""" + global _plugin_catalog_factory + _plugin_catalog_factory = factory + + class PluginManager(ConfigReloadMixin, metaclass=Singleton): """插件管理器""" CONFIG_WATCH = {"DEV", "PLUGIN_AUTO_RELOAD", "PLUGIN_LOCAL_REPO_PATHS"} @@ -104,10 +114,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): def __init__(self): """初始化插件注册表、缓存和开发模式监控状态。""" - # 插件列表 - self._plugins: dict = {} - # 运行态插件列表 - self._running_plugins: dict = {} + self._plugin_registry = PluginRegistry() + # 旧属性继续引用注册表拥有的可变字典,保持插件和测试的访问身份。 + self._plugins = self._plugin_registry.classes + self._running_plugins = self._plugin_registry.running # 配置Key self._config_key: str = "plugin.%s" # 监控线程 @@ -135,6 +145,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): ) -> Optional[EventHandlerBinding]: """为插件声明的事件方法解析当前运行实例。""" plugin_id = owner_class.__name__ + # 旧测试与部分扩展会替换私有映射来构造隔离运行态,解析器继续尊重该接缝。 if plugin_id not in self._plugins: return None plugin = self._running_plugins.get(plugin_id) @@ -174,7 +185,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): return True # 已安装插件 - installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + installed_plugins = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] # 扫描插件目录,只加载符合条件的插件 plugins = self._load_selective_plugins(pid, installed_plugins, check_module) # 排序 @@ -268,14 +279,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): # 清空对象 if pid: # 清空指定插件 - self._plugins.pop(pid, None) - self._running_plugins.pop(pid, None) + self._plugin_registry.remove(pid) # 清除插件模块缓存,包括所有子模块 self._clear_plugin_modules(pid) else: # 清空 - self._plugins = {} - self._running_plugins = {} + self._plugin_registry.clear() # 清除所有插件模块缓存 self._clear_plugin_modules() self.clear_plugin_agent_tools_cache() @@ -372,7 +381,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): 获取运行态插件列表 :return: 运行态插件列表 """ - return self._running_plugins + return self._plugin_registry.running @property def plugins(self) -> Dict[str, Any]: @@ -380,7 +389,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): 获取插件列表 :return: 插件列表 """ - return self._plugins + return self._plugin_registry.classes def on_config_changed(self): """在插件监控配置变化后重建文件监控。""" @@ -442,7 +451,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ # 监视插件目录 plugin_paths = [str(settings.ROOT_PATH / "app" / "plugins")] - for local_repo_path in PluginHelper.get_local_repo_paths(): + for local_repo_path in get_plugin_system().local_repo_paths(): if local_repo_path.exists() and local_repo_path.is_dir(): plugin_paths.append(str(local_repo_path)) logger.info(">>> 监控线程已启动,准备进入watch循环...") @@ -650,7 +659,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ try: event_path = event_path.resolve() - for local_repo_path in PluginHelper.get_local_repo_paths(): + for local_repo_path in get_plugin_system().local_repo_paths(): if not local_repo_path.exists() or not local_repo_path.is_dir(): continue if not event_path.is_relative_to(local_repo_path): @@ -668,12 +677,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): else: continue plugin_dir_name = relative_parts[1] - candidate = PluginHelper().get_local_plugin_candidate( - pid=plugin_dir_name, + candidate = get_plugin_system().local_candidate( + plugin_dir_name, package_version=package_version, repo_path=local_repo_path, strict_compat=False, - strict_system_version=not settings.DEV + strict_system_version=not settings.DEV, ) if candidate: return candidate @@ -687,12 +696,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ 已安装本地插件源码变化时,同步到运行目录 """ - installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + installed_plugins = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] if pid not in installed_plugins: logger.info(f"本地插件 {pid} 尚未安装,跳过自动同步和热重载") return False - candidate = candidate or PluginHelper().get_local_plugin_candidate(pid) + candidate = candidate or get_plugin_system().local_candidate(pid) if not candidate: return False if candidate.get("compatible") is False: @@ -702,16 +711,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): source_dir = Path(candidate.get("path")) dest_dir = settings.ROOT_PATH / "app" / "plugins" / pid.lower() try: - if source_dir.resolve() == dest_dir.resolve(): - return True - if dest_dir.exists(): - shutil.rmtree(dest_dir, ignore_errors=True) - shutil.copytree( - source_dir, - dest_dir, - dirs_exist_ok=True, - ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", "node_modules") - ) + if not get_plugin_system().package.sync_local(pid, source_dir): + return False PluginManager()._recent_local_sync[pid] = time.time() logger.info(f"已同步本地插件 {pid}:{source_dir} -> {dest_dir}") return True @@ -798,7 +799,11 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): def install_plugin(plugin): start_time = time.time() - state, msg = PluginHelper().install(pid=plugin.id, repo_url=plugin.repo_url, force_install=True) + state, msg = get_plugin_system().package.install( + plugin_id=plugin.id, + repo_url=plugin.repo_url, + force_install=True, + ) elapsed_time = time.time() - start_time if state: _plugin_install_reporter( @@ -813,11 +818,11 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): f"插件 {plugin.plugin_name} v{plugin.plugin_version} 安装失败:{msg},耗时:{elapsed_time:.2f} 秒") failed_plugins.append(plugin.id) - if SystemUtils.is_frozen(): + if get_plugin_system().is_frozen(): return [] # 获取已安装插件列表 - install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + install_plugins = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] # 获取远程和本地仓库来源插件列表 online_plugins = self.get_online_plugins() local_repo_plugins = self.get_local_repo_plugins() @@ -863,16 +868,16 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ 安装插件中缺失或不兼容的依赖项 """ - pluginhelper = PluginHelper() + dependency_installer = get_plugin_system().dependency # 第一步:获取需要安装的依赖项列表 - missing_dependencies = pluginhelper.find_missing_dependencies() + missing_dependencies = dependency_installer.find_missing() if not missing_dependencies: return missing_dependencies logger.debug(f"检测到缺失的依赖项: {missing_dependencies}") logger.info(f"开始安装缺失的依赖项,共 {len(missing_dependencies)} 个...") # 第二步:安装依赖项并返回结果 total_start_time = time.time() - success, message = pluginhelper.install_dependencies(missing_dependencies) + success, message = dependency_installer.install(missing_dependencies) total_elapsed_time = time.time() - total_start_time if success: logger.info(f"已完成 {len(missing_dependencies)} 个依赖项安装,总耗时:{total_elapsed_time:.2f} 秒") @@ -887,7 +892,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ if not self._plugins.get(pid): return {} - conf = SystemConfigOper().get(self._config_key % pid) + conf = get_plugin_storage().read(self._config_key % pid) if conf: # 去掉空Key return {k: v for k, v in conf.items() if k} @@ -902,7 +907,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ if not force and not self._plugins.get(pid): return False - SystemConfigOper().set(self._config_key % pid, conf) + get_plugin_storage().write(self._config_key % pid, conf) return True async def async_save_plugin_config( @@ -916,7 +921,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ if not force and not self._plugins.get(pid): return False - await SystemConfigOper().async_set(self._config_key % pid, conf) + await get_plugin_storage().async_write(self._config_key % pid, conf) return True def delete_plugin_config(self, pid: str, force: bool = False) -> bool: @@ -927,7 +932,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ if not force and not self._plugins.get(pid): return False - return SystemConfigOper().delete(self._config_key % pid) + return get_plugin_storage().delete(self._config_key % pid) def delete_plugin_data(self, pid: str, force: bool = False) -> bool: """ @@ -937,7 +942,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ if not force and not self._plugins.get(pid): return False - PluginDataOper().del_data(pid) + get_plugin_storage().delete_data(pid) return True def get_plugin_state(self, pid: str) -> bool: @@ -945,9 +950,21 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): 获取插件状态 :param pid: 插件ID """ - plugin = self._running_plugins.get(pid) + plugin = self._plugin_registry.instance(pid) return plugin.get_state() if plugin else False + def _plugin_projection(self) -> PluginProjection: + """构造绑定当前运行态插件注册表的能力投影服务。""" + return PluginProjection( + self._plugin_registry.running, + logger, + self.get_plugin_remote_entry, + ) + + def _plugin_catalog(self) -> Any: + """构造绑定当前市场客户端和插件 DTO 映射器的目录应用服务。""" + return _plugin_catalog_factory(self) + def get_plugin_commands(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ 获取插件命令 @@ -959,23 +976,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): "pid": "", }] """ - ret_commands = [] - # 创建字典快照避免并发修改 - running_plugins_snapshot = dict(self._running_plugins) - for plugin_id, plugin in running_plugins_snapshot.items(): - if pid and pid != plugin_id: - continue - if hasattr(plugin, "get_command") and ObjectUtils.check_method(plugin.get_command): - try: - if not plugin.get_state(): - continue - commands = plugin.get_command() or [] - for command in commands: - command["pid"] = plugin_id - ret_commands.extend(commands) - except Exception as e: - logger.error(f"获取插件命令出错:{str(e)}") - return ret_commands + return self._plugin_projection().commands(pid) def get_plugin_apis(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ @@ -989,25 +990,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): "allow_anonymous": false }] """ - ret_apis = [] - if pid: - plugins = {pid: self._running_plugins.get(pid)} - else: - plugins = self._running_plugins - for plugin_id, plugin in plugins.items(): - if pid and pid != plugin_id: - continue - if hasattr(plugin, "get_api") and ObjectUtils.check_method(plugin.get_api): - try: - apis = plugin.get_api() or [] - for api in apis: - api["path"] = f"/{plugin_id}{api['path']}" - if not api.get("auth"): - api["auth"] = "apikey" - ret_apis.extend(apis) - except Exception as e: - logger.error(f"获取插件 {plugin_id} API出错:{str(e)}") - return ret_apis + return self._plugin_projection().apis(pid) def get_plugin_services(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ @@ -1021,21 +1004,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): "func_kwargs": {} # 方法参数 }] """ - ret_services = [] - # 创建字典快照避免并发修改 - running_plugins_snapshot = dict(self._running_plugins) - for plugin_id, plugin in running_plugins_snapshot.items(): - if pid and pid != plugin_id: - continue - if hasattr(plugin, "get_service") and ObjectUtils.check_method(plugin.get_service): - try: - if not plugin.get_state(): - continue - services = plugin.get_service() or [] - ret_services.extend(services) - except Exception as e: - logger.error(f"获取插件 {plugin_id} 服务出错:{str(e)}") - return ret_services + return self._plugin_projection().services(pid) def get_plugin_modules(self, pid: Optional[str] = None) -> Dict[tuple, Dict[str, Any]]: """ @@ -1046,21 +1015,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): } } """ - ret_modules = {} - # 创建字典快照避免并发修改 - running_plugins_snapshot = dict(self._running_plugins) - for plugin_id, plugin in running_plugins_snapshot.items(): - if pid and pid != plugin_id: - continue - if hasattr(plugin, "get_module") and ObjectUtils.check_method(plugin.get_module): - try: - if not plugin.get_state(): - continue - plugin_module = plugin.get_module() or [] - ret_modules[(plugin_id, plugin.get_name())] = plugin_module - except Exception as e: - logger.error(f"获取插件 {plugin_id} 模块出错:{str(e)}") - return ret_modules + return self._plugin_projection().modules(pid) def get_plugin_actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ @@ -1072,26 +1027,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): "kwargs": {} # 需要附加传递的参数 }] """ - ret_actions = [] - # 创建字典快照避免并发修改 - running_plugins_snapshot = dict(self._running_plugins) - for plugin_id, plugin in running_plugins_snapshot.items(): - if pid and pid != plugin_id: - continue - if hasattr(plugin, "get_actions") and ObjectUtils.check_method(plugin.get_actions): - try: - if not plugin.get_state(): - continue - actions = plugin.get_actions() - if actions: - ret_actions.append({ - "plugin_id": plugin_id, - "plugin_name": plugin.plugin_name, - "actions": actions - }) - except Exception as e: - logger.error(f"获取插件 {plugin_id} 动作出错:{str(e)}") - return ret_actions + return self._plugin_projection().actions(pid) @staticmethod def _copy_plugin_agent_tools( @@ -1131,9 +1067,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): for plugin_id, plugin in running_plugins_snapshot.items(): if pid and pid != plugin_id: continue - if hasattr(plugin, "get_agent_tools") and ObjectUtils.check_method( - plugin.get_agent_tools - ): + if supports_plugin_hook(plugin, "get_agent_tools"): try: if not plugin.get_state(): continue @@ -1180,22 +1114,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ 获取插件联邦组件列表 """ - remotes = [] - # 创建字典快照避免并发修改 - running_plugins_snapshot = dict(self._running_plugins) - for plugin_id, plugin in running_plugins_snapshot.items(): - if pid and pid != plugin_id: - continue - if hasattr(plugin, "get_render_mode"): - render_mode, dist_path = plugin.get_render_mode() - if render_mode != "vue": - continue - remotes.append({ - "id": plugin_id, - "url": self.get_plugin_remote_entry(plugin_id, dist_path), - "name": plugin.plugin_name, - }) - return remotes + return self._plugin_projection().remotes(pid) def get_plugin_auth_providers(self) -> List[Dict[str, Any]]: """ @@ -1203,132 +1122,21 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :return: 插件认证入口列表 """ - providers: List[Dict[str, Any]] = [] - running_plugins_snapshot = dict(self._running_plugins) - for plugin_id, plugin in running_plugins_snapshot.items(): - if not plugin.get_state(): - continue - if not hasattr(plugin, "get_auth_providers") or not ObjectUtils.check_method(plugin.get_auth_providers): - continue - try: - plugin_providers = plugin.get_auth_providers() or [] - except Exception as e: - logger.error(f"获取插件 {plugin_id} 登录认证提供方出错:{str(e)}") - continue - render_mode = None - dist_path = None - if hasattr(plugin, "get_render_mode"): - render_mode, dist_path = plugin.get_render_mode() - for raw_provider in plugin_providers: - if not raw_provider or not isinstance(raw_provider, dict): - continue - provider = raw_provider.copy() - provider["type"] = "plugin" - provider["plugin_id"] = plugin_id - provider.setdefault("id", f"plugin:{plugin_id}") - provider.setdefault("name", plugin.plugin_name) - provider.setdefault("enabled", True) - if render_mode == "vue" and dist_path: - provider.setdefault("component", "AuthPage") - provider["remote"] = { - "id": plugin_id, - "url": self.get_plugin_remote_entry(plugin_id, dist_path), - "name": plugin.plugin_name, - } - providers.append(provider) - return providers + return self._plugin_projection().auth_providers() def get_plugin_sidebar_nav(self) -> List[Dict[str, Any]]: """ 聚合所有已启用 Vue 插件的侧栏导航项(get_sidebar_nav)。 """ - valid_sections = {"start", "discovery", "subscribe", "organize", "system"} - valid_permissions = {"subscribe", "discovery", "search", "manage", "admin"} - items: List[Dict[str, Any]] = [] - running_plugins_snapshot = dict(self._running_plugins) - for plugin_id, plugin in running_plugins_snapshot.items(): - if not plugin.get_state(): - continue - if not hasattr(plugin, "get_sidebar_nav") or not ObjectUtils.check_method(plugin.get_sidebar_nav): - continue - if not hasattr(plugin, "get_render_mode"): - continue - render_mode, _ = plugin.get_render_mode() - if render_mode != "vue": - continue - try: - nav_list = plugin.get_sidebar_nav() - if not nav_list: - continue - for raw in nav_list: - if not raw or not isinstance(raw, dict): - continue - nav_key = str(raw.get("nav_key") or raw.get("key") or "main").strip() - if not nav_key or any(c in nav_key for c in ["/", "?", "#", " "]): - logger.warning(f"插件[{plugin_id}]侧栏项 nav_key 无效,已跳过: {nav_key!r}") - continue - title = raw.get("title") or plugin.plugin_name - icon = raw.get("icon") or "mdi-puzzle" - section = str(raw.get("section") or "system").lower() - if section not in valid_sections: - section = "system" - perm = raw.get("permission") - if perm is not None and str(perm) not in valid_permissions: - perm = None - else: - perm = str(perm) if perm is not None else None - order = raw.get("order", 0) - try: - order = int(order) - except (TypeError, ValueError): - order = 0 - items.append({ - "plugin_id": plugin_id, - "nav_key": nav_key, - "title": title, - "icon": icon, - "section": section, - "permission": perm, - "order": order, - }) - except Exception as e: - logger.error(f"获取插件[{plugin_id}]侧栏导航出错:{str(e)}") - items.sort(key=lambda x: (x["section"], x["order"], x["plugin_id"], x["nav_key"])) - return items + return self._plugin_projection().sidebar() def get_plugin_dashboard_meta(self) -> List[Dict[str, str]]: """ 获取所有插件仪表盘元信息 """ - dashboard_meta = [] - # 创建字典快照避免并发修改 - running_plugins_snapshot = dict(self._running_plugins) - for plugin_id, plugin in running_plugins_snapshot.items(): - if not hasattr(plugin, "get_dashboard") or not ObjectUtils.check_method(plugin.get_dashboard): - continue - try: - if not plugin.get_state(): - continue - # 如果是多仪表盘实现 - if hasattr(plugin, "get_dashboard_meta") and ObjectUtils.check_method(plugin.get_dashboard_meta): - meta = plugin.get_dashboard_meta() - if meta: - dashboard_meta.extend([{ - "id": plugin_id, - "name": m.get("name"), - "key": m.get("key"), - } for m in meta if m]) - else: - dashboard_meta.append({ - "id": plugin_id, - "name": plugin.plugin_name, - "key": "", - }) - except Exception as e: - logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}") - return dashboard_meta + return self._plugin_projection().dashboard_metadata() - def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> Optional[schemas.PluginDashboard]: + def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> Optional[_SchemaPluginDashboard]: """ 获取插件仪表盘 """ @@ -1341,7 +1149,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): return len(signature.parameters) # 获取插件实例 - plugin_instance = self.running_plugins.get(pid) + plugin_instance = self._plugin_registry.instance(pid) if not plugin_instance: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {pid} 不存在或未加载") @@ -1368,7 +1176,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"插件 {pid} 返回的仪表盘数据格式错误") cols, attrs, elements = dashboard - return schemas.PluginDashboard( + return _SchemaPluginDashboard( id=pid, name=plugin_instance.plugin_name, key=key, @@ -1384,7 +1192,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param pid: 插件ID :param attr: 属性名 """ - plugin = self._running_plugins.get(pid) + plugin = self._plugin_registry.instance(pid) if not plugin: return None if not hasattr(plugin, attr): @@ -1399,7 +1207,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param args: 参数 :param kwargs: 关键字参数 """ - plugin = self._running_plugins.get(pid) + plugin = self._plugin_registry.instance(pid) if not plugin: return None if not hasattr(plugin, method): @@ -1414,7 +1222,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param args: 参数 :param kwargs: 关键字参数 """ - plugin = self._running_plugins.get(pid) + plugin = self._plugin_registry.instance(pid) if not plugin: return None if not hasattr(plugin, method): @@ -1429,76 +1237,46 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ 获取所有插件ID """ - return list(self._plugins.keys()) + return self._plugin_registry.plugin_ids() def get_running_plugin_ids(self) -> List[str]: """ 获取所有运行态插件ID """ - return list(self._running_plugins.keys()) + return self._plugin_registry.running_ids() - def get_online_plugins(self, force: bool = False) -> List[schemas.Plugin]: + def get_online_plugins(self, force: bool = False) -> List[_SchemaPlugin]: """ 获取所有在线插件信息 """ if not settings.PLUGIN_MARKET: return [] - - # 拉取当前索引及可扫描的旧索引;旧条目可用当前版本 false 显式排除。 - compatible_flags = ( - [settings.VERSION_FLAG] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []) - if settings.VERSION_FLAG else [] + compatible_flags = get_plugin_system().compatible_flags( + settings.VERSION_FLAG ) markets = [m for m in settings.PLUGIN_MARKET.split(",") if m] - - # 使用多线程获取线上插件 - with concurrent.futures.ThreadPoolExecutor() as executor: - # future -> (market_index, is_higher, flag_priority) - futures_meta: Dict[concurrent.futures.Future, Tuple[int, bool, int]] = {} - for market_index, m in enumerate(markets): - # 默认索引只展示声明 V2 或当前版本兼容的共享实现。 - base_future = executor.submit(self.get_plugins_from_market, m, None, force) - futures_meta[base_future] = (market_index, False, 0) - # 提交当前专用索引(如 v3)及可扫描的旧索引(如 v2)。 - for flag_priority, flag in enumerate(compatible_flags): - higher_future = executor.submit(self.get_plugins_from_market, m, flag, force) - futures_meta[higher_future] = (market_index, True, flag_priority) - - # 收集结果,按市场顺序、高版本优先、兼容版本优先级排序,保证去重时优先保留高版本来源 - collected: List[Tuple[int, bool, int, List[schemas.Plugin]]] = [] - for future in concurrent.futures.as_completed(futures_meta): - plugins = future.result() - market_index, is_higher, flag_priority = futures_meta[future] - collected.append((market_index, is_higher, flag_priority, plugins or [])) - - collected.sort(key=lambda item: (item[0], 0 if item[1] else 1, item[2])) - higher_version_plugins: List[schemas.Plugin] = [] - base_version_plugins: List[schemas.Plugin] = [] - for _market_index, is_higher, _flag_priority, plugins in collected: - if not plugins: - continue - if is_higher: - higher_version_plugins.extend(plugins) - else: - base_version_plugins.extend(plugins) - - result = self.process_plugins_list(higher_version_plugins, base_version_plugins) + result = self._plugin_catalog().collect( + markets=markets, + compatible_flags=compatible_flags, + force=force, + loader=self.get_plugins_from_market, + ) logger.info(f"获取到 {len(result)} 个线上插件") return result - def get_local_plugins(self) -> List[schemas.Plugin]: + def get_local_plugins(self) -> List[_SchemaPlugin]: """ 获取所有本地已下载的插件信息 """ # 返回值 plugins = [] # 已安装插件 - installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + installed_apps = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] for pid, plugin_class in self._plugins.items(): # 运行状插件 plugin_obj = self._running_plugins.get(pid) # 基本属性 - plugin = schemas.Plugin() + plugin = _SchemaPlugin() # ID plugin.id = pid # 安装状态 @@ -1518,10 +1296,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): plugin.state = False # 是否有详情页面 if hasattr(plugin_class, "get_page"): - if ObjectUtils.check_method(plugin_class.get_page): - plugin.has_page = True - else: - plugin.has_page = False + plugin.has_page = supports_plugin_hook(plugin_class, "get_page") # 公钥 if hasattr(plugin_class, "plugin_public_key"): plugin.plugin_public_key = plugin_class.plugin_public_key @@ -1565,21 +1340,22 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): 插件类由运行期动态加载,旧插件可能未声明版本属性,因此缺失时返回 None。 """ - installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + installed_apps = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] if pid not in installed_apps: return None + # 保留测试和旧扩展可能替换 `_plugins` 字典的兼容接缝。 plugin_class = self._plugins.get(pid) if not plugin_class: return None return getattr(plugin_class, "plugin_version", None) - def get_local_repo_plugins(self) -> List[schemas.Plugin]: + def get_local_repo_plugins(self) -> List[_SchemaPlugin]: """ 获取本地插件仓库目录中的插件信息 """ plugins = [] - installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] - local_candidates = PluginHelper().get_local_plugin_candidates() + installed_apps = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] + local_candidates = get_plugin_system().local_candidates() if not local_candidates: return [] for pid, plugin_info in local_candidates.items(): @@ -1587,7 +1363,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): plugin = self._process_plugin_info( pid=pid, plugin_info=plugin_info, - market=PluginHelper.make_local_repo_url( + market=get_plugin_system().local_repo_url( pid, plugin_info.get("repo_path"), package_version @@ -1639,7 +1415,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): def get_plugins_from_market(self, market: str, package_version: Optional[str] = None, - force: bool = False) -> Optional[List[schemas.Plugin]]: + force: bool = False) -> Optional[List[_SchemaPlugin]]: """ 从指定的市场获取插件信息 :param market: 市场的 URL 或标识 @@ -1647,81 +1423,26 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param force: 是否强制刷新(忽略缓存) :return: 返回插件的列表,若获取失败返回 [] """ - if not market: - return [] - # 已安装插件 - installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] - # 获取在线插件 - with fresh(force): - online_plugins = PluginHelper().get_plugins(market, package_version) - if online_plugins is None: - logger.warning( - f"获取{package_version if package_version else ''}插件库失败:{market},请检查 GitHub 网络连接") - return [] - ret_plugins = [] - add_time = len(online_plugins) - for pid, plugin_info in online_plugins.items(): - plugin = self._process_plugin_info(pid, plugin_info, market, installed_apps, add_time, package_version) - if plugin: - ret_plugins.append(plugin) - add_time -= 1 + return self._plugin_catalog().load(market, package_version, force) - return ret_plugins - - @staticmethod - def process_plugins_list(higher_version_plugins: List[schemas.Plugin], - base_version_plugins: List[schemas.Plugin]) -> List[schemas.Plugin]: + def process_plugins_list(self, higher_version_plugins: List[_SchemaPlugin], + base_version_plugins: List[_SchemaPlugin]) -> List[_SchemaPlugin]: """ 处理插件列表:合并、去重、排序、保留最高版本 :param higher_version_plugins: 高版本插件列表 :param base_version_plugins: 基础版本插件列表 :return: 处理后的插件列表 """ - # 优先处理高版本插件 - all_plugins = [] - all_plugins.extend(higher_version_plugins) - # 将未出现在高版本插件列表中的 v1 插件加入 all_plugins - higher_plugin_ids = {f"{p.id}{p.plugin_version}" for p in higher_version_plugins} - all_plugins.extend([p for p in base_version_plugins if f"{p.id}{p.plugin_version}" not in higher_plugin_ids]) markets = [item for item in settings.PLUGIN_MARKET.split(",") if item] - - def repo_order(plugin: schemas.Plugin) -> int: - if PluginHelper.is_local_repo_url(plugin.repo_url): - return len(markets) + 1 - if plugin.repo_url in markets: - return markets.index(plugin.repo_url) - return len(markets) - - # 去重:同 ID + 版本优先保留市场来源,其次按来源顺序稳定保留。 - dedup_plugins = {} - for plugin in sorted(all_plugins, key=repo_order): - key = f"{plugin.id}{plugin.plugin_version}" - exists = dedup_plugins.get(key) - if not exists: - dedup_plugins[key] = plugin - continue - if PluginHelper.is_local_repo_url(exists.repo_url) and not PluginHelper.is_local_repo_url(plugin.repo_url): - dedup_plugins[key] = plugin - - # 相同 ID 的插件保留版本号最大的版本;同版本市场来源优先。 - result_by_id = {} - for plugin in sorted(dedup_plugins.values(), key=repo_order): - exists = result_by_id.get(plugin.id) - if not exists: - result_by_id[plugin.id] = plugin - continue - if compare_version(plugin.plugin_version, ">", exists.plugin_version): - result_by_id[plugin.id] = plugin - elif plugin.plugin_version == exists.plugin_version \ - and PluginHelper.is_local_repo_url(exists.repo_url) \ - and not PluginHelper.is_local_repo_url(plugin.repo_url): - result_by_id[plugin.id] = plugin - - return list(result_by_id.values()) + return self._plugin_catalog().merge( + higher_version_plugins, + base_version_plugins, + markets, + ) def _process_plugin_info(self, pid: str, plugin_info: dict, market: str, installed_apps: List[str], add_time: int, - package_version: Optional[str] = None) -> Optional[schemas.Plugin]: + package_version: Optional[str] = None) -> Optional[_SchemaPlugin]: """ 处理单个插件信息,创建 schemas.Plugin 对象 :param pid: 插件ID @@ -1735,19 +1456,21 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): if not isinstance(plugin_info, dict): return None - plugin_info = PluginHelper.annotate_plugin_system_version(plugin_info.copy()) - if not PluginHelper.is_package_plugin_compatible( + plugin_info = get_plugin_system().annotate_system_version( + plugin_info.copy() + ) + if not get_plugin_system().is_package_compatible( plugin_info, package_version or "" ): # 插件当前版本不兼容 return None # 运行状插件 - plugin_obj = self._running_plugins.get(pid) + plugin_obj = self._plugin_registry.instance(pid) # 非运行态插件 - plugin_static = self._plugins.get(pid) + plugin_static = self._plugin_registry.plugin_class(pid) # 基本属性 - plugin = schemas.Plugin() + plugin = _SchemaPlugin() # ID plugin.id = pid # 安装状态 @@ -1780,9 +1503,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): plugin.state = False # 是否有详情页面 plugin.has_page = False - if plugin_obj and hasattr(plugin_obj, "get_page"): - if ObjectUtils.check_method(plugin_obj.get_page): - plugin.has_page = True + if plugin_obj and supports_plugin_hook(plugin_obj, "get_page"): + plugin.has_page = True # 公钥 if plugin_info.get("key"): plugin.plugin_public_key = plugin_info.get("key") @@ -1840,7 +1562,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): self, force: bool = False, progress_callback: Optional[Callable[..., None]] = None, - ) -> List[schemas.Plugin]: + ) -> List[_SchemaPlugin]: """ 异步获取所有在线插件信息 :param force: 是否强制刷新(忽略缓存) @@ -1850,95 +1572,22 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): if progress_callback: progress_callback(value=100, text="未配置插件市场,跳过刷新") return [] - - async def fetch_market( - market: str, - package_version: Optional[str], - result_version: str, - task_index: int, - ) -> Tuple[int, str, List[schemas.Plugin]]: - """ - 获取单个市场版本的插件列表并保留结果分组。 - """ - plugins = await self.async_get_plugins_from_market( - market, - package_version, - force, - ) - return task_index, result_version, plugins or [] - - higher_version_plugins = [] - base_version_plugins = [] - tasks = [] - - # 拉取当前索引及可扫描的旧索引;旧条目可用当前版本 false 显式排除。 - compatible_flags = ( - [settings.VERSION_FLAG] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []) - if settings.VERSION_FLAG else [] + compatible_flags = get_plugin_system().compatible_flags( + settings.VERSION_FLAG + ) + result = await self._plugin_catalog().async_collect( + markets=[item for item in settings.PLUGIN_MARKET.split(",") if item], + compatible_flags=compatible_flags, + force=force, + loader=self.async_get_plugins_from_market, + progress_callback=progress_callback, ) - for market in settings.PLUGIN_MARKET.split(","): - if not market: - continue - tasks.append( - asyncio.create_task( - fetch_market(market, None, "base_version", len(tasks)) - ) - ) - for flag in compatible_flags: - tasks.append( - asyncio.create_task( - fetch_market( - market, - flag, - "higher_version", - len(tasks), - ) - ) - ) - - if tasks: - total_tasks = len(tasks) - finished_tasks = 0 - task_results = {} - if progress_callback: - progress_callback( - value=0, - text=f"开始刷新插件市场,共 {total_tasks} 个请求 ...", - data={"total": total_tasks, "finished": 0}, - ) - for completed_task in asyncio.as_completed(tasks): - try: - task_index, version, plugins = await completed_task - task_results[task_index] = (version, plugins) - except Exception as err: - logger.error(f"获取插件市场数据失败:{str(err)}") - finished_tasks += 1 - if progress_callback: - progress_callback( - value=finished_tasks / total_tasks * 100, - text=( - f"插件市场请求" - f"({finished_tasks}/{total_tasks})处理完成" - ), - data={"total": total_tasks, "finished": finished_tasks}, - ) - for task_index in sorted(task_results): - version, plugins = task_results[task_index] - if plugins: - if version == "higher_version": - higher_version_plugins.extend(plugins) - else: - base_version_plugins.extend(plugins) - - result = self.process_plugins_list(higher_version_plugins, base_version_plugins) logger.info(f"获取到 {len(result)} 个线上插件") - if progress_callback: - progress_callback(value=100, text="插件市场缓存刷新完成") return result async def async_get_plugins_from_market(self, market: str, package_version: Optional[str] = None, - force: bool = False) -> Optional[List[schemas.Plugin]]: + force: bool = False) -> Optional[List[_SchemaPlugin]]: """ 异步从指定的市场获取插件信息 :param market: 市场的 URL 或标识 @@ -1946,29 +1595,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param force: 是否强制刷新(忽略缓存) :return: 返回插件的列表,若获取失败返回 [] """ - if not market: - return [] - # 已安装插件 - installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] - # 获取在线插件 - async with async_fresh(force): - online_plugins = await PluginHelper().async_get_plugins(market, package_version) - if online_plugins is None: - logger.warning( - f"获取{package_version if package_version else ''}插件库失败:{market},请检查 GitHub 网络连接") - return [] - ret_plugins = [] - add_time = len(online_plugins) - for pid, plugin_info in online_plugins.items(): - plugin = self._process_plugin_info(pid, plugin_info, market, installed_apps, add_time, package_version) - if plugin: - ret_plugins.append(plugin) - add_time -= 1 - - return ret_plugins + return await self._plugin_catalog().async_load( + market, + package_version, + force, + ) @staticmethod - def __set_and_check_auth_level(plugin: Union[schemas.Plugin, Type[Any]], + def __set_and_check_auth_level(plugin: Union[_SchemaPlugin, Type[Any]], source: Optional[Union[dict, Type[Any]]] = None) -> bool: """ 设置并检查插件的认证级别 @@ -1994,7 +1628,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): # 如果当前站点认证级别大于 1 且插件级别为 99,并存在插件公钥,说明为特殊密钥认证,通过密钥匹配进行认证 auth_level = _site_auth_level_provider() if auth_level > 1 and plugin.auth_level == 99 and hasattr(plugin, "plugin_public_key"): - plugin_id = plugin.id if isinstance(plugin, schemas.Plugin) else plugin.__name__ + plugin_id = plugin.id if isinstance(plugin, _SchemaPlugin) else plugin.__name__ public_key = plugin.plugin_public_key if public_key: private_key = PluginManager.__get_plugin_private_key(plugin_id) @@ -2049,42 +1683,29 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): if self.is_plugin_exists(clone_id): return False, f"分身插件 {clone_id} 已存在" - # 获取原插件目录 - original_plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower() - if not original_plugin_dir.exists(): - return False, f"原插件目录 {original_plugin_dir} 不存在" + original_plugin_class = self._plugins.get(plugin_id) + if not original_plugin_class: + return False, f"无法获取原插件类 {plugin_id}" - # 创建分身插件目录 - clone_plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / clone_id.lower() - - # 复制插件目录 - import shutil - shutil.copytree(original_plugin_dir, clone_plugin_dir) - logger.info(f"已复制插件目录:{original_plugin_dir} -> {clone_plugin_dir}") - - # 修改插件文件内容 - success, msg = self._modify_plugin_files( - plugin_dir=clone_plugin_dir, - original_id=plugin_id, + success, msg = get_plugin_system().package.clone( + plugin_id=plugin_id, + clone_id=clone_id, + original_class_name=original_plugin_class.__name__, suffix=suffix.lower(), name=name, description=description, version=version, - icon=icon + icon=icon, ) - if not success: - # 如果修改失败,清理已创建的目录 - if clone_plugin_dir.exists(): - shutil.rmtree(clone_plugin_dir) return False, msg # 将分身插件添加到已安装列表 - systemconfig = SystemConfigOper() - installed_plugins = systemconfig.get(SystemConfigKey.UserInstalledPlugins) or [] + storage = get_plugin_storage() + installed_plugins = storage.read(SystemConfigKey.UserInstalledPlugins) or [] if clone_id not in installed_plugins: installed_plugins.append(clone_id) - systemconfig.set(SystemConfigKey.UserInstalledPlugins, installed_plugins) + storage.write(SystemConfigKey.UserInstalledPlugins, installed_plugins) # 为分身插件创建初始配置(从原插件复制配置) logger.info(f"正在为分身插件 {clone_id} 创建初始配置...") @@ -2124,7 +1745,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): name: str, description: str, version: str = None, icon: str = None) -> Tuple[bool, str]: """ - 修改插件文件中的类名和相关信息 + 兼容旧内部调用,将分身文件改写委托给包适配器。 :param plugin_dir: 插件目录 :param original_id: 原插件ID :param suffix: 分身后缀 @@ -2134,221 +1755,54 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param icon: 自定义图标URL :return: (是否成功, 错误信息) """ - try: - # 获取原插件类 - original_plugin_class = self._plugins.get(original_id) - if not original_plugin_class: - return False, f"无法获取原插件类 {original_id}" - - # 获取原类名 - original_class_name = original_plugin_class.__name__ - clone_class_name = f"{original_class_name}{suffix}" - - # 修改 __init__.py 文件 - init_file = plugin_dir / "__init__.py" - if init_file.exists(): - success, msg = self._modify_python_file( - file_path=init_file, - original_class_name=original_class_name, - clone_class_name=clone_class_name, - name=name, - description=description, - version=version, - icon=icon - ) - if not success: - return False, msg - - # 检查是否为联邦插件(存在dist目录) - dist_dir = plugin_dir / "dist" - if dist_dir.exists(): - success, msg = self._modify_federation_files( - dist_dir=dist_dir, - original_class_name=original_class_name, - clone_class_name=clone_class_name - ) - if not success: - return False, msg - - return True, "文件修改成功" - - except Exception as e: - logger.error(f"修改插件文件失败:{str(e)}") - return False, f"修改插件文件失败:{str(e)}" + original_plugin_class = self._plugins.get(original_id) + if not original_plugin_class: + return False, f"无法获取原插件类 {original_id}" + return get_plugin_system().package._modify_plugin_files( + plugin_dir=plugin_dir, + original_class_name=original_plugin_class.__name__, + suffix=suffix, + name=name, + description=description, + version=version, + icon=icon, + ) @staticmethod def _modify_python_file(file_path: Path, original_class_name: str, clone_class_name: str, name: str, description: str, version: str = None, icon: str = None) -> Tuple[bool, str]: """ - 修改Python文件中的类名和插件信息 + 兼容旧内部调用,将 Python 文件改写委托给包适配器。 """ - try: - with open(file_path, 'r', encoding='utf-8', errors='replace') as f: - content = f.read() - - # 替换类名 - content = content.replace(f"class {original_class_name}", f"class {clone_class_name}") - - # 替换插件名称和描述 - import re - - # 替换 plugin_name - if name: - content = re.sub( - r'plugin_name\s*=\s*["\'][^"\']*["\']', - f'plugin_name = "{name}"', - content - ) - - # 替换 plugin_desc - if description: - content = re.sub( - r'plugin_desc\s*=\s*["\'][^"\']*["\']', - f'plugin_desc = "{description}"', - content - ) - - # 替换 plugin_config_prefix(如果存在) - content = re.sub( - r'plugin_config_prefix\s*=\s*["\'][^"\']*["\']', - f'plugin_config_prefix = "{clone_class_name.lower()}_"', - content - ) - - # 替换 plugin_version(如果提供了自定义版本) - if version: - content = re.sub( - r'plugin_version\s*=\s*["\'][^"\']*["\']', - f'plugin_version = "{version}"', - content - ) - - # 替换 plugin_icon(如果提供了自定义图标) - if icon and icon.strip(): - old_content = content - content = re.sub( - r'plugin_icon\s*=\s*["\'][^"\']*["\']', - f'plugin_icon = "{icon}"', - content - ) - if old_content != content: - logger.info(f"已替换插件图标为: {icon}") - else: - logger.warning(f"插件图标替换失败,未找到匹配的图标设置") - else: - logger.info("未提供自定义图标,保持原插件图标") - - # 添加分身标志 - if "def init_plugin(self" in content: - init_index = content.index("def init_plugin(self") - # 在 def init_plugin(self 前添加 is_clone = True - content = content[:init_index] + "is_clone = True\n\n " + content[init_index:] - - with open(file_path, 'w', encoding='utf-8') as f: - f.write(content) - - logger.debug(f"已修改Python文件:{file_path}") - return True, "Python文件修改成功" - - except Exception as e: - logger.error(f"修改Python文件失败:{str(e)}") - return False, f"修改Python文件失败:{str(e)}" + return get_plugin_system().package._modify_python_file( + file_path=file_path, + original_class_name=original_class_name, + clone_class_name=clone_class_name, + name=name, + description=description, + version=version, + icon=icon, + ) def _modify_federation_files(self, dist_dir: Path, original_class_name: str, clone_class_name: str) -> Tuple[bool, str]: """ - 修改联邦插件的前端文件 + 兼容旧内部调用,将联邦文件改写委托给包适配器。 """ - try: - # 获取原始插件名(从类名推导) - original_plugin_name = original_class_name - clone_plugin_name = clone_class_name - - # 遍历dist目录下的所有文件 - for file_path in dist_dir.rglob("*"): - if not file_path.is_file(): - continue - - # 处理JS文件 - if file_path.suffix == '.js': - try: - with open(file_path, 'r', encoding='utf-8', errors='replace') as f: - content = f.read() - - # 替换类名引用(精确匹配) - content = content.replace(original_class_name, clone_class_name) - # 替换插件名引用(如果存在) - content = content.replace(f'"{original_plugin_name}"', f'"{clone_plugin_name}"') - content = content.replace(f"'{original_plugin_name}'", f"'{clone_plugin_name}'") - # 替换CSS key中的类名(联邦插件特有) - content = content.replace(f'css__{original_class_name}__', f'css__{clone_class_name}__') - # 替换可能的小写类名引用 - content = content.replace(original_class_name.lower(), clone_class_name.lower()) - - with open(file_path, 'w', encoding='utf-8') as f: - f.write(content) - - logger.debug(f"已修改联邦插件JS文件:{file_path}") - - except Exception as e: - logger.warning(f"修改联邦插件文件 {file_path} 失败:{str(e)}") - continue - - # 处理CSS文件 - elif file_path.suffix == '.css': - try: - with open(file_path, 'r', encoding='utf-8', errors='replace') as f: - content = f.read() - - # 替换CSS中可能的类名引用 - content = content.replace(original_class_name.lower(), - clone_class_name.lower()).replace(original_class_name, - clone_class_name) - - with open(file_path, 'w', encoding='utf-8') as f: - f.write(content) - - logger.debug(f"已修改联邦插件CSS文件:{file_path}") - - except Exception as e: - logger.warning(f"修改联邦插件CSS文件 {file_path} 失败:{str(e)}") - continue - - # 重命名构建文件(如果需要) - self._rename_federation_assets(dist_dir, original_class_name, clone_class_name) - - return True, "联邦插件文件修改完成" - - except Exception as e: - logger.error(f"修改联邦插件文件失败:{str(e)}") - return False, f"修改联邦插件文件失败:{str(e)}" + return get_plugin_system().package._modify_federation_files( + dist_dir=dist_dir, + original_class_name=original_class_name, + clone_class_name=clone_class_name, + ) @staticmethod def _rename_federation_assets(dist_dir: Path, original_class_name: str, clone_class_name: str): """ - 重命名联邦插件的资源文件,避免文件名冲突 + 兼容旧内部调用,将资源重命名委托给包适配器。 """ - try: - # 查找包含原类名的文件并重命名 - for file_path in dist_dir.glob("*"): - if not file_path.is_file(): - continue - - file_name = file_path.name - # 如果文件名包含原类名,则重命名 - if original_class_name.lower() in file_name.lower(): - new_name = file_name.replace( - original_class_name.lower(), - clone_class_name.lower() - ) - new_path = file_path.parent / new_name - - # 避免重命名冲突 - if not new_path.exists(): - file_path.rename(new_path) - logger.debug(f"重命名联邦插件文件:{file_name} -> {new_name}") - - except Exception as e: - # 重命名失败不影响整体流程 - logger.warning(f"重命名联邦插件资源文件失败:{str(e)}") + get_plugin_system().package._rename_federation_assets( + dist_dir, + original_class_name, + clone_class_name, + ) diff --git a/app/runtime/extensions/service_config.py b/app/runtime/extensions/service_config.py index 3c45795cb..c752e93f3 100644 --- a/app/runtime/extensions/service_config.py +++ b/app/runtime/extensions/service_config.py @@ -1,25 +1,40 @@ -from typing import List, Optional, Type +from collections.abc import Callable +from typing import Any, List, Optional, Type from pydantic import ValidationError -from app.db.oper.systemconfig import SystemConfigOper from app.runtime.log import logger -from app.schemas import ( - DownloaderConf, - MediaServerConf, - NotificationConf, - NotificationSwitchConf, -) +from app.schemas.system import DownloaderConf +from app.schemas.system import MediaServerConf +from app.schemas.system import NotificationConf +from app.schemas.system import NotificationSwitchConf from app.schemas.types import MessageType, SystemConfigKey +ServiceConfigReader = Callable[[SystemConfigKey], Any] + + +def _empty_service_config(_config_key: SystemConfigKey) -> Any: + """组合根尚未装配时返回空服务配置。""" + return None + + +_service_config_reader: ServiceConfigReader = _empty_service_config + + +def configure_service_config_reader(reader: ServiceConfigReader) -> None: + """由启动组合根注入服务配置读取能力。""" + global _service_config_reader + _service_config_reader = reader + + class ServiceConfigHelper: """读取并校验通知、下载器和媒体服务器的宿主配置。""" @staticmethod def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List: """按指定 Schema 过滤单条非法配置,避免影响同组其它服务。""" - config_data = SystemConfigOper().get(config_key) + config_data = _service_config_reader(config_key) if not config_data: return [] configs = [] diff --git a/app/runtime/extensions/service_registry.py b/app/runtime/extensions/service_registry.py index 610953501..455fc0f54 100644 --- a/app/runtime/extensions/service_registry.py +++ b/app/runtime/extensions/service_registry.py @@ -1,9 +1,8 @@ from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator -from app.db.oper.systemconfig import SystemConfigOper from app.runtime.extensions.module_manager import ModuleManager from app.runtime.extensions.service_config import ServiceConfigHelper -from app.schemas import ServiceInfo +from app.schemas.system import ServiceInfo from app.schemas.types import SystemConfigKey, ModuleType TConf = TypeVar("TConf") @@ -11,7 +10,6 @@ TConf = TypeVar("TConf") __all__ = [ "ServiceBaseHelper", "ServiceConfigHelper", - "SystemConfigOper", ] diff --git a/app/runtime/rate.py b/app/runtime/rate.py index e0949d6d2..2d1edce5a 100644 --- a/app/runtime/rate.py +++ b/app/runtime/rate.py @@ -6,7 +6,8 @@ from collections import deque from typing import Any, Tuple, List, Callable, Optional from app.runtime.log import logger -from app.schemas import RateLimitExceededException, LimitException +from app.schemas.exception import RateLimitExceededException +from app.schemas.exception import LimitException # 抽象基类 diff --git a/app/scheduler.py b/app/scheduler.py index e1cb35824..51c2daf68 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -2,22 +2,20 @@ import asyncio import gc import hashlib import inspect -import json import multiprocessing import threading import traceback from datetime import datetime, timedelta -from typing import Callable, Optional, Dict, Any -from typing import List +from typing import Callable, Optional, Dict, Any, List import pytz from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.jobstores.base import JobLookupError from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger -from sqlalchemy.orm import Session - -from app import schemas +from app.schemas.dashboard import ScheduleInfo as _SchemaScheduleInfo +from app.schemas.dashboard import ScheduleProgress as _SchemaScheduleProgress +from app.schemas.system import MediaServerConf as _SchemaMediaServerConf from app.chain import ChainBase from app.chain.mediaserver import MediaServerChain from app.chain.recommend import RecommendChain @@ -28,14 +26,9 @@ from app.chain.workflow import WorkflowChain from app.runtime.config import settings, global_vars from app.runtime.events import Event, eventmanager from app.runtime.extensions.plugin_manager import PluginManager -from app.db import SessionFactory from app.db.oper.agenttask import AgentTaskOper -from app.db.models.downloadhistory import DownloadHistory, DownloadFiles -from app.db.models.downloadfailure import DownloadFailure -from app.db.models.message import Message as MessageModel -from app.db.models.siteuserdata import SiteUserData -from app.db.models.transferhistory import TransferHistory from app.db.oper.systemconfig import SystemConfigOper +from app.application.maintenance import build_cleanup_service from app.application.image import WallpaperHelper from app.application.messaging.message import MessageHelper from app.runtime.progress import ProgressHelper @@ -43,7 +36,9 @@ from app.adapters.external.server import MoviePilotServerHelper from app.runtime.extensions.service_registry import ServiceConfigHelper from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.runtime.log import logger -from app.schemas import Message, MessageType, Workflow +from app.schemas.message import Message +from app.schemas.message import MessageType +from app.schemas.workflow import Workflow from app.schemas.types import EventType, SystemConfigKey from app.runtime.gc import get_memory_usage from app.runtime.reload import ConfigReloadMixin @@ -60,7 +55,7 @@ class SchedulerChain(ChainBase): """ 定时任务链,负责执行各类定时任务,包括数据清理等 """ - # 每批处理的记录数,避免一次性删除过多数据导致性能问题 + # 保留旧常量,插件和维护脚本如有引用无需跟随内部职责迁移。 DEFAULT_BATCH_SIZE = 500 def cleanup( @@ -71,224 +66,10 @@ class SchedulerChain(ChainBase): """ 按配置保留期执行分批清理。 """ - started_at = datetime.now() - batch_size = batch_size or self.DEFAULT_BATCH_SIZE - if batch_size <= 0: - batch_size = self.DEFAULT_BATCH_SIZE - - report: Dict[str, Any] = { - "started_at": started_at.strftime("%Y-%m-%d %H:%M:%S"), - "batch_size": batch_size, - "enabled": bool(settings.DATA_CLEANUP_ENABLE), - "tables": {}, - "total_deleted": 0, - } - - if not settings.DATA_CLEANUP_ENABLE: - report["skipped_reason"] = "disabled" - logger.info("数据表清理总开关未开启,跳过执行") - return report - - errors = [] - - plans = self._build_cleanup_plans(started_at=started_at, batch_size=batch_size) - total_plans = len(plans) - if progress_callback: - progress_callback(value=0, text="开始清理数据表 ...") - - with SessionFactory() as db: - for plan_index, plan in enumerate(plans): - name = plan["name"] - retention_days = plan["retention_days"] - if retention_days <= 0: - report["tables"][name] = { - "deleted": 0, - "batches": 0, - "cutoff": None, - "retention_days": retention_days, - "skipped": True, - "reason": "retention_days<=0", - } - if progress_callback: - progress_callback( - value=(plan_index + 1) / total_plans * 100, - text=f"数据表 {name} 跳过清理", - ) - continue - - try: - if progress_callback: - progress_callback( - value=plan_index / total_plans * 100, - text=f"正在清理数据表 {name} ...", - ) - table_report = self._cleanup_in_batches( - db=db, - table_name=name, - delete_batch=plan["handler"], - ) - table_report["cutoff"] = plan["cutoff"] - table_report["retention_days"] = retention_days - report["tables"][name] = table_report - report["total_deleted"] += table_report["deleted"] - except Exception as err: - errors.append(f"{name}: {str(err)}") - logger.error(f"数据表 {name} 清理失败:{str(err)}") - report["tables"][name] = { - "deleted": 0, - "batches": 0, - "cutoff": plan["cutoff"], - "retention_days": retention_days, - "error": str(err), - } - finally: - if progress_callback: - progress_callback( - value=(plan_index + 1) / total_plans * 100, - text=f"数据表 {name} 清理处理完成", - ) - - if errors: - report["errors"] = errors - logger.error( - f"数据表清理部分失败:{json.dumps(report, ensure_ascii=False)}" - ) - raise RuntimeError(";".join(errors)) - - logger.info(f"数据表清理完成:{json.dumps(report, ensure_ascii=False)}") - return report - - @staticmethod - def _normalize_retention_days(retention_days: Any) -> int: - try: - normalized_days = int(retention_days or 0) - except (TypeError, ValueError): - return 0 - return max(normalized_days, 0) - - def _build_cleanup_plans( - self, - started_at: datetime, - batch_size: int, - ) -> List[Dict[str, Any]]: - message_days = self._normalize_retention_days(settings.DATA_CLEANUP_MESSAGE_DAYS) - download_history_days = self._normalize_retention_days( - settings.DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS + return build_cleanup_service().execute( + batch_size=batch_size, + progress_callback=progress_callback, ) - site_userdata_days = self._normalize_retention_days( - settings.DATA_CLEANUP_SITE_USERDATA_DAYS - ) - transfer_history_days = self._normalize_retention_days( - settings.DATA_CLEANUP_TRANSFER_HISTORY_DAYS - ) - download_failure_days = self._normalize_retention_days( - settings.DATA_CLEANUP_DOWNLOAD_FAILURE_DAYS - ) - - message_cutoff = ( - started_at - timedelta(days=message_days) - ).strftime("%Y-%m-%d") - download_history_cutoff = ( - started_at - timedelta(days=download_history_days) - ).strftime("%Y-%m-%d") - site_userdata_cutoff = ( - started_at - timedelta(days=site_userdata_days) - ).strftime("%Y-%m-%d") - transfer_history_cutoff = ( - started_at - timedelta(days=transfer_history_days) - ).strftime("%Y-%m-%d") - download_failure_cutoff = ( - started_at - timedelta(days=download_failure_days) - ).strftime("%Y-%m-%d %H:%M:%S") - - return [ - { - "name": "message", - "retention_days": message_days, - "cutoff": message_cutoff, - "handler": lambda db: MessageModel.delete_before( - db=db, - before_time=message_cutoff, - limit=batch_size, - ), - }, - { - "name": "downloadhistory", - "retention_days": download_history_days, - "cutoff": download_history_cutoff, - "handler": lambda db: DownloadHistory.delete_before( - db=db, - before_time=download_history_cutoff, - limit=batch_size, - ), - }, - { - "name": "downloadfiles", - "retention_days": download_history_days, - "cutoff": "follow-parent-history", - "handler": lambda db: DownloadFiles.delete_orphans( - db=db, - limit=batch_size, - ), - }, - { - "name": "siteuserdata", - "retention_days": site_userdata_days, - "cutoff": site_userdata_cutoff, - "handler": lambda db: SiteUserData.delete_before( - db=db, - before_day=site_userdata_cutoff, - limit=batch_size, - ), - }, - { - "name": "transferhistory", - "retention_days": transfer_history_days, - "cutoff": transfer_history_cutoff, - "handler": lambda db: TransferHistory.delete_before( - db=db, - before_time=transfer_history_cutoff, - limit=batch_size, - ), - }, - { - "name": "downloadfailure", - "retention_days": download_failure_days, - "cutoff": download_failure_cutoff, - "handler": lambda db: DownloadFailure.delete_expired( - db=db, - before_time=download_failure_cutoff, - limit=batch_size, - ), - }, - ] - - @staticmethod - def _cleanup_in_batches( - db: Session, - table_name: str, - delete_batch: Callable[[Session], int], - ) -> Dict[str, int]: - """ - 循环执行单表分批删除,直到没有可删除数据。 - """ - total_deleted = 0 - batches = 0 - - while True: - deleted = delete_batch(db) or 0 - if deleted <= 0: - break - batches += 1 - total_deleted += deleted - logger.info( - f"数据表 {table_name} 清理第 {batches} 批完成,删除 {deleted} 条记录" - ) - - return { - "deleted": total_deleted, - "batches": batches, - } class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): @@ -347,7 +128,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): @staticmethod def _get_mediaserver_sync_interval( - mediaserver: schemas.MediaServerConf, + mediaserver: _SchemaMediaServerConf, default_interval: Optional[int], ) -> Optional[int]: """ @@ -365,7 +146,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): @classmethod def _build_mediaserver_sync_schedules( cls, - mediaservers: List[schemas.MediaServerConf], + mediaservers: List[_SchemaMediaServerConf], default_interval: Optional[int], ) -> List[dict]: """ @@ -863,7 +644,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): value=progress_value, ) - def get_progress(self, job_id: str) -> Optional[schemas.ScheduleProgress]: + def get_progress(self, job_id: str) -> Optional[_SchemaScheduleProgress]: """ 查询指定定时服务的执行进度。 """ @@ -886,7 +667,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): value = float(value) except (TypeError, ValueError): value = 0.0 - return schemas.ScheduleProgress( + return _SchemaScheduleProgress( id=job_id, name=data.get("name") or job_name, provider=data.get("provider") or provider_name, @@ -1450,7 +1231,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): role="system", ) - def list(self) -> List[schemas.ScheduleInfo]: + def list(self) -> List[_SchemaScheduleInfo]: """ 当前所有任务 """ @@ -1476,7 +1257,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): added.append(job_id) progress = self.get_progress(job_id) schedulers.append( - schemas.ScheduleInfo( + _SchemaScheduleInfo( id=job_id, name=name, provider=provider_name, @@ -1503,7 +1284,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): next_run = TimerUtils.time_difference(job.next_run_time) progress = self.get_progress(job_id) schedulers.append( - schemas.ScheduleInfo( + _SchemaScheduleInfo( id=job_id, name=job.name, provider=service.get("provider_name", "[系统]"), @@ -1524,7 +1305,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): added.append(job_id) progress = self.get_progress(job_id) schedulers.append( - schemas.ScheduleInfo( + _SchemaScheduleInfo( id=job_id, name=service.get("name"), provider=service.get("provider_name", "[系统]"), diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index fa4516219..29bc78482 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -1,36 +1,30 @@ -from .agent import * -from .cache import * -from .category import * -from .common import * -from .context import * -from .dashboard import * -from .download import * -from .event import * -from .exception import * -from .file import * -from .history import * -from .llm import * -from .mediaserver import * -from .message import * -from .mfa import * -from .music import * -from .monitoring import * -from .notification import * -from .plugin import * -from .response import * -from .rule import * -from .search import * -from .storage import * -from .openai import * -from .servarr import * -from .servcookie import * -from .site import * -from .subscribe import * -from .system import * -from .system import * -from .tmdb import * -from .token import * -from .transfer import * -from .user import * -from .workflow import * -from .mcp import * +"""Schema 根包的惰性兼容导出入口。 + +公开符号清单由 ``scripts/schema/exports.py`` 从各 schema 子模块生成。 +旧的 ``app.schemas.X`` 与 ``from app.schemas import X`` 路径保持不变,但仅在首次 +访问具体符号时加载其所有者模块,避免任意 schema 导入触发完整模型图。 +""" + +from importlib import import_module as _import_module +from typing import Any as _Any + +from app.schemas.exports import SCHEMA_EXPORTS as _SCHEMA_EXPORTS + + +def __getattr__(name: str) -> _Any: + """按生成清单惰性解析并缓存 schema 公开符号。""" + contract = _SCHEMA_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) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + """返回兼容公开面,供 IDE、文档与交互式检查使用。""" + return sorted({*globals(), *_SCHEMA_EXPORTS}) + + +__all__ = sorted(_SCHEMA_EXPORTS) diff --git a/app/schemas/agent.py b/app/schemas/agent.py index 65f5cdab8..3ee3113d3 100644 --- a/app/schemas/agent.py +++ b/app/schemas/agent.py @@ -1,13 +1,12 @@ """AI智能体相关数据模型""" from datetime import datetime -from typing import Any, List, Literal, Optional, Union +from typing import List, Literal, Optional, Union from langchain_core.messages import BaseMessage from pydantic import BaseModel, Field, ConfigDict, field_serializer from app.schemas.common import JsonData -from app.schemas.types import ReplyMode class ConversationMemory(BaseModel): diff --git a/app/schemas/exports.py b/app/schemas/exports.py new file mode 100644 index 000000000..aabc008a1 --- /dev/null +++ b/app/schemas/exports.py @@ -0,0 +1,445 @@ +"""由 scripts/schema/exports.py 生成,请勿手工编辑。""" + +SCHEMA_EXPORTS = { + 'APIRateLimitException': ('app.schemas.exception', 'APIRateLimitException'), + 'Action': ('app.schemas.workflow', 'Action'), + 'ActionContext': ('app.schemas.workflow', 'ActionContext'), + 'ActionContract': ('app.schemas.workflow', 'ActionContract'), + 'ActionContractField': ('app.schemas.workflow', 'ActionContractField'), + 'ActionExecution': ('app.schemas.workflow', 'ActionExecution'), + 'ActionFlow': ('app.schemas.workflow', 'ActionFlow'), + 'ActionParams': ('app.schemas.workflow', 'ActionParams'), + 'ActionPosition': ('app.schemas.workflow', 'ActionPosition'), + 'ActionResult': ('app.schemas.workflow', 'ActionResult'), + 'ActionRetry': ('app.schemas.workflow', 'ActionRetry'), + 'AgentChatAttachment': ('app.schemas.agent', 'AgentChatAttachment'), + 'AgentChatChoiceButton': ('app.schemas.agent', 'AgentChatChoiceButton'), + 'AgentChatChoiceCard': ('app.schemas.agent', 'AgentChatChoiceCard'), + 'AgentChatChoiceSelection': ('app.schemas.agent', 'AgentChatChoiceSelection'), + 'AgentChatDisplaySaveRequest': ('app.schemas.agent', 'AgentChatDisplaySaveRequest'), + 'AgentChatMessage': ('app.schemas.agent', 'AgentChatMessage'), + 'AgentChatMessageSegment': ('app.schemas.agent', 'AgentChatMessageSegment'), + 'AgentChatSession': ('app.schemas.agent', 'AgentChatSession'), + 'AgentChatSessionDetail': ('app.schemas.agent', 'AgentChatSessionDetail'), + 'AgentChatSessionSummary': ('app.schemas.agent', 'AgentChatSessionSummary'), + 'AgentChatToolCall': ('app.schemas.agent', 'AgentChatToolCall'), + 'AgentChatUploadAttachment': ('app.schemas.agent', 'AgentChatUploadAttachment'), + 'AgentLLMProviderEventData': ('app.schemas.event', 'AgentLLMProviderEventData'), + 'AgentMcpServerConfig': ('app.schemas.agent', 'AgentMcpServerConfig'), + 'AgentMcpServerListData': ('app.schemas.agent', 'AgentMcpServerListData'), + 'AgentMcpServerTestRequest': ('app.schemas.agent', 'AgentMcpServerTestRequest'), + 'AgentMcpServerTestResult': ('app.schemas.agent', 'AgentMcpServerTestResult'), + 'AgentMcpServerToolInfo': ('app.schemas.agent', 'AgentMcpServerToolInfo'), + 'AgentMcpServersSaveRequest': ('app.schemas.agent', 'AgentMcpServersSaveRequest'), + 'AgentSessionStopData': ('app.schemas.agent', 'AgentSessionStopData'), + 'AgentState': ('app.schemas.agent', 'AgentState'), + 'AgentTokensUsageEventData': ('app.schemas.event', 'AgentTokensUsageEventData'), + 'AgentWebCallbackData': ('app.schemas.agent', 'AgentWebCallbackData'), + 'AgentWebChatRequest': ('app.schemas.message', 'AgentWebChatRequest'), + 'AgentWebChoiceFeedback': ('app.schemas.agent', 'AgentWebChoiceFeedback'), + 'AgentWebChoiceRequest': ('app.schemas.message', 'AgentWebChoiceRequest'), + 'AgentWebCommandInfo': ('app.schemas.agent', 'AgentWebCommandInfo'), + 'Annotated': ('app.schemas.context', 'Annotated'), + 'AnthropicErrorDetail': ('app.schemas.openai', 'AnthropicErrorDetail'), + 'AnthropicErrorResponse': ('app.schemas.openai', 'AnthropicErrorResponse'), + 'AnthropicMessage': ('app.schemas.openai', 'AnthropicMessage'), + 'AnthropicMessagesRequest': ('app.schemas.openai', 'AnthropicMessagesRequest'), + 'AnthropicMessagesResponse': ('app.schemas.openai', 'AnthropicMessagesResponse'), + 'AnthropicTextBlock': ('app.schemas.openai', 'AnthropicTextBlock'), + 'AnthropicUsage': ('app.schemas.openai', 'AnthropicUsage'), + 'Any': ('app.schemas.workflow', 'Any'), + 'AuthCredentials': ('app.schemas.event', 'AuthCredentials'), + 'AuthInterceptCredentials': ('app.schemas.event', 'AuthInterceptCredentials'), + 'AuthProviderInfo': ('app.schemas.user', 'AuthProviderInfo'), + 'AuthProviderRemote': ('app.schemas.user', 'AuthProviderRemote'), + 'BaseEventData': ('app.schemas.event', 'BaseEventData'), + 'BaseMessage': ('app.schemas.agent', 'BaseMessage'), + 'BaseModel': ('app.schemas.mcp', 'BaseModel'), + 'BatchProgressKeyData': ('app.schemas.common', 'BatchProgressKeyData'), + 'BatchTransferHistoryRedoRequest': ('app.schemas.history', 'BatchTransferHistoryRedoRequest'), + 'Callable': ('app.schemas.event', 'Callable'), + 'CategoryConfig': ('app.schemas.category', 'CategoryConfig'), + 'CategoryRule': ('app.schemas.category', 'CategoryRule'), + 'ChainEventData': ('app.schemas.event', 'ChainEventData'), + 'ChannelCapabilities': ('app.schemas.notification', 'ChannelCapabilities'), + 'ChannelCapability': ('app.schemas.notification', 'ChannelCapability'), + 'ChannelCapabilityManager': ('app.schemas.notification', 'ChannelCapabilityManager'), + 'ClassVar': ('app.schemas.subscribe', 'ClassVar'), + 'CommandRegisterEventData': ('app.schemas.event', 'CommandRegisterEventData'), + 'ConfigChangeEventData': ('app.schemas.event', 'ConfigChangeEventData'), + 'ConfigDict': ('app.schemas.workflow', 'ConfigDict'), + 'ContentType': ('app.schemas.message', 'ContentType'), + 'Context': ('app.schemas.workflow', 'Context'), + 'ConversationMemory': ('app.schemas.agent', 'ConversationMemory'), + 'CookieActionResponse': ('app.schemas.servcookie', 'CookieActionResponse'), + 'CookieData': ('app.schemas.servcookie', 'CookieData'), + 'CookieDecryptedPayload': ('app.schemas.servcookie', 'CookieDecryptedPayload'), + 'CookieEncryptedPayload': ('app.schemas.servcookie', 'CookieEncryptedPayload'), + 'CookiePassword': ('app.schemas.servcookie', 'CookiePassword'), + 'CustomRule': ('app.schemas.rule', 'CustomRule'), + 'DashboardMemoryInfo': ('app.schemas.dashboard', 'DashboardMemoryInfo'), + 'DashboardSystemInfo': ('app.schemas.dashboard', 'DashboardSystemInfo'), + 'DataT': ('app.schemas.response', 'DataT'), + 'Dict': ('app.schemas.mcp', 'Dict'), + 'DiscoverMediaSource': ('app.schemas.event', 'DiscoverMediaSource'), + 'DiscoverSourceEventData': ('app.schemas.event', 'DiscoverSourceEventData'), + 'Discriminator': ('app.schemas.context', 'Discriminator'), + 'DownloadAddedData': ('app.schemas.download', 'DownloadAddedData'), + 'DownloadDirectory': ('app.schemas.download', 'DownloadDirectory'), + 'DownloadHistory': ('app.schemas.history', 'DownloadHistory'), + 'DownloadTask': ('app.schemas.workflow', 'DownloadTask'), + 'DownloadTaskMedia': ('app.schemas.transfer', 'DownloadTaskMedia'), + 'DownloaderConf': ('app.schemas.system', 'DownloaderConf'), + 'DownloaderInfo': ('app.schemas.dashboard', 'DownloaderInfo'), + 'DownloaderTorrent': ('app.schemas.transfer', 'DownloaderTorrent'), + 'DownloadingTorrent': ('app.schemas.transfer', 'DownloadingTorrent'), + 'EndpointStats': ('app.schemas.monitoring', 'EndpointStats'), + 'Enum': ('app.schemas.notification', 'Enum'), + 'EpisodeFormat': ('app.schemas.transfer', 'EpisodeFormat'), + 'EpisodeFormatRecommendData': ('app.schemas.transfer', 'EpisodeFormatRecommendData'), + 'EpisodeFormatRecommendItem': ('app.schemas.transfer', 'EpisodeFormatRecommendItem'), + 'EpisodeFormatRule': ('app.schemas.transfer', 'EpisodeFormatRule'), + 'ErrorRequest': ('app.schemas.monitoring', 'ErrorRequest'), + 'Event': ('app.schemas.event', 'Event'), + 'ExistMediaInfo': ('app.schemas.mediaserver', 'ExistMediaInfo'), + 'Field': ('app.schemas.mcp', 'Field'), + 'FileItem': ('app.schemas.workflow', 'FileItem'), + 'FileNameData': ('app.schemas.common', 'FileNameData'), + 'FileURI': ('app.schemas.file', 'FileURI'), + 'FilterRuleGroup': ('app.schemas.system', 'FilterRuleGroup'), + 'Generic': ('app.schemas.response', 'Generic'), + 'IdData': ('app.schemas.common', 'IdData'), + 'ImmediateException': ('app.schemas.exception', 'ImmediateException'), + 'IncomingMessage': ('app.schemas.message', 'IncomingMessage'), + 'Iterable': ('app.schemas.event', 'Iterable'), + 'JsonData': ('app.schemas.mcp', 'JsonData'), + 'JsonObject': ('app.schemas.common', 'JsonObject'), + 'JsonObjectList': ('app.schemas.common', 'JsonObjectList'), + 'LLMAuthStatus': ('app.schemas.llm', 'LLMAuthStatus'), + 'LLMModelCatalogData': ('app.schemas.llm', 'LLMModelCatalogData'), + 'LLMModelInfo': ('app.schemas.llm', 'LLMModelInfo'), + 'LLMProviderAuthMethod': ('app.schemas.llm', 'LLMProviderAuthMethod'), + 'LLMProviderAuthSession': ('app.schemas.llm', 'LLMProviderAuthSession'), + 'LLMProviderBaseUrlPreset': ('app.schemas.llm', 'LLMProviderBaseUrlPreset'), + 'LLMProviderInfo': ('app.schemas.llm', 'LLMProviderInfo'), + 'LLMServerToolCapability': ('app.schemas.llm', 'LLMServerToolCapability'), + 'LLMTestResult': ('app.schemas.llm', 'LLMTestResult'), + 'LimitException': ('app.schemas.exception', 'LimitException'), + 'List': ('app.schemas.workflow', 'List'), + 'Literal': ('app.schemas.mcp', 'Literal'), + 'LocaleHelper': ('app.schemas.response', 'LocaleHelper'), + 'MCP_JSONRPC_REQUEST_SCHEMA': ('app.schemas.mcp', 'MCP_JSONRPC_REQUEST_SCHEMA'), + 'ManageRequest': ('app.schemas.common', 'ManageRequest'), + 'ManualTransferHistoryInfo': ('app.schemas.transfer', 'ManualTransferHistoryInfo'), + 'ManualTransferItem': ('app.schemas.transfer', 'ManualTransferItem'), + 'ManualTransferPreviewItem': ('app.schemas.transfer', 'ManualTransferPreviewItem'), + 'ManualTransferPreviewSummary': ('app.schemas.transfer', 'ManualTransferPreviewSummary'), + 'ManualTransferResultData': ('app.schemas.transfer', 'ManualTransferResultData'), + 'ManualTransferTargetPath': ('app.schemas.transfer', 'ManualTransferTargetPath'), + 'McpJsonRpcCapabilities': ('app.schemas.mcp', 'McpJsonRpcCapabilities'), + 'McpJsonRpcClientInfo': ('app.schemas.mcp', 'McpJsonRpcClientInfo'), + 'McpJsonRpcEmptyResult': ('app.schemas.mcp', 'McpJsonRpcEmptyResult'), + 'McpJsonRpcError': ('app.schemas.mcp', 'McpJsonRpcError'), + 'McpJsonRpcErrorDetail': ('app.schemas.mcp', 'McpJsonRpcErrorDetail'), + 'McpJsonRpcInitializeParams': ('app.schemas.mcp', 'McpJsonRpcInitializeParams'), + 'McpJsonRpcInitializeRequest': ('app.schemas.mcp', 'McpJsonRpcInitializeRequest'), + 'McpJsonRpcInitializeResult': ('app.schemas.mcp', 'McpJsonRpcInitializeResult'), + 'McpJsonRpcInitializedNotification': ('app.schemas.mcp', 'McpJsonRpcInitializedNotification'), + 'McpJsonRpcPingRequest': ('app.schemas.mcp', 'McpJsonRpcPingRequest'), + 'McpJsonRpcRequest': ('app.schemas.mcp', 'McpJsonRpcRequest'), + 'McpJsonRpcResponse': ('app.schemas.mcp', 'McpJsonRpcResponse'), + 'McpJsonRpcServerInfo': ('app.schemas.mcp', 'McpJsonRpcServerInfo'), + 'McpJsonRpcSuccess': ('app.schemas.mcp', 'McpJsonRpcSuccess'), + 'McpJsonRpcTextContent': ('app.schemas.mcp', 'McpJsonRpcTextContent'), + 'McpJsonRpcToolCallParams': ('app.schemas.mcp', 'McpJsonRpcToolCallParams'), + 'McpJsonRpcToolCallResult': ('app.schemas.mcp', 'McpJsonRpcToolCallResult'), + 'McpJsonRpcToolsCallRequest': ('app.schemas.mcp', 'McpJsonRpcToolsCallRequest'), + 'McpJsonRpcToolsCapability': ('app.schemas.mcp', 'McpJsonRpcToolsCapability'), + 'McpJsonRpcToolsListRequest': ('app.schemas.mcp', 'McpJsonRpcToolsListRequest'), + 'McpJsonRpcToolsListResult': ('app.schemas.mcp', 'McpJsonRpcToolsListResult'), + 'McpJsonSchema': ('app.schemas.mcp', 'McpJsonSchema'), + 'McpToolInfo': ('app.schemas.mcp', 'McpToolInfo'), + 'MediaCategoryMap': ('app.schemas.category', 'MediaCategoryMap'), + 'MediaCompany': ('app.schemas.context', 'MediaCompany'), + 'MediaCountry': ('app.schemas.context', 'MediaCountry'), + 'MediaCredit': ('app.schemas.context', 'MediaCredit'), + 'MediaEpisode': ('app.schemas.context', 'MediaEpisode'), + 'MediaEpisodeGroup': ('app.schemas.context', 'MediaEpisodeGroup'), + 'MediaEpisodeGroupNetwork': ('app.schemas.context', 'MediaEpisodeGroupNetwork'), + 'MediaGenre': ('app.schemas.context', 'MediaGenre'), + 'MediaImageSet': ('app.schemas.context', 'MediaImageSet'), + 'MediaInfo': ('app.schemas.workflow', 'MediaInfo'), + 'MediaLanguage': ('app.schemas.context', 'MediaLanguage'), + 'MediaPerson': ('app.schemas.context', 'MediaPerson'), + 'MediaRecognizeConvertEventData': ('app.schemas.event', 'MediaRecognizeConvertEventData'), + 'MediaReleaseDate': ('app.schemas.context', 'MediaReleaseDate'), + 'MediaSearchResult': ('app.schemas.context', 'MediaSearchResult'), + 'MediaSearchResults': ('app.schemas.context', 'MediaSearchResults'), + 'MediaSeason': ('app.schemas.context', 'MediaSeason'), + 'MediaServerConf': ('app.schemas.system', 'MediaServerConf'), + 'MediaServerExistingEpisodes': ('app.schemas.mediaserver', 'MediaServerExistingEpisodes'), + 'MediaServerExistsData': ('app.schemas.mediaserver', 'MediaServerExistsData'), + 'MediaServerItem': ('app.schemas.mediaserver', 'MediaServerItem'), + 'MediaServerItemUserState': ('app.schemas.mediaserver', 'MediaServerItemUserState'), + 'MediaServerLibrary': ('app.schemas.mediaserver', 'MediaServerLibrary'), + 'MediaServerPlayData': ('app.schemas.mediaserver', 'MediaServerPlayData'), + 'MediaServerPlayItem': ('app.schemas.mediaserver', 'MediaServerPlayItem'), + 'MediaServerSeasonInfo': ('app.schemas.mediaserver', 'MediaServerSeasonInfo'), + 'MediaSource': ('app.schemas.transfer', 'MediaSource'), + 'MediaType': ('app.schemas.subscribe', 'MediaType'), + 'Message': ('app.schemas.message', 'Message'), + 'MessageClearBefore': ('app.schemas.message', 'MessageClearBefore'), + 'MessageClearData': ('app.schemas.message', 'MessageClearData'), + 'MessageClearScope': ('app.schemas.message', 'MessageClearScope'), + 'MessageHistoryItem': ('app.schemas.message', 'MessageHistoryItem'), + 'MessageResponse': ('app.schemas.message', 'MessageResponse'), + 'MessageType': ('app.schemas.message', 'MessageType'), + 'MetaInfo': ('app.schemas.transfer', 'MetaInfo'), + 'MfaChallenge': ('app.schemas.token', 'MfaChallenge'), + 'MfaStatusData': ('app.schemas.mfa', 'MfaStatusData'), + 'MonitoringConfig': ('app.schemas.monitoring', 'MonitoringConfig'), + 'MonitoringOverview': ('app.schemas.monitoring', 'MonitoringOverview'), + 'MusicAlbumInfo': ('app.schemas.music', 'MusicAlbumInfo'), + 'MusicArtistInfo': ('app.schemas.music', 'MusicArtistInfo'), + 'MusicEntityType': ('app.schemas.music', 'MusicEntityType'), + 'MusicInfo': ('app.schemas.transfer', 'MusicInfo'), + 'MusicMeta': ('app.schemas.transfer', 'MusicMeta'), + 'MusicRecognitionCacheData': ('app.schemas.music', 'MusicRecognitionCacheData'), + 'MusicRecognitionCacheItem': ('app.schemas.music', 'MusicRecognitionCacheItem'), + 'MusicRecognizeRequest': ('app.schemas.music', 'MusicRecognizeRequest'), + 'MusicRelease': ('app.schemas.music', 'MusicRelease'), + 'MusicTargetEntityType': ('app.schemas.transfer', 'MusicTargetEntityType'), + 'NameData': ('app.schemas.common', 'NameData'), + 'NameValueOption': ('app.schemas.workflow', 'NameValueOption'), + 'NetTestTarget': ('app.schemas.system', 'NetTestTarget'), + 'NotExistMediaInfo': ('app.schemas.mediaserver', 'NotExistMediaInfo'), + 'NotificationChannel': ('app.schemas.notification', 'NotificationChannel'), + 'NotificationConf': ('app.schemas.system', 'NotificationConf'), + 'NotificationSwitch': ('app.schemas.message', 'NotificationSwitch'), + 'NotificationSwitchConf': ('app.schemas.system', 'NotificationSwitchConf'), + 'OpenAIChatChoice': ('app.schemas.openai', 'OpenAIChatChoice'), + 'OpenAIChatChoiceMessage': ('app.schemas.openai', 'OpenAIChatChoiceMessage'), + 'OpenAIChatCompletionResponse': ('app.schemas.openai', 'OpenAIChatCompletionResponse'), + 'OpenAIChatCompletionsRequest': ('app.schemas.openai', 'OpenAIChatCompletionsRequest'), + 'OpenAIChatContentPart': ('app.schemas.openai', 'OpenAIChatContentPart'), + 'OpenAIChatMessage': ('app.schemas.openai', 'OpenAIChatMessage'), + 'OpenAIErrorDetail': ('app.schemas.openai', 'OpenAIErrorDetail'), + 'OpenAIErrorResponse': ('app.schemas.openai', 'OpenAIErrorResponse'), + 'OpenAIIncompleteDetails': ('app.schemas.openai', 'OpenAIIncompleteDetails'), + 'OpenAIModelInfo': ('app.schemas.openai', 'OpenAIModelInfo'), + 'OpenAIModelListResponse': ('app.schemas.openai', 'OpenAIModelListResponse'), + 'OpenAIResponseAnnotation': ('app.schemas.openai', 'OpenAIResponseAnnotation'), + 'OpenAIResponsesOutputMessage': ('app.schemas.openai', 'OpenAIResponsesOutputMessage'), + 'OpenAIResponsesOutputText': ('app.schemas.openai', 'OpenAIResponsesOutputText'), + 'OpenAIResponsesRequest': ('app.schemas.openai', 'OpenAIResponsesRequest'), + 'OpenAIResponsesResponse': ('app.schemas.openai', 'OpenAIResponsesResponse'), + 'OpenAIUsage': ('app.schemas.openai', 'OpenAIUsage'), + 'OperationInterrupted': ('app.schemas.exception', 'OperationInterrupted'), + 'Optional': ('app.schemas.mcp', 'Optional'), + 'OptionalMediaIdentityMixin': ('app.schemas.transfer', 'OptionalMediaIdentityMixin'), + 'OtpGenerateData': ('app.schemas.mfa', 'OtpGenerateData'), + 'PasskeyInfo': ('app.schemas.mfa', 'PasskeyInfo'), + 'PasskeyOptions': ('app.schemas.mfa', 'PasskeyOptions'), + 'PasskeyStartData': ('app.schemas.mfa', 'PasskeyStartData'), + 'Path': ('app.schemas.transfer', 'Path'), + 'PerformanceSnapshot': ('app.schemas.monitoring', 'PerformanceSnapshot'), + 'Plugin': ('app.schemas.plugin', 'Plugin'), + 'PluginDashboard': ('app.schemas.plugin', 'PluginDashboard'), + 'PluginDashboardMetaItem': ('app.schemas.plugin', 'PluginDashboardMetaItem'), + 'PluginDataResetEventData': ('app.schemas.event', 'PluginDataResetEventData'), + 'PluginFolderConfigData': ('app.schemas.plugin', 'PluginFolderConfigData'), + 'PluginFoldersData': ('app.schemas.plugin', 'PluginFoldersData'), + 'PluginMarketSyncData': ('app.schemas.system', 'PluginMarketSyncData'), + 'PluginMarketSyncRequest': ('app.schemas.system', 'PluginMarketSyncRequest'), + 'PluginMemoryInfo': ('app.schemas.plugin', 'PluginMemoryInfo'), + 'PluginRating': ('app.schemas.plugin', 'PluginRating'), + 'PluginRatingMap': ('app.schemas.plugin', 'PluginRatingMap'), + 'PluginRatingRequest': ('app.schemas.plugin', 'PluginRatingRequest'), + 'PluginReleaseData': ('app.schemas.plugin', 'PluginReleaseData'), + 'PluginReleaseItem': ('app.schemas.plugin', 'PluginReleaseItem'), + 'PluginRemoteInfo': ('app.schemas.plugin', 'PluginRemoteInfo'), + 'PluginSidebarNavItem': ('app.schemas.plugin', 'PluginSidebarNavItem'), + 'PluginWorkflowActionGroup': ('app.schemas.workflow', 'PluginWorkflowActionGroup'), + 'ProcessInfo': ('app.schemas.dashboard', 'ProcessInfo'), + 'ProgressKeyData': ('app.schemas.common', 'ProgressKeyData'), + 'RadarrMovie': ('app.schemas.servarr', 'RadarrMovie'), + 'RateLimitExceededException': ('app.schemas.exception', 'RateLimitExceededException'), + 'RecommendMediaSource': ('app.schemas.event', 'RecommendMediaSource'), + 'RecommendSourceEventData': ('app.schemas.event', 'RecommendSourceEventData'), + 'RefreshMediaItem': ('app.schemas.mediaserver', 'RefreshMediaItem'), + 'RequestMetrics': ('app.schemas.monitoring', 'RequestMetrics'), + 'RequiredMediaIdentityMixin': ('app.schemas.music', 'RequiredMediaIdentityMixin'), + 'ResourceDownloadEventData': ('app.schemas.event', 'ResourceDownloadEventData'), + 'ResourceSelectionEventData': ('app.schemas.event', 'ResourceSelectionEventData'), + 'Response': ('app.schemas.response', 'Response'), + 'RootModel': ('app.schemas.mcp', 'RootModel'), + 'RuleTestData': ('app.schemas.system', 'RuleTestData'), + 'ScheduleInfo': ('app.schemas.dashboard', 'ScheduleInfo'), + 'ScheduleProgress': ('app.schemas.dashboard', 'ScheduleProgress'), + 'SearchLastContextData': ('app.schemas.search', 'SearchLastContextData'), + 'SearchRecommendStatusData': ('app.schemas.search', 'SearchRecommendStatusData'), + 'ServarrFormatItem': ('app.schemas.servarr', 'ServarrFormatItem'), + 'ServarrIdResponse': ('app.schemas.servarr', 'ServarrIdResponse'), + 'ServarrImage': ('app.schemas.servarr', 'ServarrImage'), + 'ServarrLanguage': ('app.schemas.servarr', 'ServarrLanguage'), + 'ServarrLanguageProfile': ('app.schemas.servarr', 'ServarrLanguageProfile'), + 'ServarrLanguageProfileItem': ('app.schemas.servarr', 'ServarrLanguageProfileItem'), + 'ServarrQuality': ('app.schemas.servarr', 'ServarrQuality'), + 'ServarrQualityProfile': ('app.schemas.servarr', 'ServarrQualityProfile'), + 'ServarrQualityProfileItem': ('app.schemas.servarr', 'ServarrQualityProfileItem'), + 'ServarrRootFolder': ('app.schemas.servarr', 'ServarrRootFolder'), + 'ServarrSystemStatus': ('app.schemas.servarr', 'ServarrSystemStatus'), + 'ServarrTag': ('app.schemas.servarr', 'ServarrTag'), + 'ServarrVersion': ('app.schemas.servarr', 'ServarrVersion'), + 'ServiceClientInfo': ('app.schemas.common', 'ServiceClientInfo'), + 'ServiceInfo': ('app.schemas.system', 'ServiceInfo'), + 'Set': ('app.schemas.notification', 'Set'), + 'Site': ('app.schemas.workflow', 'Site'), + 'SiteAuth': ('app.schemas.site', 'SiteAuth'), + 'SiteCategory': ('app.schemas.site', 'SiteCategory'), + 'SiteCookieUpdate': ('app.schemas.site', 'SiteCookieUpdate'), + 'SiteIconData': ('app.schemas.site', 'SiteIconData'), + 'SiteMappingData': ('app.schemas.site', 'SiteMappingData'), + 'SiteStatistic': ('app.schemas.site', 'SiteStatistic'), + 'SiteUnreadMessage': ('app.schemas.site', 'SiteUnreadMessage'), + 'SiteUserData': ('app.schemas.site', 'SiteUserData'), + 'SonarrRatings': ('app.schemas.servarr', 'SonarrRatings'), + 'SonarrSeason': ('app.schemas.servarr', 'SonarrSeason'), + 'SonarrSeries': ('app.schemas.servarr', 'SonarrSeries'), + 'SonarrStatistics': ('app.schemas.servarr', 'SonarrStatistics'), + 'Statistic': ('app.schemas.dashboard', 'Statistic'), + 'Storage': ('app.schemas.dashboard', 'Storage'), + 'StorageAuthUrlData': ('app.schemas.storage', 'StorageAuthUrlData'), + 'StorageConf': ('app.schemas.system', 'StorageConf'), + 'StorageLoginStatusData': ('app.schemas.storage', 'StorageLoginStatusData'), + 'StorageOperSelectionEventData': ('app.schemas.event', 'StorageOperSelectionEventData'), + 'StorageQrCodeData': ('app.schemas.storage', 'StorageQrCodeData'), + 'StorageQueryError': ('app.schemas.exception', 'StorageQueryError'), + 'StorageSchema': ('app.schemas.file', 'StorageSchema'), + 'StorageTransType': ('app.schemas.file', 'StorageTransType'), + 'StorageUsage': ('app.schemas.file', 'StorageUsage'), + 'SubscrbieInfo': ('app.schemas.subscribe', 'SubscrbieInfo'), + 'Subscribe': ('app.schemas.workflow', 'Subscribe'), + 'SubscribeCompletionCheckEventData': ('app.schemas.event', 'SubscribeCompletionCheckEventData'), + 'SubscribeDownloadFileInfo': ('app.schemas.subscribe', 'SubscribeDownloadFileInfo'), + 'SubscribeEpisodeInfo': ('app.schemas.subscribe', 'SubscribeEpisodeInfo'), + 'SubscribeEpisodesRefreshEventData': ('app.schemas.event', 'SubscribeEpisodesRefreshEventData'), + 'SubscribeLibraryFileInfo': ('app.schemas.subscribe', 'SubscribeLibraryFileInfo'), + 'SubscribeModifiedEventData': ('app.schemas.event', 'SubscribeModifiedEventData'), + 'SubscribeShare': ('app.schemas.subscribe', 'SubscribeShare'), + 'SubscribeShareStatistics': ('app.schemas.subscribe', 'SubscribeShareStatistics'), + 'Subscription': ('app.schemas.message', 'Subscription'), + 'SubscriptionMessage': ('app.schemas.message', 'SubscriptionMessage'), + 'SubtitleDownloadData': ('app.schemas.download', 'SubtitleDownloadData'), + 'SubtitleInfo': ('app.schemas.search', 'SubtitleInfo'), + 'SystemEnvironmentUpdateData': ('app.schemas.system', 'SystemEnvironmentUpdateData'), + 'SystemModuleInfo': ('app.schemas.system', 'SystemModuleInfo'), + 'SystemModuleListData': ('app.schemas.system', 'SystemModuleListData'), + 'TMDbException': ('app.schemas.exception', 'TMDbException'), + 'Tag': ('app.schemas.context', 'Tag'), + 'TimeData': ('app.schemas.common', 'TimeData'), + 'TmdbEpisode': ('app.schemas.tmdb', 'TmdbEpisode'), + 'TmdbEpisodeCredit': ('app.schemas.tmdb', 'TmdbEpisodeCredit'), + 'TmdbEpisodeCrew': ('app.schemas.tmdb', 'TmdbEpisodeCrew'), + 'TmdbEpisodeGuestStar': ('app.schemas.tmdb', 'TmdbEpisodeGuestStar'), + 'TmdbRecognitionCacheData': ('app.schemas.tmdb', 'TmdbRecognitionCacheData'), + 'TmdbRecognitionCacheItem': ('app.schemas.tmdb', 'TmdbRecognitionCacheItem'), + 'TmdbSeason': ('app.schemas.tmdb', 'TmdbSeason'), + 'Token': ('app.schemas.token', 'Token'), + 'TokenPayload': ('app.schemas.token', 'TokenPayload'), + 'ToolCallData': ('app.schemas.mcp', 'ToolCallData'), + 'ToolCallRequest': ('app.schemas.mcp', 'ToolCallRequest'), + 'ToolResult': ('app.schemas.agent', 'ToolResult'), + 'TorrentCacheData': ('app.schemas.cache', 'TorrentCacheData'), + 'TorrentCacheItem': ('app.schemas.cache', 'TorrentCacheItem'), + 'TorrentInfo': ('app.schemas.system', 'TorrentInfo'), + 'TorrentReidentifyData': ('app.schemas.cache', 'TorrentReidentifyData'), + 'TransferDirectoryConf': ('app.schemas.system', 'TransferDirectoryConf'), + 'TransferHistory': ('app.schemas.history', 'TransferHistory'), + 'TransferHistoryPage': ('app.schemas.history', 'TransferHistoryPage'), + 'TransferInfo': ('app.schemas.transfer', 'TransferInfo'), + 'TransferInterceptEventData': ('app.schemas.event', 'TransferInterceptEventData'), + 'TransferJob': ('app.schemas.transfer', 'TransferJob'), + 'TransferJobTask': ('app.schemas.transfer', 'TransferJobTask'), + 'TransferOverwriteCheckEventData': ('app.schemas.event', 'TransferOverwriteCheckEventData'), + 'TransferRenameBuildEventData': ('app.schemas.event', 'TransferRenameBuildEventData'), + 'TransferRenameEventData': ('app.schemas.event', 'TransferRenameEventData'), + 'TransferTorrent': ('app.schemas.transfer', 'TransferTorrent'), + 'TypeAdapter': ('app.schemas.mcp', 'TypeAdapter'), + 'TypeAlias': ('app.schemas.mcp', 'TypeAlias'), + 'TypeAliasType': ('app.schemas.common', 'TypeAliasType'), + 'TypeVar': ('app.schemas.response', 'TypeVar'), + 'TypedDict': ('app.schemas.user', 'TypedDict'), + 'Union': ('app.schemas.mcp', 'Union'), + 'User': ('app.schemas.user', 'User'), + 'UserBase': ('app.schemas.user', 'UserBase'), + 'UserCreate': ('app.schemas.user', 'UserCreate'), + 'UserInDB': ('app.schemas.user', 'UserInDB'), + 'UserInDBBase': ('app.schemas.user', 'UserInDBBase'), + 'UserMessage': ('app.schemas.agent', 'UserMessage'), + 'UserPermissions': ('app.schemas.user', 'UserPermissions'), + 'UserUpdate': ('app.schemas.user', 'UserUpdate'), + 'ValidationIssue': ('app.schemas.response', 'ValidationIssue'), + 'ValueData': ('app.schemas.common', 'ValueData'), + 'WINDOWS_DRIVE_PATTERN': ('app.schemas.file', 'WINDOWS_DRIVE_PATTERN'), + 'WebMessageItem': ('app.schemas.message', 'WebMessageItem'), + 'WebhookEventInfo': ('app.schemas.mediaserver', 'WebhookEventInfo'), + 'WechatClawBotData': ('app.schemas.notification', 'WechatClawBotData'), + 'WechatClawBotKnownTarget': ('app.schemas.notification', 'WechatClawBotKnownTarget'), + 'Workflow': ('app.schemas.workflow', 'Workflow'), + 'WorkflowActionDefinition': ('app.schemas.workflow', 'WorkflowActionDefinition'), + 'WorkflowExecutionConfig': ('app.schemas.workflow', 'WorkflowExecutionConfig'), + 'WorkflowExecutionState': ('app.schemas.workflow', 'WorkflowExecutionState'), + 'WorkflowNodeState': ('app.schemas.workflow', 'WorkflowNodeState'), + 'WorkflowRuntimeState': ('app.schemas.workflow', 'WorkflowRuntimeState'), + 'WorkflowShare': ('app.schemas.workflow', 'WorkflowShare'), + 'compute_subscribe_completed_episode': ('app.schemas.subscribe', 'compute_subscribe_completed_episode'), + 'dataclass': ('app.schemas.system', 'dataclass'), + 'datetime': ('app.schemas.monitoring', 'datetime'), + 'field_serializer': ('app.schemas.agent', 'field_serializer'), + 'field_validator': ('app.schemas.system', 'field_validator'), + 'json': ('app.schemas.subscribe', 'json'), + 'model_validator': ('app.schemas.subscribe', 'model_validator'), + 're': ('app.schemas.file', 're'), +} + +SCHEMA_CONFLICTS = { + 'Any': ['app.schemas.common', 'app.schemas.context', 'app.schemas.event', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.response', 'app.schemas.subscribe', 'app.schemas.system', 'app.schemas.workflow'], + 'BaseModel': ['app.schemas.agent', 'app.schemas.cache', 'app.schemas.category', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.download', 'app.schemas.event', 'app.schemas.file', 'app.schemas.history', 'app.schemas.llm', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.monitoring', 'app.schemas.notification', 'app.schemas.plugin', 'app.schemas.response', 'app.schemas.rule', 'app.schemas.search', 'app.schemas.storage', 'app.schemas.openai', 'app.schemas.servarr', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.subscribe', 'app.schemas.system', 'app.schemas.tmdb', 'app.schemas.token', 'app.schemas.transfer', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'], + 'ConfigDict': ['app.schemas.agent', 'app.schemas.category', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.response', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.subscribe', 'app.schemas.user', 'app.schemas.workflow'], + 'Context': ['app.schemas.context', 'app.schemas.workflow'], + 'Dict': ['app.schemas.category', 'app.schemas.common', 'app.schemas.context', 'app.schemas.event', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.notification', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.site', 'app.schemas.subscribe', 'app.schemas.mcp'], + 'DownloadTask': ['app.schemas.download', 'app.schemas.workflow'], + 'Enum': ['app.schemas.message', 'app.schemas.notification'], + 'Field': ['app.schemas.agent', 'app.schemas.cache', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.download', 'app.schemas.event', 'app.schemas.file', 'app.schemas.history', 'app.schemas.llm', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.notification', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.storage', 'app.schemas.openai', 'app.schemas.servarr', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.subscribe', 'app.schemas.system', 'app.schemas.tmdb', 'app.schemas.token', 'app.schemas.transfer', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'], + 'FileItem': ['app.schemas.event', 'app.schemas.file', 'app.schemas.transfer', 'app.schemas.workflow'], + 'FilterRuleGroup': ['app.schemas.rule', 'app.schemas.system'], + 'JsonData': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'], + 'List': ['app.schemas.agent', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.monitoring', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.workflow'], + 'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.mcp'], + 'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'], + 'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'], + 'MediaSource': ['app.schemas.cache', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'], + 'MediaType': ['app.schemas.mediaserver', 'app.schemas.subscribe'], + 'MetaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer'], + 'MusicInfo': ['app.schemas.context', 'app.schemas.music', 'app.schemas.transfer'], + 'MusicMeta': ['app.schemas.context', 'app.schemas.music', 'app.schemas.transfer'], + 'MusicTargetEntityType': ['app.schemas.music', 'app.schemas.transfer'], + 'NotificationChannel': ['app.schemas.event', 'app.schemas.message', 'app.schemas.notification'], + 'Optional': ['app.schemas.agent', 'app.schemas.cache', 'app.schemas.category', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.download', 'app.schemas.event', 'app.schemas.file', 'app.schemas.history', 'app.schemas.llm', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.notification', 'app.schemas.plugin', 'app.schemas.response', 'app.schemas.rule', 'app.schemas.storage', 'app.schemas.openai', 'app.schemas.servarr', 'app.schemas.site', 'app.schemas.subscribe', 'app.schemas.system', 'app.schemas.tmdb', 'app.schemas.token', 'app.schemas.transfer', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'], + 'OptionalMediaIdentityMixin': ['app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'], + 'Path': ['app.schemas.event', 'app.schemas.file', 'app.schemas.mediaserver', 'app.schemas.transfer'], + 'RequiredMediaIdentityMixin': ['app.schemas.event', 'app.schemas.music'], + 'RootModel': ['app.schemas.category', 'app.schemas.common', 'app.schemas.context', 'app.schemas.mediaserver', 'app.schemas.mfa', 'app.schemas.plugin', 'app.schemas.site', 'app.schemas.mcp'], + 'Set': ['app.schemas.event', 'app.schemas.notification'], + 'Site': ['app.schemas.site', 'app.schemas.workflow'], + 'Subscribe': ['app.schemas.subscribe', 'app.schemas.workflow'], + 'SubtitleInfo': ['app.schemas.context', 'app.schemas.search'], + 'TorrentInfo': ['app.schemas.context', 'app.schemas.search', 'app.schemas.system'], + 'Union': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.site', 'app.schemas.transfer', 'app.schemas.mcp'], + 'UserPermissions': ['app.schemas.token', 'app.schemas.user'], + 'dataclass': ['app.schemas.notification', 'app.schemas.system'], + 'datetime': ['app.schemas.agent', 'app.schemas.monitoring'], + 'field_validator': ['app.schemas.event', 'app.schemas.message', 'app.schemas.music', 'app.schemas.response', 'app.schemas.subscribe', 'app.schemas.system'], + 'model_validator': ['app.schemas.dashboard', 'app.schemas.event', 'app.schemas.mediaserver', 'app.schemas.subscribe'], +} diff --git a/app/schemas/music.py b/app/schemas/music.py index 9876a59ff..360240d06 100644 --- a/app/schemas/music.py +++ b/app/schemas/music.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Union +from typing import Literal, Optional from pydantic import BaseModel, Field, field_validator diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 6615fbfa8..8e16164f0 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -9,10 +9,6 @@ from app.schemas.types import MediaSource, MusicTargetEntityType from app.schemas.context import MetaInfo, MediaInfo from app.schemas.music import MusicInfo, MusicMeta from app.schemas.file import FileItem -from app.schemas.history import DownloadHistory -from app.schemas.system import TransferDirectoryConf -from app.schemas.tmdb import TmdbEpisode -from app.schemas.types import MediaType class DownloaderTorrent(BaseModel): diff --git a/app/sdk/logging.py b/app/sdk/logging.py index 98f1b3ad8..8a7319e45 100644 --- a/app/sdk/logging.py +++ b/app/sdk/logging.py @@ -1,17 +1,6 @@ """插件可使用的稳定日志入口。""" -from app.runtime.log import ( - CustomFormatter, - LogConfigModel, - LogEntry, - LogSettings, - LoggerManager, - NonBlockingFileHandler, - configure_log_settings, - configure_log_writer, - logger, - log_settings, -) +from app.runtime.log import logger __all__ = ["logger"] diff --git a/app/startup/database_initializer.py b/app/startup/database_initializer.py index 4e32990ac..de2feeedf 100644 --- a/app/startup/database_initializer.py +++ b/app/startup/database_initializer.py @@ -6,6 +6,7 @@ from alembic.config import Config from app.runtime.config import settings from app.db import Base +from app.db.models import load_all_models from app.runtime.log import logger @@ -19,7 +20,7 @@ def init_db(): from app.db.engine import get_engine # 确保所有模型都已注册到 Base.metadata 中 - import app.db.models # noqa: F401 + load_all_models() # 全量建表 Base.metadata.create_all(bind=get_engine()) @@ -47,4 +48,3 @@ def update_db(): f'数据库更新失败:{str(error)} - {traceback.format_exc()}' ) raise - diff --git a/app/startup/lifecycle.py b/app/startup/lifecycle.py deleted file mode 100644 index daf0db1a1..000000000 --- a/app/startup/lifecycle.py +++ /dev/null @@ -1,170 +0,0 @@ -import asyncio -import inspect -from contextlib import asynccontextmanager -from typing import Callable - -from fastapi import FastAPI - -from app.startup.cache_initializer import configure_cache_dependencies -# 缓存装饰器会在业务模块导入时创建后端,必须先完成适配器装配。 -configure_cache_dependencies() -# urllib3-future 覆盖 urllib3 命名空间后删除了 format_header_param,导致 telebot 崩溃,需在加载模块前打补丁 -try: - import urllib3.fields as _urllib3_fields - - if not hasattr(_urllib3_fields, "format_header_param") and hasattr( - _urllib3_fields, "format_header_param_rfc2231" - ): - _urllib3_fields.format_header_param = ( - _urllib3_fields.format_header_param_rfc2231 - ) -except Exception: - pass - -from app.chain.system import SystemChain -from app.runtime.config import global_vars, settings -from app.adapters.external.server import MoviePilotServerHelper -from app.runtime.state import SystemHelper -from app.runtime.log import logger, LoggerManager -from app.startup.command_initializer import init_command, stop_command, restart_command -from app.startup.domain_initializer import configure_domain_dependencies -from app.startup.modules_initializer import init_modules, stop_modules -from app.startup.monitor_initializer import stop_monitor, init_monitor -from app.startup.plugins_initializer import init_plugins, stop_plugins, sync_plugins -from app.startup.routers_initializer import init_routers -from app.startup.scheduler_initializer import ( - stop_scheduler, - init_scheduler, - init_plugin_scheduler, -) -from app.db import check_connection_budget, get_engine, get_global_async_engine -from app.startup.transfer_initializer import replay_pending_transfers -from app.startup.workflow_initializer import init_workflow, stop_workflow -from app.adapters.network.http import ( - aclose_shared_async_transports, - configure_default_user_agent, -) - - -async def init_extra(): - """ - 同步插件及重启相关依赖服务 - """ - if settings.MOVIEPILOT_SAFE_MODE: - SystemHelper().set_system_modified() - SystemChain().restart_finish() - return - if await sync_plugins(): - # 重新注册插件定时服务 - init_plugin_scheduler() - # 重新注册命令 - restart_command() - # 设置系统已修改标志 - SystemHelper().set_system_modified() - # 重启完成 - SystemChain().restart_finish() - # 上报当前安装版本 - await MoviePilotServerHelper.async_report_usage() - - -async def run_shutdown_step(name: str, callback: Callable[[], object]) -> None: - """隔离单个关闭阶段的异常,确保后续资源仍有机会释放""" - try: - result = callback() - if inspect.isawaitable(result): - await result - except Exception as err: - logger.error(f"关闭{name}失败:{err}") - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """ - 定义应用的生命周期事件 - """ - print("Starting up...") - # HTTP 基础能力不反向读取平台配置,由启动层注入宿主标识。 - configure_default_user_agent(settings.USER_AGENT) - # 领域层只消费显式注入的配置和适配器,不自行读取平台或数据库。 - configure_domain_dependencies() - # 存储当前循环 - global_vars.set_loop(asyncio.get_event_loop()) - # 同步与异步引擎各预热一次。引擎改为惰性创建后,两者的首次创建时机都不再由启动路径 - # 决定,这一步把它们拉回来。必须排在所有 init_* 之前,两个理由: - # - # 其一,fail-fast 的落点。异步驱动缺失、异步 URL 拼错这类问题若不在这里暴露,会一路 - # 推迟到第一个异步查询——表现为用户请求 500 或调度任务静默失败,而不是启动即崩。 - # 故意不 try/except:起不来就该起不来,吞掉它等于把 fail-fast 又还回去了。而既然会抛, - # 就必须抛在 init_routers / init_modules 之前——下面的 try/finally 关停块要到 yield 处 - # 才开始,在它之后抛异常,已经初始化好的模块就拿不到 stop_modules() 了。 - # - # 其二,同步引擎的首次创建要落在单线程期。init_db() 会顺带预热它,但那只对 - # run_application() 入口成立;外部 supervisor 直挂 ASGI app(如 - # `gunicorn -k uvicorn.workers.UvicornWorker app.factory:app`)时 init_db() 根本不执行, - # 首次创建便退到运行期——而那时 init_scheduler() / init_monitor() 已经放出上百个线程, - # 引擎构建里那段 PRAGMA journal_mode 会让它们一起堵在创建锁上。 - # - # 代价:异步侧几乎为零,create_async_engine 只校验 URL 与驱动导入、不建立连接;同步侧 - # 会连一次库、设一遍 journal mode,在事件循环上阻塞一小会儿——但那一次本来就免不了, - # 放在这里至少还独占着单线程,而且此刻 uvicorn 尚未开始接请求。 - get_engine() - get_global_async_engine() - # 核算数据库连接理论峰值。各连接池是彼此独立配置的,没有任何地方核算总和, - # 超额只会在突发并发时以 TooManyConnectionsError 的形式暴露;这里在启动期 - # 就对照数据库的真实上限校验一次,把问题前移到可见的位置 - check_connection_budget() - # 初始化路由 - init_routers(app) - # 初始化模块 - await init_modules() - if settings.MOVIEPILOT_SAFE_MODE: - print("MoviePilot safe mode enabled: skip plugins, scheduler, monitor, commands and workflow.") - else: - # 恢复插件备份 - SystemChain().restore_plugins() - # 初始化插件 - init_plugins() - # 初始化定时器 - init_scheduler() - # 初始化监控器 - init_monitor() - # 回放上次未整理完的文件(后台线程,不阻塞启动) - replay_pending_transfers() - # 初始化命令 - init_command() - # 初始化工作流 - init_workflow() - # 插件同步到本地 - sync_plugins_task = asyncio.create_task(init_extra()) - try: - # 在此处 yield,表示应用已经启动,控制权交回 FastAPI 主事件循环 - yield - finally: - print("Shutting down...") - global_vars.stop_system() - # 取消同步插件任务 - try: - sync_plugins_task.cancel() - await sync_plugins_task - except asyncio.CancelledError: - pass - except Exception as e: - print(str(e)) - try: - if not settings.MOVIEPILOT_SAFE_MODE: - await run_shutdown_step( - "插件备份", lambda: SystemChain().backup_plugins() - ) - await run_shutdown_step("工作流", stop_workflow) - await run_shutdown_step("命令服务", stop_command) - await run_shutdown_step("监控器", stop_monitor) - await run_shutdown_step("定时器", stop_scheduler) - await run_shutdown_step("插件", stop_plugins) - await run_shutdown_step("模块服务", stop_modules) - await run_shutdown_step( - "共享异步 HTTP 连接池", - aclose_shared_async_transports, - ) - finally: - # 日志最后关闭,确保其他组件的收尾信息已写入文件 - LoggerManager.shutdown() diff --git a/app/startup/lifecycle/__init__.py b/app/startup/lifecycle/__init__.py new file mode 100644 index 000000000..d3e6cc66f --- /dev/null +++ b/app/startup/lifecycle/__init__.py @@ -0,0 +1,332 @@ +"""应用生命周期组件的组装、启动和关闭编排。""" + +import asyncio +import inspect +import time +from contextlib import asynccontextmanager +from typing import Callable + +from fastapi import FastAPI + +from app.startup.cache_initializer import configure_cache_dependencies +# 缓存装饰器会在业务模块导入时创建后端,必须先完成适配器装配。 +configure_cache_dependencies() +# urllib3-future 覆盖 urllib3 命名空间后删除了 format_header_param,导致 telebot 崩溃,需在加载模块前打补丁 +try: + import urllib3.fields as _urllib3_fields + + if not hasattr(_urllib3_fields, "format_header_param") and hasattr( + _urllib3_fields, "format_header_param_rfc2231" + ): + _urllib3_fields.format_header_param = ( + _urllib3_fields.format_header_param_rfc2231 + ) +except Exception: + pass + +from app.chain.system import SystemChain +from app.runtime.config import global_vars, settings +from app.adapters.external.server import MoviePilotServerHelper +from app.runtime.state import SystemHelper +from app.runtime.log import logger, LoggerManager +from app.startup.command_initializer import init_command, stop_command, restart_command +from app.startup.domain_initializer import configure_domain_dependencies +from app.startup.modules_initializer import init_modules, stop_modules +from app.startup.monitor_initializer import stop_monitor, init_monitor +from app.startup.plugins_initializer import init_plugins, stop_plugins, sync_plugins +from app.startup.routers_initializer import init_routers +from app.startup.scheduler_initializer import ( + stop_scheduler, + init_scheduler, + init_plugin_scheduler, +) +from app.db import check_connection_budget, get_engine, get_global_async_engine +from app.startup.transfer_initializer import replay_pending_transfers +from app.startup.workflow_initializer import init_workflow, stop_workflow +from app.startup.lifecycle.components import ( + LifecycleComponent, + LifecycleMode, + lifecycle_manifest, +) +from app.adapters.network.http import ( + aclose_shared_async_transports, + configure_default_user_agent, +) + + +async def init_extra(): + """ + 同步插件及重启相关依赖服务 + """ + if settings.MOVIEPILOT_SAFE_MODE: + SystemHelper().set_system_modified() + SystemChain().restart_finish() + return + if await sync_plugins(): + # 重新注册插件定时服务 + init_plugin_scheduler() + # 重新注册命令 + restart_command() + # 设置系统已修改标志 + SystemHelper().set_system_modified() + # 重启完成 + SystemChain().restart_finish() + # 上报当前安装版本 + await MoviePilotServerHelper.async_report_usage() + + +async def run_shutdown_step( + name: str, + callback: Callable[[], object], + timeout_seconds: float | None = None, +) -> None: + """隔离单个关闭阶段的异常,确保后续资源仍有机会释放""" + try: + result = callback() + if inspect.isawaitable(result): + if timeout_seconds: + await asyncio.wait_for(result, timeout=timeout_seconds) + else: + await result + except Exception as err: + logger.error(f"关闭{name}失败:{err}") + + +async def run_startup_step( + name: str, + callback: Callable[[], object], + timeout_seconds: float | None = None, +) -> object: + """执行单个启动阶段并记录耗时,失败时保留原异常和 fail-fast 语义。""" + started_at = time.perf_counter() + try: + result = callback() + if inspect.isawaitable(result): + if timeout_seconds: + result = await asyncio.wait_for(result, timeout=timeout_seconds) + else: + result = await result + return result + finally: + elapsed_ms = (time.perf_counter() - started_at) * 1000 + logger.info("启动%s完成,耗时=%.2fms", name, elapsed_ms) + + +def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: + """按现有顺序构建应用组件清单,回调在每次 lifespan 启动时重新绑定。""" + return ( + LifecycleComponent( + name="HTTP 基础能力", + start=lambda: configure_default_user_agent(settings.USER_AGENT), + stop=aclose_shared_async_transports, + start_order=10, + stop_order=80, + start_timeout_seconds=30, + stop_timeout_seconds=120, + ), + LifecycleComponent( + name="领域依赖装配", + dependencies=("HTTP 基础能力",), + start=configure_domain_dependencies, + start_order=20, + start_timeout_seconds=30, + ), + LifecycleComponent( + name="数据库引擎预热", + dependencies=("领域依赖装配",), + start=lambda: (get_engine(), get_global_async_engine()), + start_order=30, + start_timeout_seconds=120, + ), + LifecycleComponent( + name="数据库连接预算", + dependencies=("数据库引擎预热",), + start=check_connection_budget, + start_order=40, + start_timeout_seconds=30, + ), + LifecycleComponent( + name="路由", + dependencies=("数据库连接预算",), + start=lambda: init_routers(app), + start_order=50, + start_timeout_seconds=30, + ), + LifecycleComponent( + name="模块服务", + dependencies=("路由",), + start=init_modules, + stop=stop_modules, + start_order=60, + stop_order=70, + start_timeout_seconds=300, + stop_timeout_seconds=300, + ), + LifecycleComponent( + name="插件备份恢复", + dependencies=("模块服务",), + mode=LifecycleMode.NORMAL_ONLY, + start=lambda: SystemChain().restore_plugins(), + start_order=70, + start_timeout_seconds=300, + ), + LifecycleComponent( + name="插件", + dependencies=("插件备份恢复",), + mode=LifecycleMode.NORMAL_ONLY, + start=init_plugins, + stop=stop_plugins, + start_order=80, + stop_order=60, + start_timeout_seconds=300, + stop_timeout_seconds=300, + ), + LifecycleComponent( + name="定时器", + dependencies=("插件",), + mode=LifecycleMode.NORMAL_ONLY, + start=init_scheduler, + stop=stop_scheduler, + start_order=90, + stop_order=50, + start_timeout_seconds=120, + stop_timeout_seconds=120, + ), + LifecycleComponent( + name="监控器", + dependencies=("定时器",), + mode=LifecycleMode.NORMAL_ONLY, + start=init_monitor, + stop=stop_monitor, + start_order=100, + stop_order=40, + start_timeout_seconds=120, + stop_timeout_seconds=120, + ), + LifecycleComponent( + name="待处理整理回放", + dependencies=("监控器",), + mode=LifecycleMode.NORMAL_ONLY, + start=replay_pending_transfers, + start_order=110, + start_timeout_seconds=30, + ), + LifecycleComponent( + name="命令服务", + dependencies=("待处理整理回放",), + mode=LifecycleMode.NORMAL_ONLY, + start=init_command, + stop=stop_command, + start_order=120, + stop_order=30, + start_timeout_seconds=120, + stop_timeout_seconds=120, + ), + LifecycleComponent( + name="工作流", + dependencies=("命令服务",), + mode=LifecycleMode.NORMAL_ONLY, + start=init_workflow, + stop=stop_workflow, + start_order=130, + stop_order=20, + start_timeout_seconds=120, + stop_timeout_seconds=120, + ), + LifecycleComponent( + name="插件备份", + dependencies=("插件",), + mode=LifecycleMode.NORMAL_ONLY, + stop=lambda: SystemChain().backup_plugins(), + stop_order=10, + stop_timeout_seconds=300, + ), + ) + + +def get_lifecycle_manifest(app: FastAPI, *, safe_mode: bool) -> tuple[dict[str, object], ...]: + """导出指定模式下的生命周期组件、依赖、顺序和超时清单。""" + return lifecycle_manifest( + build_lifecycle_components(app), + safe_mode=safe_mode, + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + 定义应用的生命周期事件 + """ + print("Starting up...") + # 存储当前循环 + global_vars.set_loop(asyncio.get_event_loop()) + # 同步与异步引擎各预热一次。引擎改为惰性创建后,两者的首次创建时机都不再由启动路径 + # 决定,这一步把它们拉回来。必须排在所有 init_* 之前,两个理由: + # + # 其一,fail-fast 的落点。异步驱动缺失、异步 URL 拼错这类问题若不在这里暴露,会一路 + # 推迟到第一个异步查询——表现为用户请求 500 或调度任务静默失败,而不是启动即崩。 + # 故意不 try/except:起不来就该起不来,吞掉它等于把 fail-fast 又还回去了。而既然会抛, + # 就必须抛在 init_routers / init_modules 之前——下面的 try/finally 关停块要到 yield 处 + # 才开始,在它之后抛异常,已经初始化好的模块就拿不到 stop_modules() 了。 + # + # 其二,同步引擎的首次创建要落在单线程期。init_db() 会顺带预热它,但那只对 + # run_application() 入口成立;外部 supervisor 直挂 ASGI app(如 + # `gunicorn -k uvicorn.workers.UvicornWorker app.factory:app`)时 init_db() 根本不执行, + # 首次创建便退到运行期——而那时 init_scheduler() / init_monitor() 已经放出上百个线程, + # 引擎构建里那段 PRAGMA journal_mode 会让它们一起堵在创建锁上。 + # + # 代价:异步侧几乎为零,create_async_engine 只校验 URL 与驱动导入、不建立连接;同步侧 + # 会连一次库、设一遍 journal mode,在事件循环上阻塞一小会儿——但那一次本来就免不了, + # 放在这里至少还独占着单线程,而且此刻 uvicorn 尚未开始接请求。 + components = build_lifecycle_components(app) + enabled_components = tuple( + component + for component in components + if component.enabled(settings.MOVIEPILOT_SAFE_MODE) + ) + logger.info( + "启用生命周期组件:%s", + ", ".join(component.name for component in enabled_components), + ) + for component in sorted( + (item for item in enabled_components if item.start is not None), + key=lambda item: item.start_order or 0, + ): + await run_startup_step( + component.name, + component.start, + component.start_timeout_seconds, + ) + if settings.MOVIEPILOT_SAFE_MODE: + print("MoviePilot safe mode enabled: skip plugins, scheduler, monitor, commands and workflow.") + # 插件同步到本地 + sync_plugins_task = asyncio.create_task( + run_startup_step("插件同步与启动收尾", init_extra) + ) + try: + # 在此处 yield,表示应用已经启动,控制权交回 FastAPI 主事件循环 + yield + finally: + print("Shutting down...") + global_vars.stop_system() + # 取消同步插件任务 + try: + sync_plugins_task.cancel() + await sync_plugins_task + except asyncio.CancelledError: + pass + except Exception as e: + print(str(e)) + try: + for component in sorted( + (item for item in enabled_components if item.stop is not None), + key=lambda item: item.stop_order or 0, + ): + await run_shutdown_step( + component.name, + component.stop, + component.stop_timeout_seconds, + ) + finally: + # 日志最后关闭,确保其他组件的收尾信息已写入文件 + LoggerManager.shutdown() diff --git a/app/startup/lifecycle/components.py b/app/startup/lifecycle/components.py new file mode 100644 index 000000000..c33545b9d --- /dev/null +++ b/app/startup/lifecycle/components.py @@ -0,0 +1,66 @@ +"""应用启动和关闭组件的声明模型。""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import Optional + + +class LifecycleMode(StrEnum): + """声明组件在哪一种应用运行模式下启用。""" + + ALWAYS = "always" + NORMAL_ONLY = "normal_only" + + +class LifecycleFailurePolicy(StrEnum): + """声明生命周期回调失败后的控制流策略。""" + + FAIL_FAST = "fail_fast" + CONTINUE = "continue" + + +@dataclass(frozen=True, slots=True) +class LifecycleComponent: + """描述一个可检查的启动或关闭组件及其顺序和失败策略。""" + + name: str + dependencies: tuple[str, ...] = () + mode: LifecycleMode = LifecycleMode.ALWAYS + start: Optional[Callable[[], object]] = None + stop: Optional[Callable[[], object]] = None + start_order: Optional[int] = None + stop_order: Optional[int] = None + start_timeout_seconds: Optional[float] = None + stop_timeout_seconds: Optional[float] = None + start_failure: LifecycleFailurePolicy = LifecycleFailurePolicy.FAIL_FAST + stop_failure: LifecycleFailurePolicy = LifecycleFailurePolicy.CONTINUE + + def enabled(self, safe_mode: bool) -> bool: + """判断组件是否应在当前安全模式设置下启用。""" + return self.mode is LifecycleMode.ALWAYS or not safe_mode + + +def lifecycle_manifest( + components: tuple[LifecycleComponent, ...], + *, + safe_mode: bool, +) -> tuple[dict[str, object], ...]: + """导出当前模式下启用组件的稳定、可序列化生命周期清单。""" + return tuple( + { + "name": component.name, + "dependencies": component.dependencies, + "mode": component.mode.value, + "start_order": component.start_order, + "stop_order": component.stop_order, + "start_timeout_seconds": component.start_timeout_seconds, + "stop_timeout_seconds": component.stop_timeout_seconds, + "start_failure": component.start_failure.value, + "stop_failure": component.stop_failure.value, + } + for component in components + if component.enabled(safe_mode) + ) diff --git a/app/startup/modules_initializer.py b/app/startup/modules_initializer.py index 4e47e13cd..323d3410c 100644 --- a/app/startup/modules_initializer.py +++ b/app/startup/modules_initializer.py @@ -19,7 +19,7 @@ from app.adapters.system.host import SystemUtils from app.runtime.log import logger from app.runtime.config import settings from app.runtime.extensions.module_manager import ModuleManager -from app.runtime.events import EventManager +from app.runtime.events import EventHandlerBinding, EventManager from app.runtime.state import SystemHelper from app.runtime.thread import ThreadHelper from app.adapters.network.doh import DohHelper @@ -28,11 +28,19 @@ from app.adapters.system.resource import ( configure_resource_version_provider, ) from app.application.messaging.message import MessageHelper, stop_message -from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.external.server import ( + MoviePilotServerHelper, + configure_server_application_services, +) +from app.application.server.report import ServerReportService +from app.application.server.share import ServerSharingService from app.db import close_database +from app.db.oper.subscribe import SubscribeOper from app.db.oper.systemconfig import SystemConfigOper +from app.db.oper.workflow import WorkflowOper from app.command import CommandChain -from app.schemas import Message, MessageType +from app.schemas.message import Message +from app.schemas.message import MessageType from app.schemas.types import SystemConfigKey from app.startup.agent_initializer import init_agent, stop_agent from app.startup.managed_resources_initializer import ( @@ -42,6 +50,62 @@ from app.startup.managed_resources_initializer import ( from app.application.security.access import set_superuser_token_payload_provider from app.application.security.auth import build_superuser_token_payload from app.application.image import configure_wallpaper_providers +from app.application.chain.context import ( + build_default_chain_runtime_context, + configure_chain_runtime_context_provider, +) +from app.runtime.extensions.service_config import configure_service_config_reader + + +async def _async_get_subscribe(subscribe_id: int): + """通过数据库操作器异步读取订阅,供服务端共享用例使用。""" + return await SubscribeOper().async_get(subscribe_id) + + +async def _async_get_workflow(workflow_id: int): + """通过数据库操作器异步读取工作流,供服务端共享用例使用。""" + return await WorkflowOper().async_get(workflow_id) + + +def configure_runtime_data_providers() -> None: + """在启动组合层装配运行时和外部服务所需的数据库读取能力。""" + configure_service_config_reader(lambda key: SystemConfigOper().get(key)) + configure_server_application_services( + report_service=ServerReportService( + config_reader=lambda key: SystemConfigOper().get(key), + config_writer=lambda key, value: SystemConfigOper().set(key, value), + installed_plugins_provider=lambda: SystemConfigOper().get( + SystemConfigKey.UserInstalledPlugins + ) or [], + subscribes_provider=lambda: SubscribeOper().list(), + plugin_report_sender=MoviePilotServerHelper.plugin_install_report, + async_plugin_report_sender=( + MoviePilotServerHelper.async_plugin_install_report + ), + subscribe_report_sender=MoviePilotServerHelper.subscribe_report, + repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url, + ), + sharing_service=ServerSharingService( + subscribe_provider=lambda subscribe_id: SubscribeOper().get( + subscribe_id + ), + async_subscribe_provider=_async_get_subscribe, + workflow_provider=lambda workflow_id: WorkflowOper().get(workflow_id), + async_workflow_provider=_async_get_workflow, + user_uuid_provider=MoviePilotServerHelper.get_user_uuid, + subscribe_sender=MoviePilotServerHelper.subscribe_share, + async_subscribe_sender=MoviePilotServerHelper.async_subscribe_share, + workflow_sender=MoviePilotServerHelper.workflow_share, + async_workflow_sender=MoviePilotServerHelper.async_workflow_share, + response_handler=MoviePilotServerHelper._handle_response, + subscribe_cache_clearer=( + MoviePilotServerHelper._clear_subscribe_share_cache + ), + workflow_cache_clearer=( + MoviePilotServerHelper._clear_workflow_share_cache + ), + ), + ) def configure_wallpaper_services() -> None: @@ -65,6 +129,50 @@ def notify_event_error(title: str, message: str) -> None: ) +def get_host_event_handler_factories() -> dict[type, Callable[[], object]]: + """返回所有使用事件装饰器的宿主类及其明确实例工厂。""" + from app.chain.download import DownloadChain + from app.chain.scraping import ScrapingChain + from app.chain.search import SearchChain + from app.chain.site import SiteChain + from app.chain.subscribe import SubscribeChain + from app.chain.workflow import WorkflowChain + from app.command import Command + from app.scheduler import Scheduler + + return { + Command: Command, + DownloadChain: DownloadChain, + Scheduler: Scheduler, + ScrapingChain: ScrapingChain, + SearchChain: SearchChain, + SiteChain: SiteChain, + SubscribeChain: SubscribeChain, + WorkflowChain: WorkflowChain, + } + + +def configure_host_event_handler_resolver() -> None: + """显式登记宿主内置类处理器,禁止事件总线按类名临时构造未知对象。""" + factories = get_host_event_handler_factories() + + def resolve(owner_class: type) -> EventHandlerBinding | None: + """按明确白名单复用单例或构造与旧路径等价的 Chain 实例。""" + factory = factories.get(owner_class) + if factory is None: + return None + get_existing = getattr(owner_class, "get_existing_instance", None) + instance = get_existing() if callable(get_existing) else None + if instance is None: + instance = factory() + return EventHandlerBinding( + instance=instance, + owner_name=owner_class.__name__, + ) + + EventManager().register_handler_instance_resolver("host", resolve) + + def start_frontend(): """ 启动前端服务 @@ -212,10 +320,14 @@ async def init_modules(): """ 启动模块 """ + # 数据访问能力统一在启动组合根注入,Runtime 和 Adapter 不再直接依赖 Oper。 + configure_runtime_data_providers() # 托管资源只在这里装配声明与 adapter,具体资源仍由首个消费者显式激活。 init_managed_resources() # 应用服务不反向依赖 Chain,由启动组合层注入壁纸来源。 configure_wallpaper_services() + # Chain 无参兼容入口由组合根明确提供依赖上下文;测试和新代码可直接注入替代上下文。 + configure_chain_runtime_context_provider(build_default_chain_runtime_context) # 认证访问层不反向依赖数据库实现,由启动组合层注入载荷提供器。 set_superuser_token_payload_provider(build_superuser_token_payload) # DoH @@ -228,6 +340,8 @@ async def init_modules(): user_auth() # 事件错误通知由启动组合层接入消息服务。 EventManager().set_error_notifier(notify_event_error) + # 宿主类处理器在启动层显式登记,事件总线不再兜底 owner_class()。 + configure_host_event_handler_resolver() # 加载模块 ModuleManager() # 启动事件消费 diff --git a/app/startup/plugins_initializer.py b/app/startup/plugins_initializer.py index 2fac784b0..94d12c661 100644 --- a/app/startup/plugins_initializer.py +++ b/app/startup/plugins_initializer.py @@ -6,17 +6,46 @@ from app.runtime.compat.diagnostics import ( ) from app.runtime.compat.resource_imports import scan_plugin_resource_imports from app.runtime.config import global_vars +from app.runtime.config import settings from app.runtime.extensions.plugin_manager import ( PluginManager, + configure_plugin_catalog_factory, configure_plugin_install_reporter, configure_plugin_legacy_import_services, configure_plugin_resource_import_preparer, configure_site_auth_level_provider, ) +from app.application.plugin.catalog import PluginCatalogService +from app.adapters.external.plugin.client import PluginMarketClient +from app.runtime.extensions.plugin.storage import ( + PluginStorage, + configure_plugin_storage, +) +from app.runtime.extensions.plugin.system import ( + PluginSystemServices, + configure_plugin_system, +) from app.runtime.managed_resources import acquire_managed_resource from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.external.market import ( + PluginHelper, + VERSION_BACKWARD_COMPATIBLE_FLAGS, + configure_installed_plugins_provider, +) +from app.adapters.system.plugin.dependency import PluginDependencyInstaller +from app.adapters.system.plugin.package import PluginPackageManager +from app.adapters.system.host import SystemUtils +from app.db.oper.plugindata import PluginDataOper +from app.db.oper.systemconfig import SystemConfigOper from app.runtime.log import logger +from app.foundation.version import compare_version +from app.schemas.types import SystemConfigKey + + +async def _async_write_plugin_config(key, value): + """通过数据库操作器异步保存插件运行时配置。""" + return await SystemConfigOper().async_set(key, value) def _prepare_legacy_plugin_import(*, plugin_id: str, plugin_dir: Path) -> None: @@ -30,6 +59,8 @@ def _prepare_legacy_plugin_import(*, plugin_id: str, plugin_dir: Path) -> None: def _configure_plugin_services() -> None: """把兼容诊断、远程上报和站点认证等级装配到插件管理器。""" + plugin_helper = PluginHelper() + market_client = PluginMarketClient(plugin_helper) configure_plugin_legacy_import_services( diagnostics_configurator=configure_legacy_import_diagnostics, import_scanner=scan_plugin_legacy_imports, @@ -37,6 +68,50 @@ def _configure_plugin_services() -> None: configure_plugin_resource_import_preparer(_prepare_legacy_plugin_import) configure_plugin_install_reporter(MoviePilotServerHelper.install_plugin_reg) configure_site_auth_level_provider(lambda: SitesHelper().auth_level) + configure_installed_plugins_provider( + lambda: SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + ) + configure_plugin_catalog_factory(_build_plugin_catalog) + configure_plugin_system(PluginSystemServices( + market=market_client, + package=PluginPackageManager(plugin_helper), + dependency=PluginDependencyInstaller( + plugin_helper, + installed_plugins_provider=lambda: SystemConfigOper().get( + SystemConfigKey.UserInstalledPlugins + ) or [], + plugin_dir=Path(settings.ROOT_PATH) / "app" / "plugins", + ), + compatible_flags=lambda flag: ( + [flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, []) + if flag else [] + ), + frozen=SystemUtils.is_frozen, + )) + configure_plugin_storage(PluginStorage( + read=lambda key: SystemConfigOper().get(key), + write=lambda key, value: SystemConfigOper().set(key, value), + async_write=_async_write_plugin_config, + delete=lambda key: SystemConfigOper().delete(key), + delete_data=lambda plugin_id: PluginDataOper().del_data(plugin_id), + )) + + +def _build_plugin_catalog(manager: PluginManager) -> PluginCatalogService: + """在组合根连接目录用例、市场客户端、持久化读取和插件 DTO 映射。""" + client = PluginMarketClient() + return PluginCatalogService( + market_loader=client.get_plugins, + async_market_loader=client.async_get_plugins, + installed_plugins_provider=lambda: SystemConfigOper().get( + SystemConfigKey.UserInstalledPlugins + ) or [], + plugin_mapper=manager._process_plugin_info, + is_local_repo=PluginMarketClient.is_local_repo_url, + version_compare=compare_version, + warning=logger.warning, + error=logger.error, + ) async def sync_plugins() -> bool: diff --git a/app/workflow/__init__.py b/app/workflow/__init__.py index 9e558836b..3646a80c5 100644 --- a/app/workflow/__init__.py +++ b/app/workflow/__init__.py @@ -10,7 +10,9 @@ from app.db.models import Workflow from app.db.oper.workflow import WorkflowOper from app.foundation.reflection import ModuleHelper from app.runtime.log import logger -from app.schemas import ActionContext, Action, ActionResult +from app.schemas.workflow import ActionContext +from app.schemas.workflow import Action +from app.schemas.workflow import ActionResult from app.schemas.types import EventType from app.foundation.singleton import Singleton diff --git a/app/workflow/actions/__init__.py b/app/workflow/actions/__init__.py index 79a0e37c1..5b727e838 100644 --- a/app/workflow/actions/__init__.py +++ b/app/workflow/actions/__init__.py @@ -3,7 +3,9 @@ from typing import Any, Union from app.chain import ChainBase from app.db.oper.systemconfig import SystemConfigOper -from app.schemas import ActionContext, ActionParams, ActionResult +from app.schemas.workflow import ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionResult class ActionChain(ChainBase): diff --git a/app/workflow/actions/add_download.py b/app/workflow/actions/add_download.py index e07909a92..a32644b45 100644 --- a/app/workflow/actions/add_download.py +++ b/app/workflow/actions/add_download.py @@ -8,7 +8,10 @@ from app.chain.media import MediaChain from app.runtime.config import global_vars from app.domain.metainfo import MetaInfo from app.runtime.log import logger -from app.schemas import ActionParams, ActionContext, DownloadTask, MediaType +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext +from app.schemas.workflow import DownloadTask +from app.schemas.types import MediaType class AddDownloadParams(ActionParams): diff --git a/app/workflow/actions/add_subscribe.py b/app/workflow/actions/add_subscribe.py index f8e8e62d3..6fac99dd6 100644 --- a/app/workflow/actions/add_subscribe.py +++ b/app/workflow/actions/add_subscribe.py @@ -4,7 +4,8 @@ from app.runtime.config import settings, global_vars from app.domain.context import MediaInfo from app.db.oper.subscribe import SubscribeOper from app.runtime.log import logger -from app.schemas import ActionParams, ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext class AddSubscribeParams(ActionParams): diff --git a/app/workflow/actions/fetch_downloads.py b/app/workflow/actions/fetch_downloads.py index f7c5b3bed..8cc0f7c35 100644 --- a/app/workflow/actions/fetch_downloads.py +++ b/app/workflow/actions/fetch_downloads.py @@ -1,6 +1,7 @@ from app.workflow.actions import BaseAction, ActionChain from app.runtime.config import global_vars -from app.schemas import ActionParams, ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext from app.runtime.log import logger diff --git a/app/workflow/actions/fetch_medias.py b/app/workflow/actions/fetch_medias.py index 5127410aa..38c1154ed 100644 --- a/app/workflow/actions/fetch_medias.py +++ b/app/workflow/actions/fetch_medias.py @@ -4,11 +4,13 @@ from pydantic import Field from app.workflow.actions import BaseAction from app.chain.recommend import RecommendChain -from app.schemas import ActionParams, ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext from app.runtime.config import settings, global_vars from app.runtime.events import eventmanager from app.runtime.log import logger -from app.schemas import RecommendSourceEventData, MediaInfo +from app.schemas.event import RecommendSourceEventData +from app.schemas.workflow import MediaInfo from app.schemas.types import ChainEventType from app.adapters.network.http import RequestUtils diff --git a/app/workflow/actions/fetch_rss.py b/app/workflow/actions/fetch_rss.py index 129aaef40..358b6c172 100644 --- a/app/workflow/actions/fetch_rss.py +++ b/app/workflow/actions/fetch_rss.py @@ -9,7 +9,8 @@ from app.domain.context import Context, TorrentInfo from app.domain.metainfo import MetaInfo from app.application.rss import RssHelper from app.runtime.log import logger -from app.schemas import ActionParams, ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext class FetchRssParams(ActionParams): diff --git a/app/workflow/actions/fetch_torrents.py b/app/workflow/actions/fetch_torrents.py index d3ed09ea6..14b69f957 100644 --- a/app/workflow/actions/fetch_torrents.py +++ b/app/workflow/actions/fetch_torrents.py @@ -9,7 +9,9 @@ from app.chain.media import MediaChain from app.chain.search import SearchChain from app.runtime.config import global_vars from app.runtime.log import logger -from app.schemas import ActionParams, ActionContext, MediaType +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext +from app.schemas.types import MediaType class FetchTorrentsParams(ActionParams): diff --git a/app/workflow/actions/filter_medias.py b/app/workflow/actions/filter_medias.py index d4a679929..a414a732b 100644 --- a/app/workflow/actions/filter_medias.py +++ b/app/workflow/actions/filter_medias.py @@ -5,7 +5,8 @@ from pydantic import Field from app.workflow.actions import BaseAction from app.runtime.config import global_vars from app.runtime.log import logger -from app.schemas import ActionParams, ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext class FilterMediasParams(ActionParams): diff --git a/app/workflow/actions/filter_torrents.py b/app/workflow/actions/filter_torrents.py index 56803316b..aae73ce35 100644 --- a/app/workflow/actions/filter_torrents.py +++ b/app/workflow/actions/filter_torrents.py @@ -6,7 +6,8 @@ from app.workflow.actions import BaseAction, ActionChain from app.runtime.config import global_vars from app.application.torrent import TorrentHelper from app.runtime.log import logger -from app.schemas import ActionParams, ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext class FilterTorrentsParams(ActionParams): diff --git a/app/workflow/actions/invoke_plugin.py b/app/workflow/actions/invoke_plugin.py index 1a5aed1c6..528cdee21 100644 --- a/app/workflow/actions/invoke_plugin.py +++ b/app/workflow/actions/invoke_plugin.py @@ -3,7 +3,8 @@ from pydantic import Field from app.workflow.actions import BaseAction from app.runtime.extensions.plugin_manager import PluginManager from app.runtime.log import logger -from app.schemas import ActionParams, ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext class InvokePluginParams(ActionParams): diff --git a/app/workflow/actions/note.py b/app/workflow/actions/note.py index ed9d1767b..8b9b7bb86 100644 --- a/app/workflow/actions/note.py +++ b/app/workflow/actions/note.py @@ -1,5 +1,5 @@ from app.workflow.actions import BaseAction -from app.schemas import ActionContext +from app.schemas.workflow import ActionContext class NoteAction(BaseAction): diff --git a/app/workflow/actions/scan_file.py b/app/workflow/actions/scan_file.py index 34cf52165..86508319d 100644 --- a/app/workflow/actions/scan_file.py +++ b/app/workflow/actions/scan_file.py @@ -7,7 +7,8 @@ from app.workflow.actions import BaseAction from app.chain.storage import StorageChain from app.runtime.config import global_vars, settings from app.runtime.log import logger -from app.schemas import ActionParams, ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext class ScanFileParams(ActionParams): diff --git a/app/workflow/actions/scrape_file.py b/app/workflow/actions/scrape_file.py index b7b2dcda1..1b44b136a 100644 --- a/app/workflow/actions/scrape_file.py +++ b/app/workflow/actions/scrape_file.py @@ -3,7 +3,8 @@ from app.chain.scraping import ScrapingChain from app.chain.storage import StorageChain from app.runtime.config import global_vars from app.runtime.log import logger -from app.schemas import ActionParams, ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext from app.workflow.actions import BaseAction diff --git a/app/workflow/actions/send_event.py b/app/workflow/actions/send_event.py index 28a41397a..2d477fd12 100644 --- a/app/workflow/actions/send_event.py +++ b/app/workflow/actions/send_event.py @@ -1,6 +1,7 @@ from app.workflow.actions import BaseAction from app.runtime.events import eventmanager -from app.schemas import ActionParams, ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext from app.schemas.types import ChainEventType diff --git a/app/workflow/actions/send_message.py b/app/workflow/actions/send_message.py index b5c88a327..21ca9a039 100644 --- a/app/workflow/actions/send_message.py +++ b/app/workflow/actions/send_message.py @@ -3,7 +3,9 @@ from typing import List, Optional, Union from pydantic import Field from app.workflow.actions import BaseAction, ActionChain -from app.schemas import ActionParams, ActionContext, Message +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext +from app.schemas.message import Message from app.runtime.config import settings diff --git a/app/workflow/actions/transfer_file.py b/app/workflow/actions/transfer_file.py index 2b1dbefd1..c928594fd 100644 --- a/app/workflow/actions/transfer_file.py +++ b/app/workflow/actions/transfer_file.py @@ -7,7 +7,8 @@ from pydantic import Field from app.workflow.actions import BaseAction from app.runtime.config import global_vars from app.db.oper.transferhistory import TransferHistoryOper -from app.schemas import ActionParams, ActionContext +from app.schemas.workflow import ActionParams +from app.schemas.workflow import ActionContext from app.chain.storage import StorageChain from app.chain.transfer import TransferChain from app.runtime.log import logger diff --git a/docs/backend-architecture-governance.md b/docs/backend-architecture-governance.md new file mode 100644 index 000000000..c67dc39d7 --- /dev/null +++ b/docs/backend-architecture-governance.md @@ -0,0 +1,1439 @@ +# MoviePilot V3 后端架构提升与分阶段治理方案 + +> 文档性质:现状审计、目标约束、迁移路线和 AI 实施手册 +> 适用仓库:`MoviePilot`,分支 `v3` +> 审计基线:2026-08-17 当前工作树 +> 相关规范:`AGENTS.md`、`docs/rules/05-architecture.md`、`docs/architecture-overview.md`、`docs/backend-module-refactor-compatibility.md` + +## 1. 文档目的 + +本文件不是另一份目录说明,也不是一次大规模重构设计稿。它解决四个更具体的问题: + +1. 区分已经完成的物理目录迁移与仍未解决的职责、依赖和运行时契约问题。 +2. 把问题定位到具体模块、类、方法和调用边界,给出可逐批落地的迁移方向。 +3. 为其他 AI 提供可以直接执行的任务边界、兼容约束、验证命令和完成标准。 +4. 在不破坏 V3 插件生态的前提下,逐步收敛宿主内部结构,而不是用一次性改名制造新的兼容层。 + +本文同时记录治理方案和当前工作树的实施状态。阶段 0 至阶段 5 已完成本轮中期验收所需的垂直切片;阶段 6 以后仍是后续路线。这里的“完成”只表示本轮验收边界已锁定,不表示所有 API、Chain、Agent 或兼容实现都已经长期收敛。每个阶段是否完成必须以本文件的机器基线、聚焦测试、插件兼容扫描和完整测试门禁为准,不能只凭目录已经创建判断。 + +## 2. 范围与明确排除项 + +### 2.1 纳入范围 + +- FastAPI 入口、路由、响应封装和动态路由注册。 +- `chain` 编排层、`application` 应用能力、`domain` 领域语义。 +- `runtime` 进程级基础设施、事件、模块、插件和服务生命周期。 +- `adapters` 技术适配与命名外部系统。 +- `db/models`、`db/oper`、会话与事务边界。 +- `modules` 宿主模块 SPI 及其与应用层的交互。 +- Agent、LLM Provider、工具注册和流式 API 的职责边界。 +- `sdk` 与 `runtime/compat` 形成的插件公开 ABI。 +- 启动、关闭、安全模式、热重载和后台任务的组合关系。 + +### 2.2 排除项 + +- **不审计、不迁移 `app/plugins/` 中的代码。**该目录是已安装插件副本,不是后端架构源代码,也不能作为插件兼容性的唯一事实来源。 +- 插件兼容基线应读取同工作区独立仓库 `../MoviePilot-Plugins` 的 `plugins.v2/`、`plugins.v3/`,再配合宿主的 SDK、兼容清单和插件管理器契约判断。 +- 不把 `app/modules/themoviedb/` 内部第三方或移植代码的局部循环,直接等同于 MoviePilot 自有架构失败。它需要被隔离,但不应优先重写上游库。 +- 本轮不主张数据库表结构变更。纯架构批次不得夹带 Alembic 迁移、字段重命名或数据回填。 +- 本轮不主张删除 V3 兼容映射。任何删除都应作为显式破坏性变更另行决策。 + +## 3. 结论摘要 + +MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helper`、`app/utils` 已转为虚拟兼容入口;`foundation`、`domain`、`runtime`、`adapters`、`application`、`chain`、`startup`、`sdk` 的目标方向也已经写入规范;现有架构门禁通过。 + +当前的主要问题已不再是“文件放错目录”这么简单,而是以下八类结构性问题: + +1. **规范比门禁严格。**现有测试能阻止核心实现层形成环,但允许 `chain`、`schemas`、`db`、Agent 子域和模块内部继续形成 SCC,也没有覆盖所有越层依赖。 +2. **核心运行契约是字符串和约定。**`ChainBase.run_module()` 依赖方法名、签名探测、返回值形态和执行顺序;插件生命周期也依赖一组隐式 `get_*`/`init_*` 方法。它们是实际 ABI,却没有统一契约清单。 +3. **编排类和端点承担过多职责。**订阅、搜索、整理、下载、Agent、插件管理、外部市场和服务端客户端均出现千行级文件、百行级方法和多种基础设施混合。 +4. **数据库边界没有收口。**API、Chain、Scheduler、Application 直接依赖 ORM 模型或会话;模型本身又包含查询方法,和“统一经 Oper 访问”的目标不一致。 +5. **组合根仍有泄漏。**全局单例、模块导入时创建 FastAPI app、事件解析器兜底实例化处理器、各 Chain 构造时自行抓取管理器,隐藏了依赖和所有权。 +6. **Adapter、Application、Runtime 之间仍有反向依赖。**外部适配器直接读写 Oper,Runtime 插件管理器和服务注册直接读取系统配置,Application 消息能力直接引用 Agent 实现。 +7. **插件兼容面大且缺少版本化。**旧导入、SDK、管理器具体类型、动态 API、事件装饰器、模块方法和热重载行为共同构成 ABI;目前主要靠兼容清单和测试样例保护。 +8. **治理缺少可量化收敛目标。**测试绿只能说明已有规则没有被违反,不能说明巨型模块、隐式协议、直接数据库访问和内部环已经减少。 + +治理顺序必须是:**先冻结行为契约和补门禁,再拆环和依赖,再拆职责,最后才讨论缩减兼容面。** + +## 4. 审计方法与当前基线 + +### 4.1 方法 + +本次基线使用以下方式获得: + +- 读取仓库与后端架构规则。 +- 运行 `tests/test_architecture_dependencies.py`。 +- 复用该测试的 AST 模块解析逻辑,统计 `app/` 内部依赖;排除 `app/plugins/`。 +- 统计文件规模、类和方法规模、入度、出度、SCC。 +- 沿启动、事件、模块、插件、Chain、API、Oper、Agent 的真实调用路径阅读。 +- 扫描独立插件仓的导入路径和插件钩子定义;不读取 `app/plugins/` 副本作为设计依据。 + +### 4.2 已验证结果 + +```text +./.venv/bin/python -m pytest tests/test_architecture_dependencies.py -q +26 passed +``` + +这只能证明当前代码符合现有门禁,不能证明符合本文件提出的更完整目标。 + +### 4.3 模块规模 + +排除 `app/plugins/` 后,当前静态扫描得到 707 个 Python 模块、6,096 条内部导入边。主要一级目录规模如下(代码行数包含注释和空行,用于趋势比较而非质量评分): + +| 一级目录 | 约代码行数 | Python 文件数 | 判断 | +| --- | ---: | ---: | --- | +| `app/modules` | 67,396 | 147 | 体量最大,包含大量具体平台模块和移植代码,需按模块族治理 | +| `app/agent` | 40,494 | 140 | Provider、工具、编排、策略均较重,应按子域治理 | +| `app/chain` | 29,663 | 36 | 文件不多但平均体量大,是优先拆分对象 | +| `app/api` | 16,745 | 42 | 多个端点含用例、持久化和流式协议实现 | +| `app/application` | 17,117 | 65 | 已承接多项用例,但部分仍是兼容 Facade 或反向依赖具体实现 | +| `app/runtime` | 13,976 | 48 | 插件注册/投影和事件运行时已拆出,宿主生命周期仍集中 | +| `app/adapters` | 12,721 | 35 | 插件市场、包、依赖和服务端入口已分出,旧 ABI 实现仍保留 | +| `app/db` | 8,181 | 49 | 根入口和模型兼容层已收敛,剩余局部环需后续治理 | +| `app/domain` | 7,654 | 21 | 相对可控,后续应继续保持纯语义 | +| `app/schemas` | 7,698 | 39 | 根入口已改为生成清单和惰性兼容导出 | + +### 4.4 高出度模块 + +| 模块 | 静态出度 | 主要原因 | +| --- | ---: | --- | +| `app.agent.tools.factory` | 99 | 一次性导入全部内置工具并维护集中注册表 | +| `app.startup.modules_initializer` | 55 | 组合根职责,这是合理高出度,但仍需声明式管理 | +| `app.api.endpoints.system` | 54 | 系统设置、规则测试、日志、网络测试、运行控制混合 | +| `app.api.deps` | 49 | 认证、插件配置和跨端点依赖装配集中 | +| `app.agent.orchestrator` | 48 | Agent 构建、执行、工具、记忆、审计、用量混合 | +| `app.chain.message` | 48 | 消息路由和多个业务域耦合 | +| `app.chain.subscribe` | 48 | 写入、识别、搜索、匹配、完成、分享混合 | +| `app.chain.download` | 45 | 下载选择、客户端调用、字幕和历史混合 | +| `app.scheduler` | 42 | 调度定义、业务调用和运行控制仍混合,清理已迁出 | +| `app.chain.transfer` | 41 | 计划、执行、刮削、通知、回调、清理混合 | + +`app.runtime`、`app.schemas`、`app.db` 等包入口具有很高入度。高入度本身不等于错误,但意味着它们是兼容和回归风险集中的枢纽,不能随意改变导出行为。 + +### 4.5 当前循环依赖 + +静态扫描共发现 9 个 SCC。首批 `schemas`、`db`、订阅音乐和 filemanager 目标环已消除,当前剩余环如下: + +| SCC | 类型 | 优先级 | 处理原则 | +| --- | --- | --- | --- | +| `app.agent.llm`、`provider`、`helper`、`capability` | Agent 子域环 | P1 | 拆 Provider 元数据、协议适配、运行时与授权 | +| `app.agent.policy` 子模块环 | Agent 子域环 | P1 | 把 policy 数据、registry、sanitizer 依赖方向固定 | +| `app.doctor`、`app.monitor` 局部环 | 自有运行能力环 | P2 | 结合生命周期治理拆分 | +| `app.modules.qqbot` 局部环 | 平台模块局部环 | P2 | 模块内部单独处理 | +| `app.modules.telegram` 局部环 | 平台模块局部环 | P2 | 模块内部单独处理 | +| `app.modules.trimemedia` 局部环 | 平台模块局部环 | P2 | 模块内部单独处理 | +| `app.modules.ugreen` 局部环 | 平台模块局部环 | P2 | 模块内部单独处理 | +| `app.modules.themoviedb` 及其对象模型环 | 移植/第三方局部环 | 隔离 | 保持包内封闭,不让环越出模块边界,不优先重写 | + +现有架构测试重点限制 `foundation/domain/runtime/adapters/application` 实现根和进程级跨包环,因此包内部的 `chain`、`schemas`、`db` 环仍能通过。后续门禁必须覆盖“自有代码 SCC 不增长”和“目标 SCC 逐项归零”。 + +### 4.6 阶段 0-5 实施后的机器基线 + +当前工作树重新生成 `tests/fixtures/architecture/dependency-baseline.json` 后得到: + +| 指标 | 初始审计 | 当前基线 | 说明 | +| --- | ---: | ---: | --- | +| Python 模块数 | 约 654 | 707 | 增量主要来自单一职责的 Application、Runtime、Adapter 和维护用例模块 | +| 内部导入边 | 约 5,623 | 6,096 | 新增显式端口和组合连接后边数增加,不能单独把边数下降当目标 | +| SCC 数 | 14 | 9 | Schema、DB、订阅音乐、filemanager 等本轮目标环已消除 | +| `adapters -> db` | 存在 | 0 | `PluginHelper`、`MoviePilotServerHelper` 的本地数据读取已移到组合根/Application | +| `runtime -> db` | 存在 | 0 | 插件存储、服务配置均改为启动注入 | + +剩余 9 个 SCC 位于 Agent LLM、Agent policy、Doctor/Monitor、TMDB 移植包及 QQBot、Telegram、TriMedia、UGreen 等模块内部,属于阶段 6 或隔离治理范围,不应为了宣布阶段 0-5 完成而仓促改写。 + +机器基线来源: + +- `tests/fixtures/architecture/dependency-baseline.json`:模块、边、SCC 和目标边。 +- `tests/fixtures/architecture/runtime-contract-baseline.json`:SDK、兼容清单、事件和 `run_module` 合同。 +- `tests/fixtures/architecture/official-plugin-baseline.json`:独立官方插件仓 V2/V3 导入及钩子快照。 +- `app/schemas/exports.py`:Schema 根入口的生成式兼容导出清单。 + +## 5. 目标架构与依赖方向 + +既有架构规则继续是规范来源。本文件补充的是可执行边界。 + +### 5.1 目标调用路径 + +```text +HTTP / CLI / Event / Scheduler / Plugin Hook + | + v + Transport / Runtime Adapter + | + v + Application Use Case / Chain Facade + | + +-------+--------+ + | | + v v + Domain Policy Application Port + | + v + Adapter / Oper / External Client +``` + +### 5.2 各层应承担的职责 + +| 层 | 应承担 | 不应承担 | +| --- | --- | --- | +| `foundation` | 无状态通用算法、值归一化、基础类型工具 | 配置、日志、数据库、HTTP、单例、业务流程 | +| `domain` | 媒体身份、规则、匹配、领域值和纯策略 | FastAPI、SQLAlchemy 会话、网络、调度器、插件管理器 | +| `runtime` | 事件循环、进程资源、扩展生命周期、执行上下文 | 具体业务查询、外部市场业务、页面 DTO 拼装 | +| `adapters` | HTTP、浏览器、文件系统、系统、命名外部服务的具体 I/O | 直接决定用例、直接持久化业务状态 | +| `application` | 有状态单能力、用例、端口协议、跨 adapter 的短流程 | 动态抓取全局管理器、长期进程生命周期、巨型多域编排 | +| `chain` | 面向用户目标的多域编排和向后兼容门面 | 直接写 SQL、实现底层协议、复制纯领域算法 | +| `modules` | 可替换宿主能力 Provider,实现模块 SPI | 反向控制 Chain、直接掌管宿主生命周期 | +| `api` | 参数解析、鉴权、传输 DTO、状态码、流协议 | 直接会话事务、调度细节、业务分支和外部上报 | +| `startup` | 唯一组合根、创建并连接实例、决定启停顺序 | 业务规则和常态请求处理 | +| `sdk` | 稳定、文档化、受测试保护的插件公开门面 | 随意导出内部单例和具体实现的新符号 | +| `runtime/compat` | 精确恢复旧路径和旧符号 | 承载新业务逻辑或模糊吞掉所有导入错误 | + +### 5.3 强制依赖规则 + +后续新增代码应满足: + +1. `foundation` 不依赖其他 MoviePilot 层。 +2. `domain` 只依赖 `foundation` 和纯类型;必要 DTO 应移动到领域或契约模块,而不是依赖运行时 schema 聚合入口。 +3. `runtime` 不直接依赖 `db.oper`、具体外部服务或 Chain。 +4. `adapters` 不直接使用业务 Oper;外部结果通过返回值交给 Application 决定是否持久化。 +5. `application` 不直接依赖 `api`、`startup`,不引用 Agent/Module 的具体类;通过 Protocol 或组合根注入。 +6. `api` 不新增 `app.db.models`、`Session`、`AsyncSession`、`Scheduler`、`PluginManager` 的直接使用。 +7. `chain` 不新增裸会话或直接 SQL,不新增通过延迟导入掩盖的环。 +8. `modules` 可实现宿主 SPI,可调用稳定的 Application 能力,但不得让 Application 反向依赖具体模块类。 +9. 只有 `startup` 和非常薄的兼容门面可以装配具体实现。 +10. 兼容入口不反向成为宿主内部新代码的首选导入路径。 + +## 6. 详细问题与治理要求 + +### 6.1 架构规则与门禁存在空档 + +#### 现状证据 + +- `tests/test_architecture_dependencies.py` 已有 23 项测试,能保护虚拟兼容根、核心实现根和若干禁止边。 +- 当前仍存在 `app.chain._music` ↔ `app.chain.subscribe`、`app.schemas`、`app.db` 等自有 SCC,说明门禁对包内部环有意留白。 +- `app/application/messaging/skill.py:8` 直接导入 `app.agent.skills.registry`,说明“Application 不依赖具体 Agent 实现”的规则还没有全包覆盖。 +- `app/adapters/external/market.py:33`、`app/adapters/external/server.py:12-14` 直接导入 Oper,说明 Adapter 禁止业务持久化的规则没有落到静态检查。 +- 多个 API 端点直接导入 `Scheduler`、ORM 模型和数据库会话。 + +#### 风险 + +- 新改动只要没有触发已有少数模式,就可能继续扩大架构债务。 +- “测试通过”容易被误读为“架构迁移完成”。 +- 后续 AI 会复制当前调用方式,造成错误模式扩散。 + +#### 治理动作 + +1. 在现有测试中增加“趋势型门禁”,先用基线白名单锁住现状,再逐项减小白名单。 +2. 对以下依赖设置零新增: + - `app.adapters..* -> app.db..*` + - `app.runtime..* -> app.db..*` + - `app.api.endpoints..* -> sqlalchemy.orm.Session/sqlalchemy.ext.asyncio.AsyncSession` + - `app.api.endpoints..* -> app.db.models..*` + - `app.application..* -> app.agent..*`,唯一例外必须是明确稳定门面。 +3. 增加自有 SCC 基线文件。白名单必须写明负责人、原因和目标阶段,不得只列模块名。 +4. 对第三方/移植包使用路径级豁免,不使用整个 `app.modules` 豁免。 +5. 每个架构批次输出变更前后:SCC、目标边数量、出度、受影响公开导入。 + +#### 完成标准 + +- 新代码不能增加上述禁止边。 +- 每个阶段至少消除一个明确 SCC 或一类越层调用。 +- 门禁失败信息打印“调用方、被调用方、允许的替代入口”。 + +### 6.2 `ChainBase` 是隐式服务定位器和字符串协议总线 + +#### 现状证据 + +- `app/chain/__init__.py:53-64` 中,每个 Chain 默认构造 `ModuleManager`、`EventManager`、`MessageOper`、`MessageHelper`、`MessageQueueManager`、`PluginManager` 和两种缓存。 +- `run_module()` 位于 `app/chain/__init__.py:370-390`,先执行插件模块,再执行系统模块。 +- 插件返回非空且不是列表时直接短路;列表结果继续合并。 +- 系统模块按优先级执行;可能根据 `ObjectUtils.check_signature()` 把前一结果作为下一处理器唯一参数。 +- AST 扫描发现约 211 个不同的字面量方法名、259 处调用。这已经是一套大型内部和插件协议,而不只是工具函数。 + +#### 不可破坏的行为 + +1. 插件模块先于系统模块执行。 +2. 非空非列表结果短路。 +3. 列表结果按现有规则合并。 +4. 系统模块按 `get_priority()` 排序。 +5. 同步方法在异步路径中进入线程池。 +6. `raise_exception`、限流和系统错误通知语义保持。 +7. 无参数 `Chain()` 构造仍可用,至少在 V3 兼容期内保持。 + +#### 目标设计 + +- 把调度算法提取为一个可单测的 `ModuleInvocationDispatcher`,只接收模块目录、插件模块目录、错误策略和执行器。 +- 建立 `ModuleMethodContract` 清单,记录方法名、调用方式、参数模型、结果聚合策略、同步/异步能力、是否允许插件短路。 +- `ChainBase` 保留兼容门面和公共辅助方法,但不再在每个实例构造时自行发现所有全局服务。 +- 由 `startup` 创建 `ChainRuntimeContext`;无参构造从兼容 provider 取默认上下文,测试和新代码显式注入。 +- 不把 211 个方法一次性改成枚举。先生成清单和测试,再按能力族引入 Typed Protocol。 + +#### 建议目标模块 + +```text +app/runtime/extensions/module/contracts.py +app/runtime/extensions/module/dispatcher.py +app/application/chain/context.py +app/chain/__init__.py # 保留 ChainBase 兼容门面 +``` + +#### 完成标准 + +- 调度器可在不创建真实 PluginManager、ModuleManager、DB 和消息队列时独立测试。 +- 现有 211 个方法名均被扫描清单覆盖,新增方法必须登记。 +- 对插件优先、短路、列表聚合、签名接力、同步/异步、异常六类行为建立参数化契约测试。 + +### 6.3 巨型 Chain 混合了用例、策略、I/O 和展示副作用 + +#### 重点文件 + +| 文件 | 规模/热点 | 当前混合职责 | 首批拆分方向 | +| --- | --- | --- | --- | +| `app/chain/subscribe.py` | 约 3,794 行,70 个方法;`match()` 约 417 行 | 订阅写入、识别、搜索、匹配、缺失判断、完成、分享、历史、通知 | 命令、查询、匹配策略、完成策略、对外 Facade | +| `app/chain/search.py` | 约 2,901 行;结果解析约 195 行 | 搜索计划、站点并发、结果解析、规则过滤、流式回调 | 计划器、执行器、结果归一化、流式进度 | +| `app/chain/transfer.py` | 约 2,685 行;`do_transfer()` 约 885 行 | 计划、文件操作、刮削、历史、消息、媒体库刷新、回调 | 传输计划、执行、后处理、结果提交 | +| `app/chain/download.py` | 约 2,100 行;批量下载约 572 行 | 资源选择、客户端选择、提交、字幕、历史、通知 | 选择策略、提交服务、字幕流程、审计记录 | +| `app/chain/media.py` | 约 2,097 行 | 识别、缓存、身份转换、同步/异步重复 | 识别用例、身份解析、Provider 网关、缓存策略 | + +#### 当前真实循环 + +`app/chain/_music.py:103-104`、`:134-135`、`:222-223` 通过延迟导入访问 `app.chain.subscribe` 的 `build_subscribe_meta`、`_subscribe_media_key`,而 `subscribe.py` 又导入 `MusicSubscribeMixin`。注释已经明确说明它是在回避模块级循环。 + +延迟导入只改变出错时机,不会恢复正确依赖方向。 + +#### 拆分原则 + +1. 保持 `app.chain.subscribe.SubscribeChain` 等公开路径和类名。 +2. 优先提取纯函数和只依赖 DTO 的策略,再提取有状态用例。 +3. 不在一次提交中同时改同步与异步全链路;先建立共享核心,再让两条入口委托。 +4. 原 Facade 的参数默认值、返回类型、事件时机和消息副作用必须保持。 +5. 不为了缩短文件把相互调用的方法机械分散到多个 `helper.py`。 + +#### 建议的订阅拆分 + +```text +app/domain/subscription/ + identity.py # 订阅媒体键、稳定身份和纯比较 + matching.py # 不访问 DB/网络的匹配规则 + completion.py # 完整性与完成判定 + +app/application/subscription/ + commands.py # 新增、修改、删除、完成 + queries.py # 可见性和订阅读取 + recognition.py # 通过端口恢复媒体信息 + search.py # 搜索用例协调 + ports.py # Repository、Search、Recognition、Event 等协议 + +app/chain/subscribe.py # V3 Facade,继续暴露 SubscribeChain 与旧辅助符号 +``` + +`app/application/subscribe.py` 已经承担订阅写入翻译,可先作为新目录的入口门面,或保留并转发到新服务。不能同时出现同名文件和包;若最终改为包,必须在一个原子批次中完成,并验证 `app.application.subscribe` 的所有导入。 + +#### 建议的整理拆分 + +```text +app/domain/transfer/ + plan.py + naming.py + result.py + +app/application/transfer_pipeline/ + planner.py + executor.py + metadata.py + commit.py + ports.py + +app/chain/transfer.py # 保持 TransferChain 兼容门面 +``` + +`do_transfer()` 应先被改造成显式阶段流水线,每个阶段接受不可变上下文并返回新结果。不能在第一步就重写文件移动算法。 + +#### 完成标准 + +- 消除 `_music` ↔ `subscribe` SCC,不再用新增延迟导入维持。 +- 目标大方法拆为有名称、可独立验证的阶段;单个用例方法原则上不超过 150 行。 +- Chain Facade 的外部路径、方法名、参数和关键副作用测试保持。 +- 每次只迁移一个垂直用例,例如“删除订阅”或“传输后处理”,不得一次搬完整个 Chain。 + +### 6.4 数据访问边界与事务所有权不一致 + +#### 现状证据 + +- `app/api/endpoints/subscribe.py:5-6` 直接导入同步/异步 Session,`:16-20` 直接导入 DB 入口、模型和 Oper,`:923-927` 直接执行删除、提交和回滚。 +- 多个 API 端点直接依赖 `app.db.models`,包括 site、history、workflow、subscribe 等。 +- Chain、Scheduler、Application 也存在模型直接引用。 +- `app/db/models/subscribe.py:121` 起在 ORM 模型上定义查询方法,并通过 `@db_query` 等装饰器执行数据库访问。 +- `app/db/__init__.py` 虽然已改为转发入口并惰性创建 Engine,但模型仍从 `app.db` 根入口回流导入装饰器和 Base,参与 DB SCC。 + +#### 问题本质 + +当前同时存在三种数据访问风格: + +1. `db/oper` 服务。 +2. ORM 模型类方法。 +3. API/业务代码直接持有 Session。 + +这会让事务边界、权限过滤、事件发送和外部上报的先后次序散落在不同层。出现失败时很难判断哪些副作用已提交。 + +#### 目标边界 + +- ORM 模型只描述表、关系、约束和极少量无 I/O 的实体辅助。 +- `db/oper` 是当前 V3 的持久化实现边界,不在本轮强制引入完整 Repository 框架。 +- Application 用例拥有事务语义;API 只调用用例。 +- 复杂跨 Oper 事务可引入小型 `UnitOfWork` Protocol,但不要为单表查询套通用框架。 +- Event、Scheduler、Server 上报只在提交成功后触发;必要时用显式 after-commit 动作清单。 + +#### 迁移顺序 + +1. 统计宿主内部所有 `app.db.models` 和 Session 直接调用,建立基线。 +2. 先迁移写操作,因为事务和副作用风险最高;读操作可稍后处理。 +3. 为每个端点提取 Application command,例如 `DeleteSubscriptionCommand`。 +4. Command 调用 Oper,并返回待发送事件/待调度动作;提交成功后执行。 +5. 宿主内部调用切到 Oper 后,模型旧类方法继续保留为兼容转发,不在 V3 直接删除。 +6. 内部 DB 模块改为从 `app.db.base`、`decorators`、`session` 精确导入,不经 `app.db` 根入口。 + +#### 插件兼容约束 + +- 独立插件仓仍有 `app.db.*`、`app.db.site_oper` 等直接导入。 +- 旧模型类方法、`DbOper`、事务装饰器和惰性 `Engine`/`AsyncEngine` 符号不能因宿主内部收口而删除。 +- 兼容转发不得改变同步/异步类型、装饰器提交行为和返回对象类型。 +- 新 SDK 应提供更窄的数据/配置服务,但不能强迫现有插件同步迁移。 + +#### 完成标准 + +- `app/api/endpoints` 不再新增裸 Session 和模型写入。 +- 第一阶段写端点全部由 Application command 负责事务。 +- Adapter、Runtime 对 `app.db` 的直接依赖归零。 +- DB 自有 SCC 消除;兼容根入口的外部导入测试保持通过。 + +### 6.5 启动组合根已经形成,但全局构造与隐式取实例仍然存在 + +#### 已有进展 + +`app/startup/modules_initializer.py:211-245` 已经承担托管资源、壁纸 Provider、认证载荷、DoH、站点、事件错误通知、模块、Agent 和前端的组合工作。`app/startup/lifecycle.py` 也显式规定数据库预热、路由、模块、插件、调度器、监控器、命令和工作流的顺序。这是正确方向。 + +#### 剩余问题 + +- `app/factory.py:328-333` 在模块导入时创建全局 FastAPI app 并注册给动态插件路由服务。 +- `app/main.py` 在模块级创建 Server。 +- `ChainBase` 构造时自行获取多个管理器和资源。 +- `app/runtime/events.py:655-691` 在没有注册 resolver 时,尝试 `get_existing_instance()`,再兜底调用 `owner_class()`。这可能在事件到达时临时构造未托管对象。 +- `eventmanager = EventManager()`、settings、global_vars 和多个 Singleton 形成事实上的服务定位器。 +- 安全模式与正常模式的装配差异主要写在过程代码里,缺少可检查的组件清单。 + +#### 目标设计 + +```text +ApplicationRuntime + - event_bus + - module_registry + - plugin_registry + - scheduler + - command_runtime + - workflow_runtime + - message_gateway + - cache_registry + - db_runtime + - agent_runtime +``` + +- `startup` 创建一个 `ApplicationRuntime` 或等价的显式组件注册表。 +- 生命周期步骤声明名称、依赖、start、stop、safe-mode 策略、超时和失败策略。 +- 老的单例入口继续返回该注册表中的实例,保持对象身份。 +- 新代码显式接收所需最小依赖,不接收整个容器。 +- Event handler 必须由模块/插件/服务 resolver 解析。未绑定类的自动构造先告警并记录命中,完成迁移后改为拒绝。 + +#### 迁移要求 + +1. 先增加生命周期快照测试,记录正常模式、安全模式、关闭顺序和失败继续策略。 +2. 再把单个资源改为注册表所有;一次只迁移一个资源。 +3. 保留 `EventManager()`、`PluginManager()` 等现有入口的同一实例语义。 +4. 禁止在迁移批次顺带改变 uvicorn/gunicorn 入口和 Docker 启动方式。 +5. 测试 `app.factory:app` 直接挂载路径,因为它与 `main.py` 路径不同。 + +#### 完成标准 + +- 启动时能打印或导出已启用组件及其依赖顺序。 +- Event handler 无未登记的运行时构造。 +- 正常、安全模式、启动中断和部分关闭失败均有测试。 +- 导入模块不建立数据库连接、不启动线程、不启动调度器。 + +### 6.6 事件总线同时承担注册、解析、调度、隔离和错误再广播 + +#### 现状证据 + +- `app/runtime/events.py` 约 801 行,包含装饰器注册、订阅快照、实例解析、同步/异步/广播调度、插件目标过滤、限流和错误通知。 +- 链式事件按优先级顺序执行;广播事件通过线程池或 `asyncio.run_coroutine_threadsafe()` 并发执行。 +- 广播事件对 `event_data` 仅做顶层浅拷贝;嵌套可变对象仍共享。 +- `MessageAction` 使用 `__mp_target_plugin_id` 作为内部定向字段。 +- 错误处理在通知后再次发送 `SystemError` 事件,存在错误处理链再次出错的递归风险。 +- 未被 resolver 管理的类处理器可被临时实例化。 + +#### 必须冻结的语义 + +1. `EventType` 与 `ChainEventType` 的区别。 +2. 链式事件的优先级、顺序和返回行为。 +3. 广播事件的并发模型和“订阅快照从下一次事件生效”。 +4. 插件定向消息不能被其他插件观察。 +5. 同步处理器在线程池执行的条件。 +6. 插件热加载/卸载时 handler 的启用和移除时机。 + +#### 目标拆分 + +```text +app/runtime/events.py # 兼容门面与 eventmanager +app/runtime/event/registry.py # 注册、快照、启停 +app/runtime/event/binding.py # resolver 与实例绑定 +app/runtime/event/dispatch.py # chain/broadcast 调度算法 +app/runtime/event/errors.py # 限流、错误隔离、通知降级 +app/domain/events/ # 逐步增加 Typed payload,不承载总线实现 +``` + +#### 实施顺序 + +1. 为所有现有事件枚举生成 producer/consumer 清单。 +2. 对高风险事件增加 payload model,但入口继续接受 dict,并在边界校验/转换。 +3. 提取纯调度器,不改变 `EventManager` 公共方法和全局实例。 +4. 为 resolver 未命中增加 DEBUG 诊断和测试;清零后移除自动构造兜底。 +5. `SystemError` 增加递归保护和不可再次广播的降级日志路径。 +6. 对需要深隔离的事件定义不可变 payload,不全局使用 `deepcopy`。 + +#### 完成标准 + +- 事件注册、实例绑定、调度和错误策略可分别测试。 +- 高风险事件 producer/consumer 的 payload 契约一致。 +- 热加载、定向插件、广播并发、错误递归保护均有回归测试。 +- `app.core.event`、`app.sdk.events` 的对象身份和装饰器用法不变。 + +### 6.7 API 层包含用例、事务、调度和长流协议 + +#### 重点文件 + +| 文件 | 典型问题 | +| --- | --- | +| `app/api/endpoints/agent.py` | 约 2,315 行,`web_agent_stream()` 约 400 行,上传、队列、Agent 执行、SSE 映射和清理混合 | +| `app/api/endpoints/system.py` | 约 1,493 行,网络测试、规则测试、日志、配置、运行控制混合 | +| `app/api/endpoints/plugin.py` | 市场、安装、状态、详情、动态 API 注册和文件操作耦合 | +| `app/api/endpoints/subscribe.py` | 鉴权、查询、事务、事件、调度、共享上报混合 | +| `app/api/endpoints/site.py` | 站点 CRUD、认证、统计、图标和资源更新混合 | +| `app/api/endpoints/transfer.py` | `manual_transfer()` 约 293 行,解析、计划、执行和响应混合 | +| `app/api/endpoints/openai.py` | OpenAI 兼容协议、流式适配、业务执行混合 | + +#### 目标设计 + +- endpoint 只负责传输参数、认证依赖、调用用例、映射响应。 +- 业务权限检查进入 Application policy/use case;FastAPI 的 token 解码仍留在 API/security adapter。 +- 后台任务不直接抓取 Scheduler 单例;调用 Application command 返回一个可提交的任务请求。 +- SSE/OpenAI 流协议由独立 transport adapter 映射领域/Agent 事件。 +- API 路径、HTTP method、状态码、响应模型和流事件格式保持。 + +#### 动态插件 API 的 P0 兼容冲突 + +当前 `app/factory.py:298-299` 把主应用默认路由类设为 `ResponseAPIRoute`;`app/application/plugins.py:87-104` 将插件返回的路由字典直接传给 `app.add_api_route()`。因此,未显式声明 raw 的动态插件 JSON 接口会进入主 API 的 `{success, message, data}` 包装逻辑。`tests/test_api_response.py:742-755` 目前甚至把这种行为固化为测试。 + +宿主的兼容原则应明确:**动态插件 API 保持插件自由返回,不强制使用主 API 统一响应信封。**这与主 API 的统一响应目标是两个边界,不能混为一谈。 + +阶段 0 必须完成以下之一,并由产品契约确认: + +1. 动态插件注册时默认注入 `openapi_extra[RAW_RESPONSE_OPENAPI_KEY] = True`;插件显式请求统一信封时再开启包装。 +2. 为动态插件创建专用 `PluginAPIRoute`,默认 raw,保留原生 `Response`、StreamingResponse 和插件自己的 Pydantic model。 + +同时补充真实请求级测试,不能只断言 route class 或 response model。 + +#### 完成标准 + +- 主 API 继续统一信封。 +- 动态插件 API 的 raw 返回、原生 Response、文件/流响应和自定义状态码保持。 +- 每个重点端点文件逐批只保留 transport 逻辑。 +- API 层不再直接提交数据库事务或调用具体外部上报 Helper。 + +### 6.8 `PluginManager` 同时承担宿主生命周期、契约聚合、UI 投影和市场安装 + +#### 现状证据 + +`app/runtime/extensions/plugin_manager.py` 当前约 1,809 行、83 个方法,仍包含: + +- 插件扫描、选择性加载、实例化、`init_plugin`、停止和热重载。 +- 文件监控和本地变化处理。 +- 配置和数据访问。 +- 命令、API、服务、模块、动作、Agent tools 聚合。 +- 页面、表单、侧栏、仪表板、授权 Provider 等 UI/交互投影。 +- 插件状态、更新入口和兼容 Facade;市场、包、依赖的宿主调用已经改为经注入系统服务。 + +这使得 PluginManager 既是运行时 registry,又是 market service 和 presentation assembler。 + +#### 目标拆分 + +```text +app/runtime/extensions/plugin_manager.py # 保留公共 Facade 和实例身份 +app/runtime/extensions/plugin/lifecycle.py # 后续提取 load/start/stop/reload +app/runtime/extensions/plugin/registry.py # 实例、状态、元数据 +app/runtime/extensions/plugin/contracts.py # hook 解析与校验 +app/runtime/extensions/plugin/projection.py # commands/apis/services/modules/actions 投影 +app/runtime/extensions/plugin/storage.py # 运行时持久化窄端口 +app/application/plugin/catalog.py # 市场目录查询、代际合并和来源去重 +app/application/plugin/install.py # 安装用例与阶段结果 +app/application/plugin/routes.py # 动态 API 注册端口 +``` + +#### 插件钩子契约 + +独立插件仓当前高频钩子包括: + +| 钩子 | 扫描到的插件文件数(约) | +| --- | ---: | +| `init_plugin`、`stop_service`、`get_state`、`get_form`、`get_page`、`get_api` | 81-82 | +| `get_command` | 79 | +| `get_service` | 47 | +| `get_render_mode` | 11 | +| `get_dashboard` | 10 | +| `get_module` | 5 | +| `get_agent_tools` | 3 | + +这些方法的存在性、参数、返回形态和异常隔离方式都是 ABI。目标 `plugin/contracts.py` 应定义 Protocol 和运行时 validator,但不能要求旧插件显式继承新 Protocol。 + +#### 实施顺序 + +1. 建立 hook contract snapshot,覆盖空值、错误值和异常。 +2. 提取只读 registry,不改变加载流程。 +3. 提取 projection,不改变前端 DTO。 +4. 把市场和安装委托给 Application;PluginManager Facade 保留旧方法。 +5. 最后才拆生命周期和文件 watcher,因为热重载风险最高。 + +#### 完成标准 + +- `PluginManager()` 仍返回同一实例,`app.sdk.plugins.PluginManager` 身份测试保持。 +- 启停、更新、热重载、配置更新、动态路由刷新顺序不变。 +- PluginManager 本身不再直接导入 DB、市场 client、pip、压缩包和备份实现;具体安装阶段由 Application command 和注入的包/依赖端口完成。 +- 所有旧公共方法在 V3 保留,内部只做委托。 + +### 6.9 外部 Adapter 直接持久化并承载业务用例 + +#### `PluginHelper` + +`app/adapters/external/market.py` 当前约 3,066 行、112 个方法,仍保留以下正式 V3 ABI 实现: + +- 市场索引和发布信息请求。 +- 插件包下载、解压、校验、备份和恢复。 +- requirements 解析、冲突判断、pip 安装与降级策略。 +- 同步/异步重复实现。 +- 市场缓存、旧同步/异步安装入口和旧私有方法兼容。 + +它当前不再导入 `SystemConfigOper`;已拆出的 canonical 入口由 `PluginMarketClient`、`PluginPackageManager`、`PluginDependencyInstaller`、`PluginCatalogService` 和 `PluginInstallCommand` 承担。为了不破坏旧插件对 `PluginHelper` 的类名、静态方法和私有兼容调用,本轮没有把 3,066 行旧实现机械搬走,也没有在新模块中复制一套同名旧导出。后续阶段可继续把旧实现的具体算法逐步内移到这些组件。 + +建议拆为: + +```text +app/adapters/external/plugin/client.py +app/adapters/system/plugin/package.py +app/adapters/system/plugin/dependency.py +app/application/plugin/catalog.py +app/application/plugin/install.py +app/adapters/external/market.py # PluginHelper 正式 ABI 与过渡实现 +``` + +外部 client 只返回结构化结果;Application 决定版本选择、安装事务、备份和重载。 + +#### `MoviePilotServerHelper` + +`app/adapters/external/server.py` 当前约 1,836 行、137 个方法,同时承担: + +- 通用请求签名和 HTTP 调用。 +- 使用统计和插件统计。 +- 订阅、工作流、识别共享。 +- 本地 Oper 查询和 payload 拼装。 +- 多类响应解析与缓存。 + +当前文件不再直接导入 `SubscribeOper`、`SystemConfigOper` 或 `WorkflowOper`;本地数据读取和 payload 组装已由启动层注入的 `report.py`、`share.py` 用例提供。 + +建议拆为: + +```text +app/adapters/external/server.py # HTTP transport 与旧 Helper Facade +app/application/server/report.py # 插件/订阅统计和首次上报 +app/application/server/share.py # 订阅/工作流等分享用例 +``` + +`server.py` 暂时同时保留底层 transport 和旧公开 Facade,但不再读取 Oper;启动组合根把数据读取 Provider、Application 用例和 transport 回调连接起来。后续如果 transport 继续增长,再建立 `app/adapters/external/server/` 主题目录并使用 `client.py`、`contracts.py` 等单词文件名,不能新增 `moviepilot_server.py` 一类多词实现模块。 + +#### 完成标准 + +- `app/adapters` 对 `app.db` 的静态导入为零。 +- 外部 client 可用 fake transport 测试,不需要真实 DB。 +- 业务用例可用 fake client 测试,不需要网络。 +- 旧 Helper 路径和方法在 V3 内继续工作。 + +### 6.10 Schema 聚合入口和本地化产生运行时耦合 + +#### 现状证据 + +- `app/schemas/__init__.py` 已改为由 `app/schemas/exports.py` 驱动的惰性兼容入口;任意 `from app import schemas` 不再主动加载全部 schema 子模块。 +- 仍需注意 `from app.schemas import X` 首次访问会加载该符号的所有者模块,不能把惰性入口误解为 schema 本身已经完全解耦。 +- `app/schemas/dashboard.py:5`、`app/schemas/response.py:5` 直接依赖 `app.runtime.localization.LocaleHelper`。 +- `Response.message` 的 Pydantic validator 在构造模型时读取当前请求 locale,序列化模型不再是纯数据操作。 + +#### 风险 + +- 小范围 schema 导入会触发大量模块加载,放大循环和启动时间。 +- 同一个 Response 在不同上下文构造可能得到不同文本,后台任务和测试受 ContextVar/全局上下文影响。 +- Domain/Application 依赖 schema 聚合入口时,被动依赖展示层和本地化运行时。 + +#### 目标设计 + +1. 宿主内部改用精确子模块导入。 +2. `app.schemas` 根入口保留兼容,但用显式导出表和惰性 `__getattr__`,不再全量星号加载。 +3. 建立导出符号冲突检查,避免不同 schema 同名时依赖导入顺序。 +4. 本地化发生在 API/消息 presentation mapper,不发生在通用 DTO 构造阶段。 +5. V3 内保持最终 API `message` 字段和语言行为,迁移时用请求级快照测试锁定。 + +#### 完成标准 + +- `app.schemas` 自有 SCC 消除。 +- 内部新增代码不得 `from app.schemas import *`。 +- 根入口公开符号集合有快照测试。 +- schema 子模块不再依赖 `runtime.localization`;最终返回文本仍符合现有 locale 行为。 + +### 6.11 Application 仍依赖具体实现,能力边界不稳定 + +#### 典型证据 + +- `app/application/messaging/skill.py:8` 直接导入 `app.agent.skills.registry.SkillHelper`。 +- `app/application/plugins.py` 直接持有 FastAPI app 并操作 `app.routes`、`openapi_schema` 和 `setup()`。 +- 多个 `modules` 直接导入 `app.application.messaging.agent`、`mediaserver`、`storage` 等;其中一部分是合理 SPI 消费,一部分表明应用能力接口和具体实现未区分。 +- `SystemConfigOper()` 在大量文件中被直接构造,形成持久化配置服务定位器。 + +#### 目标边界 + +- Application 能依赖自己定义的端口,不依赖 Agent registry、FastAPI app、PluginManager 具体类。 +- 端口定义靠近消费者,例如 `SkillCatalog` 定义在 messaging use case 一侧,由 Agent adapter 实现。 +- 动态路由操作应定义 `DynamicRouteRegistry` Protocol,FastAPI 实现在 API adapter,插件应用服务只提交路由描述。 +- `SystemConfigReader/Writer` 作为窄协议注入用例,默认实现可继续包装 `SystemConfigOper`。 +- Modules 只消费稳定 Application facade;需要长期保留的接口进入 SDK/Host SPI,而不是随意导入内部文件。 + +#### 完成标准 + +- `app.application` 不直接导入 `app.agent`、FastAPI 和具体 Module 类。 +- Application 单测可通过 fake port 完成。 +- Module 依赖的 Application 能力有 Protocol、生命周期和异常语义说明。 + +### 6.12 Agent 子系统存在集中注册、Provider 巨型对象和编排混合 + +#### 现状证据 + +- `app/agent/tools/factory.py` 静态出度约 99,一次性导入大量内置工具并维护集中列表。 +- `app/agent/llm/provider.py` 约 3,527 行,内置 Provider 规格段约 700 行,并混合配置、授权、模型发现、协议兼容和运行实例创建。 +- `app/agent/orchestrator.py` 约 3,116 行,混合 Agent 创建、执行、工具选择、用量记录、记忆和流式事件。 +- `app/agent/llm/helper.py` 约 1,699 行,包含多种供应商兼容修补。 +- Agent 已通过 `runtime_loader.py` 延迟物化,因此“让 Agent 延迟启动”不是下一阶段主要任务。 + +#### 目标拆分 + +```text +app/agent/llm/specs/ # Provider 静态规格,数据化并校验唯一 ID +app/agent/llm/auth/ # OAuth/设备码/会话状态 +app/agent/llm/catalog.py # 模型发现和缓存 +app/agent/llm/protocols/ # OpenAI/Anthropic/Gemini 等适配 +app/agent/llm/runtime.py # 选定配置到运行客户端 +app/agent/tools/manifests/ # 按能力域声明工具,不在工厂顶层全量导入 +app/agent/execution/ # 执行、流事件、用量、恢复 +``` + +#### 兼容要求 + +- Provider ID、配置 key、已保存授权状态、模型 ID 和默认选择不能变化。 +- 工具名称、参数 schema、权限、用户确认语义不能变化。 +- 插件 `get_agent_tools()` 和 `MoviePilotTool` 继承/注册机制保持。 +- OpenAI 兼容 API 的事件顺序、finish reason、error 形态和 usage 保持。 + +#### 完成标准 + +- 工具工厂不再静态导入全部工具;按 manifest 或域 registry 延迟加载。 +- `provider.py` 只保留兼容 Facade 和运行时入口。 +- 每个 Provider 协议适配可单独做录制响应/fixture 测试。 +- Agent 编排不直接处理 HTTP/SSE 格式。 + +### 6.13 `modules` 既是 Provider 集合,又出现模块内环和跨层扩散 + +#### 判断原则 + +`app/modules` 的高体量并不意味着应该整体改造成 Application。它是宿主可替换 Provider 的主要实现区,正确目标是: + +- 每个模块实现明确 SPI。 +- 模块自己的平台协议和对象留在模块内。 +- 宿主只通过 ModuleManager/HostModuleAdapter 调用。 +- 共享语义不藏在某个具体模块中。 +- 模块不反向驱动 Chain 和宿主生命周期。 + +#### 当前重点 + +- `filemanager` 与 `transhandler` 形成双向依赖,应先提取传输 DTO、回调 Protocol 和文件操作结果。 +- 消息平台模块重复依赖 `application.messaging.agent` 等能力,应固化消息网关 SPI,避免每个平台了解 Agent 细节。 +- 媒体服务器模块直接消费 `application.mediaserver`,需要区分“宿主下发能力”与“模块反调宿主”的方向。 +- TMDB 移植包内部大 SCC 应包内隔离,通过单一 Facade 对外,不开展无收益重写。 + +#### 完成标准 + +- 每个模块族有一份 SPI 清单和返回契约。 +- 自有模块内部 SCC 逐项消除;第三方局部环不越过 Facade。 +- ModuleManager 不再通过任意 `hasattr` 发现无限制能力;能力必须进入 method contract 清单。 +- 插件 `get_module()` 仍可提供同名方法并参与现有聚合。 + +### 6.14 SDK 与兼容层是正式 ABI,但当前过宽 + +#### 当前事实 + +`app/runtime/compat/manifest.py` 当前约包含: + +- 112 个模块别名。 +- 1 个包别名。 +- 8 个模块、约 41 个符号别名。 +- 3 个虚拟包。 + +独立插件仓中仍高频使用: + +| 导入面 | 使用文件数(约) | +| --- | ---: | +| `app.log` | 97 | +| `app.plugins` | 81 | +| `app.core.config` | 71 | +| `app.schemas.types` | 67 | +| `app.schemas` | 49 | +| `app.utils.http` / `app.utils.string` | 45 / 43 | +| `app.core.event` | 42 | +| `app.sdk.media` | 33 | +| `app.sdk.logging` | 24 | +| `app.sdk.config` / `app.sdk.network` | 20 / 18 | +| `app.core.context` | 17 | +| `app.helper.downloader` / `app.helper.sites` | 14 / 13 | +| `app.chain.download` / `subscribe` / `media` | 11 / 10 / 9 | +| `app.db.site_oper` | 10 | + +现有 SDK 也直接导出 settings/global_vars、具体 PluginManager/ModuleManager、具体 EventManager 和多个跨层 Helper。它能维持兼容,但不是新插件应无限扩张依赖的依据。 + +#### 治理策略 + +1. 把 SDK 和兼容清单视为版本化公开产品,不是临时代码。 +2. 建立 `sdk-public-api.json` 或等价测试清单,记录模块、符号、类型身份和行为测试。 +3. 新增 SDK 能力优先导出 Protocol/Facade,不新增内部 manager 的可变状态。 +4. 宿主内部不因兼容存在而继续使用旧 `app.core.*`、`app.helper.*`、`app.utils.*` 路径。 +5. V3 默认只增不删。弃用必须包含:替代入口、诊断、至少一个完整发布周期、官方插件仓扫描、样例第三方插件验证。 +6. 兼容模块必须精确路由,不能用宽泛 `__getattr__` 吞掉拼写错误。 +7. 需要保持类/单例身份的符号必须测试 `is`,不能只测试能导入。 + +#### 完成标准 + +- SDK 公开面有机器可读清单和变更审查。 +- 每次迁移明确列出旧路径、新路径、身份要求和保留期限。 +- 独立插件仓 v2/v3 静态导入扫描通过。 +- V3 治理批次不删除现有 112/41 兼容项。 + +### 6.15 配置、缓存和错误策略分散 + +#### 现状 + +- `settings` 在大量模块中直接读取,这是运行配置的事实 API。 +- `SystemConfigOper()` 在几十个文件中直接构造,运行配置与持久化用户配置边界模糊。 +- 缓存装饰器、文件缓存、Redis、内存状态由调用方自行选择,缺少能力级一致失效策略。 +- 部分层把异常转成 `schemas.Response`,部分抛异常,部分发送 `SystemError`,部分只记录日志。 + +#### 目标 + +- `settings` 仅表示启动时环境配置;用例接收所需配置快照,而不是读取整个 settings。 +- 持久化系统配置通过窄 `SystemConfigReader/Writer`。 +- 每个能力明确缓存所有者、key、TTL、负缓存、失效事件和降级策略。 +- Domain/Application 返回领域错误;API 映射 HTTP;Event/Background runtime 决定重试、通知和死信。 +- 不在第一阶段引入统一“万能 Result”类型;先统一错误所有权。 + +## 7. 插件兼容治理专章 + +### 7.1 插件是外部消费者,不是内部实现目录 + +本次后端重构必须同时接受两个事实: + +1. `app/plugins/` 是运行时副本,不能按其当前内容决定宿主架构。 +2. 插件运行时仍依赖宿主提供的 `_PluginBase`、旧导入、SDK、事件、模块、API、调度和配置能力,这些必须作为黑盒 ABI 保护。 + +兼容审计至少包含: + +- 独立官方插件仓 `plugins.v2/`、`plugins.v3/`。 +- `runtime/compat/manifest.py`。 +- `app/sdk/` 公开导出。 +- PluginManager 实际消费的 hook。 +- `tests/test_legacy_import_compat.py`、`tests/test_legacy_plugin_resource_imports.py`、`tests/test_plugin_sdk.py` 等。 +- 一组最小第三方插件 fixture,覆盖旧导入、事件、动态 API、模块、服务和 Agent tool。 + +### 7.2 必须保持的兼容维度 + +| 维度 | 必须验证 | +| --- | --- | +| 导入 | 旧模块和旧符号可导入;包/模块形态与子模块导入不冲突 | +| 身份 | Singleton、Manager、EventManager、公开类在旧新路径下按要求保持 `is` | +| 构造 | 插件基类和 Chain 的无参构造仍工作 | +| Hook | 方法名、参数、同步/异步、None/空列表语义、异常隔离不变 | +| Module | 插件优先级、短路、列表合并、签名接力语义不变 | +| Event | 注册装饰器、目标插件过滤、链式顺序、热卸载清理不变 | +| API | 路径、鉴权默认值、raw 返回、原生 Response、流式返回不被主 API 信封改变 | +| Service | 定时任务描述、Cron、启动/停止和去重语义不变 | +| UI | form/page/dashboard/sidebar DTO 形态不变 | +| Data | 插件配置和 PluginData 的 key、序列化、隔离和迁移行为不变 | +| Reload | 本地开发 watcher、更新、备份、重新实例化和路由刷新顺序不变 | + +### 7.3 兼容迁移模式 + +每个公开模块迁移采用以下模式: + +```text +旧入口(永久或长期 Facade) + | + v +新 Application/Runtime/Adapter 实现 + ^ + | +startup 注入具体依赖 +``` + +规则: + +1. 先新增实现和契约测试。 +2. 旧入口改为薄委托,但保留公开名称。 +3. 宿主内部调用切换到新入口。 +4. 官方插件无需修改即可通过。 +5. 新 SDK 入口可逐步推广,但不以删除旧路径作为同一批次完成条件。 +6. 若类的 `__module__`、pickle、反射或前端模块名会变化,必须显式增加兼容测试。 +7. 已废弃的模块路径统一登记到 `app/runtime/compat/manifest.py`,不得在新实现模块内复制导出旧对象。 +8. `PluginManager`、`PluginHelper`、`MoviePilotServerHelper` 等仍被插件直接依赖的正式公共路径必须保留原有公共合同和对象身份;其中已经完成职责拆分的入口可以委托新实现,但尚未迁出的算法仍可能留在原类中,不能把它们笼统描述成纯薄 Facade。 +9. 新实现包默认不增加 `__all__`、惰性 `__getattr__` 或模块级旧类别名;确需公开时进入 `app/sdk` 导出清单和架构快照。 + +### 7.4 插件兼容禁止事项 + +- 不扫描 `app/plugins/` 后批量改写插件源码。 +- 不把插件 API 自动包装成主 API 统一信封。 +- 不改变 `get_module()` 返回字典的方法名或 `run_module()` 聚合顺序。 +- 不因新 Protocol 存在就要求旧插件继承它。 +- 不把热重载问题用“重启生效”替代。 +- 不把 SDK 改成全新对象,导致旧路径和新路径的 Singleton 身份分裂。 +- 不在 V3 普通架构 PR 中删除兼容 manifest 项。 + +## 8. 分阶段实施路线 + +每个阶段可以拆成多个小 PR/提交。阶段之间有依赖,阶段内部按风险从低到高推进。 + +### 阶段 0:冻结契约、纠正 P0 兼容边界 + +#### 目标 + +先知道什么不能变,并修复会阻碍后续治理的契约冲突。 + +#### 工作项 + +1. 生成并提交当前架构基线:模块、导入边、自有 SCC、禁止边白名单。 +2. 生成 `run_module` 方法清单,覆盖约 211 个方法名及调用位置。 +3. 生成插件 hook、SDK 导出、compat manifest 和官方插件导入快照。 +4. 增加动态插件 API 真实请求测试,恢复/确认 raw free-return 边界。 +5. 增加启动矩阵:`app.factory:app`、主入口、安全模式、正常模式、关闭失败。 +6. 增加 Event/Module/PluginManager 对象身份测试。 +7. 记录当前导入耗时和启动关键阶段耗时,作为后续非功能基线。 + +#### 不做 + +- 不拆巨型文件。 +- 不移动公开类。 +- 不删除兼容映射。 +- 不改数据库结构。 + +#### 验收 + +- 行为契约成为测试或机器可读清单。 +- 动态插件 API 的返回边界有明确、可执行测试。 +- 架构基线可以在 CI 中稳定复现。 + +### 阶段 1:补架构门禁并消除低风险环 + +#### 目标 + +先让依赖图停止恶化,再处理不涉及业务算法的环。 + +#### 工作项 + +1. `app.schemas` 改为显式/惰性兼容导出;宿主内部使用精确子模块导入。 +2. 移除重复 `system` 导出,增加公开符号快照和冲突检查。 +3. DB 内部模块从具体 `app.db.base/decorators/session/engine` 导入,不经根入口回流。 +4. 消除 `app.chain._music` ↔ `subscribe`:把订阅媒体 key、meta 构造移到 Domain/Application 的单向依赖模块。 +5. 消除 `filemanager` ↔ `transhandler`:提取共享 DTO/Protocol。 +6. 新门禁设为禁止新增 Adapter→DB、Runtime→DB、API→Session/Model、Application→Agent 具体实现。 + +#### 兼容方式 + +- `app.schemas.X` 和 `app.db` 旧导出继续工作。 +- `app.chain.subscribe` 的旧辅助函数保留转发,直到插件扫描证明可移除;V3 默认不移除。 +- 文件改包时保持完整导入路径和类名。 + +#### 验收 + +- 自有目标 SCC 至少减少 3 个。 +- 新门禁无无期限宽泛豁免。 +- 官方插件仓静态导入和宿主兼容测试通过。 + +### 阶段 2:显式运行时组合与事件/模块调度契约 + +#### 目标 + +把隐藏在 Singleton、装饰器和字符串里的宿主运行机制变成可组合、可测试的基础设施。 + +#### 工作项 + +1. 提取 `ModuleInvocationDispatcher`,由 `ChainBase` 委托。 +2. 引入 method contract registry,先覆盖高频能力族。 +3. 提取 Event registry、binding resolver、dispatcher、error policy。 +4. 引入生命周期组件描述,逐个登记 start/stop/safe-mode/timeout。 +5. Event resolver 未命中增加诊断;迁移宿主 handler 到显式 resolver。 +6. 让 Chain 新代码可注入 `ChainRuntimeContext`,保留无参兼容 provider。 + +#### 风险控制 + +- 先复制现有算法到可测试组件,再让 Facade 委托,不能边提取边重写规则。 +- 同步和异步聚合测试必须成对。 +- 对广播事件使用可控 executor 和 loop fixture。 +- PluginManager/EventManager/ModuleManager 身份保持。 + +#### 验收 + +- 调度算法不依赖真实插件、数据库和线程即可单测。 +- Event handler 不再由总线隐式构造,或剩余命中有明确白名单和日志。 +- 生命周期顺序由测试锁定。 + +### 阶段 3:数据访问与 API 用例收口 + +#### 目标 + +让事务、权限和提交后副作用拥有清晰所有者。 + +#### 工作项 + +1. 从订阅删除/修改、站点修改、工作流修改等写端点开始,建立 Application command。 +2. 把 ORM 直接写入、commit/rollback、事件、调度、外部上报迁入用例。 +3. 建立必要的 Oper/UnitOfWork 端口。 +4. 模型类方法在宿主内部逐步停用,保留兼容转发。 +5. 端点只做 FastAPI 参数和结果映射。 +6. Scheduler 的数据库清理逻辑迁到 Application maintenance use case,Scheduler 只触发。 + +#### 推荐垂直切片顺序 + +1. 删除订阅。 +2. 手工触发订阅搜索。 +3. 站点启停/修改。 +4. 工作流启停/删除。 +5. 历史删除与清理。 +6. 插件状态与配置更新。 + +每个切片单独验证,不等待所有端点一起完成。 + +#### 验收 + +- 已迁移端点不持有 Session、不直接调用 Model/Oper/Scheduler/ServerHelper。 +- commit 失败时不发送成功事件、不上报、不调度后续任务。 +- 同步/异步路径和权限结果不变。 + +### 阶段 4:按用例拆分巨型 Chain + +#### 目标 + +在数据和运行时边界已经稳定后,拆解业务编排。 + +#### 工作项 + +1. Subscribe:身份、命令、识别、搜索、匹配、完成。 +2. Search:计划、并发执行、归一化、过滤、流式进度。 +3. Transfer:计划、执行、元数据、提交、后处理。 +4. Download:候选选择、客户端提交、字幕、审计。 +5. Media:身份解析、Provider 识别、缓存和同步/异步共核。 +6. Message:通道解析、路由、交互状态和业务 handler。 + +#### 拆分方式 + +- 每次选一个公开方法作为纵向切片。 +- 先做 characterization test。 +- 新服务返回结构化结果,Facade 负责兼容旧返回。 +- 事件、通知、历史和缓存失效点写入时序测试。 +- 纯策略下沉 Domain;短用例进入 Application;多域串联保留 Chain。 + +#### 验收 + +- 目标 Chain 文件规模和出度持续下降。 +- 不新增 `misc.py`、`common.py`、`helper.py` 式无边界收纳文件。 +- Facade 兼容测试覆盖所有被迁移公开方法。 + +#### 阶段 0-4 当前落地索引 + +| 阶段 | 已落地入口 | 已锁定的关键语义 | +| --- | --- | --- | +| 0 | `scripts/architecture/baseline.py`、`scripts/schema/exports.py`、三份 architecture fixture | 模块/边/SCC、SDK/compat、事件、`run_module`、官方插件 V2/V3 导入和钩子快照 | +| 0 | `app/adapters/web/plugin/routes.py`、`app/application/plugin/routes.py` | 主程序继续统一 envelope;动态插件 API 默认 raw,自定义状态码、原生 Response、文件/流响应不被改写 | +| 0 | `MoviePilot-Frontend/src/api/client.ts` | 联邦插件公共客户端遇到非 `Response` payload 时原样返回;合法 envelope 仍保留统一错误反馈 | +| 1 | `app/schemas/exports.py`、`app/schemas/__init__.py` | Schema 根入口惰性兼容导出,宿主内部使用精确子模块,公开符号由生成清单锁定 | +| 1 | `app/application/subscription/contract.py`、`app/modules/filemanager/module.py` | 订阅身份/元数据和文件管理共享合同改为单向依赖,目标 SCC 不再靠延迟导入维持 | +| 1 | `tests/test_architecture_dependencies.py`、dependency baseline | Adapter/Runtime 到 DB 零新增,API/Session/Model 与 Application/Agent 采用趋势基线治理 | +| 2 | `app/runtime/extensions/module/contracts.py`、`dispatcher.py` | 插件优先、短路、列表合并、参数签名和同步/异步执行顺序保持 | +| 2 | `app/runtime/event/{registry,binding,dispatch,errors}.py` | 事件注册、实例解析、分发和错误降级拆开;总线不再隐式构造未绑定处理器 | +| 2 | `app/application/chain/context.py`、`app/startup/lifecycle/components.py`、`scripts/startup/performance.py` | Chain 依赖可注入;正常/安全模式启停顺序、超时、阶段耗时及隔离资源快照可导出测试 | +| 3 | `app/db/uow.py`、`app/application/subscription/{delete,identity}.py` | 订阅删除事务、权限、提交后事件/上报时序归 Application 所有;端点只做传输映射 | +| 3 | `app/application/subscription/query.py`、`app/application/maintenance.py`、`app/db/maintenance.py` | 订阅查询三条垂直切片和六张维护表的保留期/批次/失败汇总归 Application;Scheduler 只触发 | +| 4 | `app/application/search/state.py` | 搜索状态查询和控制从巨型 Chain 提取,保留原同步/异步状态语义 | +| 4 | `app/application/download/tasks.py` | 下载任务查询/控制形成窄用例,Chain 保留用户目标编排 Facade | +| 4 | `app/application/music/catalog.py` | 多来源音乐目录聚合形成可用 fake Provider 测试的应用服务,不改变原搜索命中/回退行为 | +| 4 | `app/application/transfer.py`、`app/application/messaging/session.py` | Transfer、Message 各三条以上状态/控制切片由窄服务承接,旧 Chain 方法保留兼容委托 | + +这些切片只代表阶段 0-4 的低风险中期目标,不表示全部 API、Chain 和模型访问已经完成长期收口。当前基线仍有 42 条 API endpoint→Model、15 条 endpoint→Session、3 条 Application→Agent 具体实现边;它们是后续垂直切片的明确欠账,不能通过扩大白名单消除告警。 + +### 阶段 5:拆分插件宿主与外部服务适配 + +#### 目标 + +让 PluginManager 只管理扩展运行,让 Adapter 只做 I/O。 + +#### 工作项 + +1. Plugin hook contract/registry/projection 从 PluginManager 提取。 +2. 插件市场查询和安装进入 Application 用例。 +3. `PluginHelper` 拆 market client、包管理、依赖安装。 +4. `MoviePilotServerHelper` 拆 transport client 与分享/统计用例。 +5. 动态路由以 `DynamicRouteRegistry` 端口连接 FastAPI adapter。 +6. Runtime 的系统配置访问改为启动注入的 reader。 + +#### 验收 + +- Runtime 和 Adapter 不再导入 DB Oper。 +- PluginManager 仍保持完整 V3 公共方法和实例身份。 +- 插件安装失败可以明确回滚文件、依赖、实例和路由中的哪些步骤。 +- 热重载与在线更新测试覆盖。 + +#### 当前已落地切片 + +| 职责 | Canonical 实现 | 旧入口/兼容方式 | +| --- | --- | --- | +| 插件钩子契约 | `app/runtime/extensions/plugin/contracts.py` | 旧插件仍按鸭子类型实现,不要求继承 Protocol 或基类 | +| 插件类与运行实例注册 | `app/runtime/extensions/plugin/registry.py` | `PluginManager.plugins`、`running_plugins` 仍返回原有可变映射 | +| 命令/API/服务/模块/动作/联邦/认证/侧栏/仪表板元数据投影 | `app/runtime/extensions/plugin/projection.py` | `PluginManager.get_plugin_*()` 原方法委托,异常隔离和 DTO 不变 | +| 插件配置和数据持久化 | `app/runtime/extensions/plugin/storage.py` | 启动层用 `SystemConfigOper`、`PluginDataOper` 注入;Runtime 不导入 Oper | +| 市场目录和版本/来源合并 | `app/application/plugin/catalog.py` | `PluginManager.get_online_plugins()` 等公开方法经启动注入的目录工厂委托 | +| 插件安装阶段编排 | `app/application/plugin/install.py` | API 和 Agent 共用命令;旧管理器/Helper 安装入口保留 | +| 动态插件路由 | `app/application/plugin/routes.py` + `app/adapters/web/plugin/routes.py` | `app/application/plugins.py` 保留旧 Facade;插件响应默认 raw | +| 市场读取 | `app/adapters/external/plugin/client.py` | `app.adapters.external.market.PluginHelper` 保留正式公共实现路径 | +| 包与依赖安装 | `app/adapters/system/plugin/package.py`、`dependency.py` | PluginManager 原方法只做委托和日志/上报 | +| 中心服务统计/分享 | `app/application/server/report.py`、`share.py` | `MoviePilotServerHelper` 保留 transport 和公开静态/类方法,由启动层注入用例 | + +阶段 5 的“拆分”是职责入口和组合依赖的拆分,不等于本轮把旧 `PluginHelper` 的全部 3,066 行算法复制到新文件。旧类仍是正式 V3 ABI,保留原类名、对象/静态方法和旧私有调用;新宿主路径使用上述 canonical client、package、dependency 和 Application command。后续如需继续内移算法,必须先增加旧私有调用命中统计和逐方法行为快照。 + +这里的“兼容”分为两类,后续 AI 不得混淆: + +1. 已迁移、只需恢复旧模块路径的入口,统一登记到 `app/runtime/compat/manifest.py`,新实现模块不复制旧对象导出。 +2. 插件直接依赖其对象身份或静态方法的正式 ABI,如 `PluginManager`、`PluginHelper`、`MoviePilotServerHelper`,继续留在原路径;已拆出的职责由 canonical 组件承接,未迁出的实现仍由原类承担。它们不是在新模块里额外定义一份别名,也不能为了“看起来统一”复制一套旧类。 + +`app.sdk.plugins` 只显式导出 `ModuleManager`、`PluginManager`。阶段 5 新实现包没有增加 `__all__`、惰性 `__getattr__`、旧 Manager/Helper/Oper 别名;任何新增插件公开能力必须先进入 SDK 清单和快照测试。 + +### 阶段 6:Agent 与模块族治理 + +#### 目标 + +处理高体量但相对独立的垂直子系统,避免阻塞前面主链路治理。 + +#### 工作项 + +1. Provider 规格数据化并与授权、模型目录、协议 client 分离。 +2. Agent 执行事件与 HTTP/SSE 映射分离。 +3. 工具注册按能力域延迟加载,降低工厂出度。 +4. 消息模块、媒体服务器模块、下载器模块分别固化 SPI。 +5. 消除自有模块内部 SCC;隔离第三方包局部环。 + +#### 验收 + +- Provider ID/配置和工具 schema 快照不变。 +- 工具工厂出度显著下降,目标不高于 20。 +- Agent 单元测试不需要启动完整 MoviePilot runtime。 +- 模块族可以用 host contract fixture 独立验证。 + +### 阶段 7:SDK 收敛、兼容治理与长期预算 + +#### 目标 + +让兼容从“永久扩张”变成“有版本、有观测、有替代入口”的产品能力。 + +#### 工作项 + +1. 发布 SDK public manifest 和变更规则。 +2. 为旧入口增加 DEBUG 级命中统计,不记录插件敏感数据。 +3. 标记推荐的新 SDK Facade;文档和新官方插件优先使用。 +4. 建立弃用决策模板,但 V3 普通版本不删除旧映射。 +5. 将架构指标纳入 CI 报告:SCC、禁止边、目标直接 DB 调用、巨型文件、SDK 变化。 + +#### 验收 + +- 新插件可以只依赖 SDK/Host SPI 完成常见能力。 +- 旧插件无需修改继续工作。 +- 每个弃用项有真实命中数据和替代方案,不按时间自动删除。 + +## 9. 推荐的首批实施任务 + +以下任务粒度适合其他 AI 独立执行,并且互相依赖清晰。 + +### 任务 A:插件动态 API raw 契约 + +**范围**:`app/application/plugins.py`、`app/api/response.py`、`app/factory.py`、对应测试。 +**目标**:主 API 统一信封,插件动态 API 默认自由返回。 +**禁止**:修改插件副本、修改普通 API 响应格式、修改鉴权默认值。 +**验证**:dict、Pydantic model、Response、StreamingResponse、204、自定义状态码、OpenAPI。 + +### 任务 B:`_music`/`subscribe` 环拆除 + +**范围**:`app/chain/_music.py`、`app/chain/subscribe.py`、订阅身份相关 Domain/Application 文件和测试。 +**目标**:迁移 `build_subscribe_meta`、`_subscribe_media_key(s)` 的真正所有权,消除延迟导入。 +**禁止**:改变音乐搜索、订阅完成判定、媒体身份字段、旧辅助函数路径。 +**验证**:音乐单曲/专辑、缺少远端 ID、同步/异步识别、旧路径导入、SCC。 + +### 任务 C:Schema 根入口惰性兼容导出 + +**范围**:`app/schemas/__init__.py`、内部精确导入、导出清单和测试。 +**目标**:消除全量星号导入和 schema SCC。 +**禁止**:删除 `app.schemas.X`、改变 Pydantic schema 和 OpenAPI。 +**验证**:公开符号快照、重复名、冷导入、全部 schema model rebuild、API OpenAPI。 + +### 任务 D:Chain 模块调度器提取 + +**范围**:`app/chain/__init__.py`、`app/runtime/extensions` 新调度组件、契约测试。 +**目标**:原样提取插件/系统模块调度算法。 +**禁止**:改变执行顺序、异常、限流、聚合、线程池策略。 +**验证**:参数化契约矩阵及 PluginManager/ModuleManager fake。 + +### 任务 E:订阅删除垂直切片 + +**范围**:`app/api/endpoints/subscribe.py` 删除端点、Application command、Oper 和测试。 +**目标**:API 不直接管理事务;提交成功后才发送事件和上报。 +**禁止**:改变路由、权限、响应、媒体身份、事件 payload。 +**验证**:存在/不存在、普通用户、管理员、commit 失败、事件失败、上报失败。 + +### 任务 F:外部服务 client 与分享用例分离 + +**范围**:`app/adapters/external/server.py` 选一个低风险能力,例如 workflow 分享。 +**目标**:client 不导入 Oper,Application 负责数据读取和 DTO。 +**禁止**:一次拆完整个 1,900 行文件。 +**验证**:旧 Helper 方法、请求参数、缓存、错误降级和 fake transport。 + +## 10. AI 实施标准作业流程 + +其他 AI 接到本文件中的任务时,必须按以下顺序执行。 + +### 10.1 开始前 + +1. 读取根 `AGENTS.md`、仓库 `AGENTS.md` 和所涉及目录规则。 +2. 检查分支、工作树、上游差异;不得覆盖用户或其他进程改动。 +3. 阅读公开入口、所有调用方、相关测试和兼容 manifest。 +4. 如果涉及插件契约,扫描 `../MoviePilot-Plugins/plugins.v2` 与 `plugins.v3`;不要把 `app/plugins` 当源码。 +5. 记录迁移前静态依赖、公开符号和行为快照。 + +### 10.2 任务说明必须包含 + +```yaml +objective: 单一可验证目标 +scope: + allowed_files: [] + affected_modules: [] +out_of_scope: [] +current_evidence: [] +public_contracts: + imports: [] + methods: [] + events: [] + api: [] +plugin_compatibility: + old_paths: [] + identity_requirements: [] + runtime_behaviors: [] +migration_steps: [] +tests: + focused: [] + architecture: [] + compatibility: [] +rollback: 如何恢复委托而不丢数据 +done_when: [] +``` + +### 10.3 编码规则 + +1. 一个批次只修一个依赖方向或一个垂直用例。 +2. 先建新实现,再让旧入口委托;不能先删除旧入口。 +3. 新增类和方法按仓库规则写类级、方法级中文注释,说明原因和关键约束。 +4. 注释不能只复述代码;兼容转发必须注明保留原因和不可改变的语义。 +5. 不用延迟导入作为最终环修复;它只能作为短期过渡且必须有清理任务。 +6. 不引入 `Manager2`、`HelperNew` 等无所有权名称。 +7. 不创建通用 `common.py`/`misc.py` 收纳不相关逻辑。 +8. 同步和异步逻辑优先共享纯核心,不用复制粘贴维持两套算法。 +9. 不把异常全部捕获后返回 False;错误类型和降级责任由边界决定。 +10. 不顺带格式化或重排无关大文件。 +11. 新增生产 Python 模块的文件名只使用一个小写单词;同一主题需要多个模块时,建立主题子目录,并在其中使用单词文件名。 +12. 已存在的多词公开导入路径只有在插件或兼容扫描证明不能迁移时才保留为薄门面,不得继续作为新模块命名模板。 +13. 测试文件继续使用 pytest 的描述性 `test_.py` 命名,不受生产模块单词命名约束。 + +### 10.4 每次迁移的七步闭环 + +1. **刻画**:补现有行为测试。 +2. **建契约**:定义 Protocol、DTO 或机器可读清单。 +3. **提取**:不改行为地移动单一职责。 +4. **委托**:旧入口调用新实现。 +5. **切换**:宿主内部新代码改用 canonical 入口。 +6. **兼容**:运行旧导入、对象身份和插件 fixture。 +7. **度量**:报告环、禁止边、出度、文件规模的变化。 + +任何一步没有验证,任务都不能标记为完成。 + +## 11. 验证矩阵 + +### 11.1 每个架构批次的最低门禁 + +```bash +./.venv/bin/python -m pytest tests/test_architecture_dependencies.py -q +./.venv/bin/python -m pytest tests/test_legacy_import_compat.py -q +./.venv/bin/python -m pytest tests/test_legacy_plugin_resource_imports.py -q +./.venv/bin/python -m pytest tests/test_plugin_sdk.py -q +``` + +再运行本批次聚焦测试。涉及发布级公共行为时,使用仓库完整门禁: + +```bash +./.venv/bin/python tests/run.py +``` + +本地若遇到已知二进制 `sites` 扩展导致的 `137/SIGKILL`,应按仓库既有测试 Stub 方案隔离;不能把进程被杀误报为断言失败,也不能因此跳过所有验证。 + +### 11.2 按边界追加的测试 + +| 变更边界 | 必测内容 | +| --- | --- | +| Event | 顺序、优先级、并发、handler 快照、目标插件、异常、热卸载 | +| Module | 插件优先、短路、列表合并、签名接力、sync/async、限流 | +| Plugin | hook 空值/异常、状态、配置、服务、API、页面、更新、热重载 | +| API | 路径、鉴权、响应信封/raw、状态码、OpenAPI、stream disconnect | +| DB | commit/rollback、并发、权限过滤、提交后副作用、同步/异步 | +| Startup | 主入口、`app.factory:app`、安全模式、部分失败、逆序关闭 | +| SDK/Compat | 旧路径、新路径、符号集合、对象身份、pickle/反射(如适用) | +| Agent | Provider 配置、工具 schema、流事件、取消、usage、插件工具 | + +### 11.3 非功能回归 + +每个阶段至少记录,并将结果写入 `tests/fixtures/architecture/`: + +- 冷导入 `app.factory` 耗时。 +- 正常和安全模式生命周期耗时;当前基线使用 `scripts/startup/performance.py` 的 no-op 组件采样,明确不启动真实插件、网络或用户数据库。 +- 隔离采样的线程数、后台任务数和数据库连接数范围;真实生产连接数由部署监控另行采集。 +- 架构模块数、边数、自有 SCC、目标禁止边数量。 +- 目标文件行数、方法最大行数、出度。 + +默认不要求每项立即变小,但不得无解释显著恶化。启动和请求关键路径超过 10% 的回归必须调查。 + +当前可复现命令: + +```bash +./.venv/bin/python scripts/startup/performance.py --repeat 3 +./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins +``` + +### 11.4 2026-08-17 当前验证快照 + +| 范围 | 命令 | 结果 | +| --- | --- | --- | +| 后端完整门禁 | `./.venv/bin/python tests/run.py` | 4,890 passed,3 skipped,0 failed | +| 架构与插件快照 | `./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins` | 通过,无基线漂移 | +| 前端联邦 API 客户端 | `yarn test:run src/api/__tests__/client.spec.ts src/api/__tests__/index.spec.ts` | 36 passed | +| 前端类型检查 | `yarn typecheck` | 通过 | +| V3 插件契约与版本门禁 | `../MoviePilot/.venv/bin/python -m pytest tests/ci/test_v3_contract.py tests/ci/test_plugin_release_gate.py -q` | 16 passed | +| 本次 IMDb/TVDB 插件适配 | `../MoviePilot/.venv/bin/python -m pytest tests/v3/imdbsource tests/v3/tvdbdiscover -q` | 14 passed | + +独立插件仓 `tests/v3` 全量当前为 62 passed、9 failed。失败集中在本次未修改的 AnimeUpscale 版本断言、LibraryScraper 未知媒体源处理、历史身份迁移和媒体服务器身份测试;它们不经过本次 IMDb/TVDB 响应适配路径,但仍是插件仓自身需要单独清理的红色基线。不得把“本次适配专项通过”扩大表述为“插件仓全量通过”。 + +## 12. 量化治理目标 + +### 12.1 短期目标(阶段 0-2) + +- 动态插件 API 返回契约明确并有真实请求测试。 +- `run_module` 方法名和插件 hook 100% 进入契约快照。 +- 自有 SCC 不增长,消除 `_music`/`subscribe`、schemas、DB 根回流等首批环。 +- Adapter→DB、Runtime→DB 新增裸依赖为零;API 既有 42 条 Model、15 条 Session 边保留在趋势基线中,新增端点不得再增加。 +- 生命周期组件和 Event resolver 命中可观测。 + +### 12.2 中期目标(阶段 3-5) + +- 本轮纳入阶段 3 的写端点不再直接持有数据库事务;其余 15 条 endpoint→Session 基线按后续切片继续收敛。 +- PluginManager 不直接做市场、pip、压缩包和备份实现。 +- 外部 Adapter 不导入 Oper。 +- 重点 Chain 每个完成至少 3 个垂直切片迁移。 +- `ChainBase` 调度可脱离真实 runtime 单测。 + +### 12.3 长期目标(阶段 6-7) + +- 除明确第三方局部豁免外,自有 Python 模块 SCC 归零。 +- `app.agent.tools.factory` 出度从约 99 降至不高于 20。 +- 新 API endpoint 原则上不超过 80 行,新 Application 用例原则上不超过 150 行。 +- 新插件常用能力只依赖 `app.sdk`/Host SPI;旧插件仍可运行。 +- 兼容面有版本、命中数据、替代入口和机器可读清单。 + +这些是治理指标,不是为了达标而机械切文件。任何指标变化都要结合职责是否真正单一判断。 + +## 13. 风险清单与回滚策略 + +| 风险 | 典型触发 | 防护 | 回滚 | +| --- | --- | --- | --- | +| 插件模块结果变化 | 改写 `run_module` | 契约矩阵、记录调用序列 | Facade 切回旧 dispatcher | +| 事件顺序/并发变化 | 拆 EventManager | 可控 loop/executor 测试 | 保留旧 dispatcher 注入 | +| 插件 API 被包装 | 共用主 RouteClass | 真实请求 raw 测试 | 动态路由强制 raw | +| Singleton 身份分裂 | 新旧入口各自实例化 | `is` 测试、startup provider | 旧入口转回同一 provider | +| DB 副作用提前 | 事务迁移 | commit 失败测试、after-commit | 用例切回旧端点实现 | +| 热重载残留 | 拆 PluginManager | handler/route/service 快照 | 切回旧 lifecycle Facade | +| Provider 配置失效 | 拆 LLM provider | 配置/ID 快照与真实 fixture | 保留旧 resolver | +| 启动死锁或提前 I/O | 组合根迁移 | import/startup 线程连接快照 | 单资源恢复旧 initializer | +| Pickle/反射路径变化 | 文件改包/类移动 | `__module__`/反序列化测试 | 旧类留在原模块作门面 | +| 缓存不一致 | 调用层迁移 | key/TTL/失效时序测试 | Facade 继续使用旧缓存策略 | + +架构改造的回滚单位必须是“旧 Facade 的委托切换”,不能依赖回滚数据库迁移或清理用户数据。 + +## 14. 明确禁止的重构方式 + +1. 把大文件机械切成多个互相任意导入的小文件。 +2. 用函数内导入、`TYPE_CHECKING` 或字符串模块名掩盖真实运行依赖,并把它当作完成。 +3. 新建另一个全局 Service Locator 取代 Singleton。 +4. 为追求纯层级而复制相同 DTO、枚举和媒体身份规则。 +5. 一次性重写 Chain、PluginManager、EventManager 或 Agent orchestrator。 +6. 在同一批次同时移动类、改参数、改返回、改异常和改缓存。 +7. 删除旧导入后批量修改官方插件来“证明兼容”。 +8. 以 `app/plugins` 当前副本扫描结果代替独立插件生态审计。 +9. 把主 API 的 `{success, message, data}` 信封强加给动态插件 API。 +10. 以 build/pytest 通过代替依赖图、ABI 和启动副作用验证。 +11. 用 LOC 作为唯一目标,导致职责更分散但依赖没有变少。 +12. 架构批次夹带数据库 schema、前端协议或资源文件变更。 + +## 15. 完成定义 + +单个治理任务只有同时满足以下条件才算完成: + +1. 目标职责有明确所有者和 canonical 路径。 +2. 旧公开入口按兼容要求保留。 +3. 宿主内部调用已经切到正确入口,不继续扩大旧模式。 +4. 静态依赖方向改善,有前后数据。 +5. 行为、错误、同步/异步和生命周期测试通过。 +6. 插件导入、hook、对象身份和动态 API 相关测试通过。 +7. 没有把问题转移成新的延迟导入、全局容器或无边界 Helper。 +8. 相关架构规则、compat manifest、SDK 清单和文档已同步。 +9. 变更范围可独立回滚,不依赖数据降级。 +10. 汇报中明确区分已验证、未验证和剩余风险。 + +## 16. 后续文档维护 + +- 每完成一个阶段,在本文对应工作项后记录实际提交、指标变化和剩余例外。 +- 若目标目录与 `docs/rules/05-architecture.md` 冲突,以更新后的正式规则为准,并在同一提交同步本文。 +- 新增兼容入口必须更新 SDK/compat 机器清单,不只更新文字。 +- 新发现的越层依赖先进入基线并给出清理阶段,不能用永久全局豁免消音。 +- 本文不记录 `app/plugins/` 副本内容;插件生态数据应以独立插件仓的可重复扫描为准。 + +--- + +下一轮建议从阶段 6 开始,优先顺序为:**Application→Agent 的 3 条反向边 → Agent LLM/policy 自有 SCC → 消息与媒体服务器模块 SPI → 剩余 API/Session/Model 垂直切片**。每批仍按“契约快照、提取、旧入口委托、独立插件仓扫描、完整门禁”的顺序实施,不能因为阶段 0-5 已完成中期验收就删除 V3 兼容入口。 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index bdb90d903..337eea787 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -120,6 +120,7 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所 - 查询接口未命中但请求已正常完成时仍返回 `success=true`,存在性等业务状态通过 `data` 表达。例如 `/mediaserver/exists` 未命中时返回空的 `data.item`。 - 每个普通 JSON 端点都会在 OpenAPI 中声明具体的 `Response[DataModel]`,调用方可从 `/docs` 或 `/api/v1/openapi.json` 查询数据结构。 - SSE、文件、图片、HTML、空响应,以及 OAuth2 登录、OpenAI、Anthropic、MCP JSON-RPC 等标准协议端点保持协议原生响应体;它们会在 OpenAPI 中显式声明对应的流、文件或协议模型。 +- 插件通过 `get_api()` 动态注册的 `/api/v1/plugin/...` 端点不属于主程序统一响应信封范围。插件自行声明响应模型、状态码和返回体,宿主只补充路径与鉴权依赖。 客户端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` 或 `Accept-Language`。后端会按当前请求语言直接翻译顶层 `message`;未提供语言头时使用简体中文,翻译缺失时回退原文本。SSE 和业务数据中原有的 `text_i18n`、`error_i18n` 等展示字段继续保留。 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 37cdcd663..9210a54a3 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -60,7 +60,14 @@ to make the directory tree look symmetrical. | Path | Ownership | |---|---| -| `app/application/*.py` | Audio, directory, downloader, filter, formatting, transfer history, image, media-server, notification, recognition, RSS, storage and torrent application services | +| `app/application/*.py` | Established single-module application services and compatibility facades | +| `app/application/subscription/` | Subscription contracts and write commands: `contract.py` owns shared metadata/media-key projection; `delete.py` and `identity.py` own deletion use cases | +| `app/application/search/` | Search state and later search-plan use cases | +| `app/application/download/` | Download task querying/control and later submission use cases | +| `app/application/music/` | Multi-source music catalog orchestration | +| `app/application/chain/` | Injectable Chain runtime context and compatibility provider | +| `app/application/plugin/` | Plugin market catalog, installation command and dynamic-route port; filenames remain single words (`catalog.py`, `install.py`, `routes.py`) | +| `app/application/server/` | MoviePilot Server reporting and sharing use cases; local data readers and transport callbacks are injected by startup | | `app/application/site/` | Configured site catalog, authentication level and index-resource capability; the generated extension and its data bundle stay together here | | `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge: `interaction.py` shared interaction contracts and view helpers; `router.py` unified interaction priority and callback dispatch; `site.py`/`subscribe.py`/`skill.py` per-command sessions, input parsing and views; `media.py` media interaction state while the business workflow stays in `MediaInteractionChain`; `plugin.py` plugin input capture and plugin button callbacks; `agent.py` agent choice state, callback protocol and WebAgent bridge; `message.py` notification rendering, templates and queue. Not a public SDK recommended for direct plugin use | | `app/application/security/` | Authentication, authorization, cookies, passkeys, OTP/two-factor, path/URL safety, SSRF and signing policy | @@ -87,6 +94,14 @@ create additional top-level directory categories. runtime. It injects providers and callbacks, orders initialization/shutdown and decides restart policy. Lower-level runtime modules must not import startup. +`app.schemas` and `app.db` are compatibility facades, not implementation +dependency hubs. Host code imports concrete schema submodules; the schema root +resolves its generated export manifest lazily for plugins and legacy callers. +DB internals import `base`, `decorators`, `engine`, `session`, concrete models +and Oper modules directly. `app.db.models.load_all_models()` is the explicit +composition entry used before metadata creation or migration; importing one +model must not import every table. + ### Adapter boundaries | Path | Ownership | @@ -95,6 +110,8 @@ decides restart policy. Lower-level runtime modules must not import startup. | `app/adapters/network/` | Generic HTTP, browser, DNS, Cloudflare and IP transport mechanisms | | `app/adapters/system/` | OS/filesystem/process facilities, stdio, display, packages, resources and optional Rust acceleration | | `app/adapters/external/` | CookieCloud, plugin market, OCR, IP-location providers and MoviePilot Server | +| `app/adapters/external/plugin/client.py` | Read-only plugin-market and local-repository client over the established `PluginHelper` implementation | +| `app/adapters/system/plugin/` | Plugin package and dependency I/O (`package.py`, `dependency.py`) | Generic protocol transport belongs in `adapters/network`; a named product or ecosystem workflow belongs in `adapters/external`. An adapter may depend on @@ -115,6 +132,9 @@ mechanism remains in `app/adapters/system/resource.py`. `start`、`stop` 生命周期,`startup` 负责构建 Capability Runtime。声明必须使用 `on_first_use`,普通启动只发现声明;消费者通过 `app/runtime/managed_resources.py` 显式获取资源。关闭路径先释放消费者,再关闭已初始化 Runtime,未使用的资源不得因关闭而物化。 +应用级启动顺序使用 `app/startup/lifecycle/components.py` 的组件描述声明依赖、 +normal/safe-mode 范围、start/stop 顺序、超时预算和失败策略。新增进程级资源不得只在 +`lifespan()` 中追加过程代码,必须先进入可导出的生命周期清单并补顺序快照测试。 Runtime 关闭后不可逆;完整应用生命周期的再次启动必须由新进程承载,不能在同一解释器中重建局部资源域。 插件需要浏览器时使用 `app.sdk.browser`,由宿主浏览器适配器协调资源,不直接依赖资源实现。 旧插件若直接导入有资源前置条件的第三方包,compat 在插件 import 前递归扫描源码并保守准备资源; @@ -199,6 +219,21 @@ Use these questions in order before creating or moving a migrated capability: Do not create generic `common`, `helper` or `utils` buckets. Reuse does not erase ownership. +New production Python module filenames use one lowercase word. When one topic +needs multiple modules, create a topic package and keep each child filename to +one word, for example `runtime/event/{registry,binding,dispatch,errors}.py` or +`application/subscription/{contract,delete,identity}.py`. Established multiword +public import paths may remain as compatibility exceptions after plugin/import +scanning, but they are not templates for new modules. Test filenames continue +to follow pytest's descriptive `test_.py` convention. + +Legacy module paths belong in `app/runtime/compat/manifest.py`. New +implementation modules must not re-export old managers, helpers or Oper classes +just to preserve imports or tests. A public runtime object whose path or identity +is itself part of the plugin ABI stays at its established path as a thin facade; +new plugin-facing symbols are exported deliberately through `app/sdk` and its +architecture snapshot, not through incidental module globals. + ## Existing Chain, Module and DB Layers ### Chain layer @@ -211,6 +246,15 @@ do not belong here. Chains interact with modules exclusively through `run_module` dispatch on method-name contracts; direct imports of module internals (classes, exceptions, constants) are forbidden, so every module stays pluggable and a chain never names a concrete module implementation. +The dispatch algorithm belongs to +`app/runtime/extensions/module/dispatcher.py`; `ChainBase` remains the +compatibility facade. New chains and tests inject the minimal +`ChainRuntimeContext` from `app/application/chain/context.py`. No-argument +`Chain()` remains supported through the startup-configured compatibility +provider. High-frequency string methods are classified in +`module/contracts.py`; unknown third-party plugin methods retain the frozen +legacy aggregation contract, while the architecture baseline records every +literal method and call site. Underscore-prefixed files in `app/chain/` are feature-domain mixins for `ChainBase` and concrete chains, not chains themselves: `_recognition.py` @@ -219,7 +263,11 @@ Underscore-prefixed files in `app/chain/` are feature-domain mixins for slash-command delegation for `remote_list` / `parse_callback` / `handle_callback_interaction` / `handle_text_interaction`), `_music.py` (`MusicSubscribeMixin`, the music single/album subscribe domain mixed into -`SubscribeChain`) and `_transfer.py` (TransferChain feature mixins). A concrete chain that exposes slash-command +`SubscribeChain`) and `_transfer.py` (TransferChain feature mixins). Shared +subscription metadata and media-key construction belongs to +`app.application.subscription.contract`; `app.chain.subscribe` keeps the old helper +names only as compatibility forwards and `_music` must not import its concrete +chain owner. A concrete chain that exposes slash-command interaction inherits `InteractionChainMixin`, injects its handler class via `_interaction_handler_type` and implements only `_interaction_handler`; it must not re-export application-layer interaction managers. @@ -235,6 +283,13 @@ exceptions and value domains used by both modules and upper layers live in method names. The directory remains unchanged because discovery and plugin code depend on this established runtime root. +`app.modules.filemanager` is a lazy compatibility entrypoint. The concrete +`FileManagerModule` implementation lives in `app.modules.filemanager.module`, +while the historical capability path and class module identity remain +`app.modules.filemanager:FileManagerModule`. Storage and transfer-handler +submodules must not import the concrete module implementation through the +package root. + `app/modules/_base/` hosts the shared template base classes for module families (`downloader.py`, `mediaserver.py`, `notification.py`), each combining the family mixin with `_ModuleBase` and typed by `TService` (usage: @@ -364,9 +419,27 @@ policy. `app/db` therefore has no dependency on `app/domain`. | `app/application/commands.py` | Command registry facade for Agent tools and endpoints; `Command` class registered by `app/startup/command_initializer.py` | | `app/chain/agent.py` | `AgentChain(ChainBase)`: the chain-layer entry for Agent sessions; Agent runtime stays in `app/agent/` | | `app/runtime/config.py` | `ConfigModel`, `Settings` and deployment configuration | -| `app/runtime/events.py` | `EventManager`, `Event` and event resolver registration | +| `app/runtime/events.py` | `EventManager`/`Event` compatibility facade and global `eventmanager` identity | +| `app/runtime/event/registry.py` | Event subscriptions, enable/disable state and dispatch snapshots | +| `app/runtime/event/binding.py` | Explicit module/plugin/host handler resolvers; unresolved classes are diagnosed and skipped, never implicitly constructed by the bus | +| `app/runtime/event/dispatch.py` | Chain/broadcast ordering, concurrency, target-plugin filtering and isolated delivery | +| `app/runtime/event/errors.py` | Handler failure notification and non-recursive `SystemError` downgrade policy | +| `app/runtime/extensions/module/dispatcher.py` | Plugin-first invocation, short-circuit, list merge, signature relay and sync/async execution | +| `app/runtime/extensions/module/contracts.py` | High-frequency method families and frozen legacy fallback contract | +| `app/application/chain/context.py` | Injectable Chain dependencies and no-argument compatibility provider | +| `app/startup/lifecycle/components.py` | Declarative normal/safe-mode lifecycle manifest, ordering and timeout budgets | | `app/runtime/extensions/module_manager.py` | Module discovery and lifecycle | | `app/runtime/extensions/plugin_manager.py` | Plugin discovery and lifecycle | +| `app/runtime/extensions/plugin/projection.py` | Plugin commands, APIs, services, modules and actions projected from a running-registry snapshot | +| `app/runtime/extensions/plugin/storage.py` | Injected plugin configuration/data persistence port; runtime code does not import DB Oper classes | +| `app/application/plugin/catalog.py` | Plugin-market mapping, concurrent collection, generation merge and source/version deduplication | +| `app/application/plugin/install.py` | Compatibility, package installation, reporting, installed-list persistence and runtime reload command | +| `app/application/plugin/routes.py` | Dynamic plugin-route registry protocol; plugin response payloads remain raw unless the plugin chooses its own envelope | +| `app/application/server/report.py` | Server reporting use cases over injected local readers and transport callbacks | +| `app/application/server/share.py` | Server sharing use cases over injected repositories and transport callbacks | +| `app/adapters/external/plugin/client.py` | Plugin-market read adapter and cache-refresh boundary | +| `app/adapters/system/plugin/package.py` | Plugin package installation adapter | +| `app/adapters/system/plugin/dependency.py` | Plugin dependency inspection and installation adapter | | `app/runtime/extensions/managed_resource_adapter.py` | Data-only managed-resource registry and sync/async lifecycle adapters | | `app/runtime/managed_resources.py` | Lightweight acquisition, state observation and shutdown facade | | `app/foundation/reflection.py` | Generic reflection and Python module discovery | @@ -396,4 +469,4 @@ imports, entrypoint (`api`/`agent`/`monitor`/`workflow`/`doctor`) imports of modules only through `run_module` dispatch), and downloader SDK (`qbittorrentapi`, `transmission_rpc`) imports inside `app/chain`. -*Last Updated: 2026-08-16* +*Last Updated: 2026-08-17* diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py new file mode 100644 index 000000000..8bc0147bc --- /dev/null +++ b/scripts/architecture/baseline.py @@ -0,0 +1,701 @@ +#!/usr/bin/env python3 +"""生成并校验 MoviePilot 后端架构与插件兼容契约基线。""" + +import argparse +import ast +import dataclasses +import hashlib +import importlib.util +import json +import subprocess +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any, Optional + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +APP_ROOT = PROJECT_ROOT / "app" +BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture" +DEPENDENCY_BASELINE_PATH = BASELINE_ROOT / "dependency-baseline.json" +RUNTIME_BASELINE_PATH = BASELINE_ROOT / "runtime-contract-baseline.json" +PLUGIN_BASELINE_PATH = BASELINE_ROOT / "official-plugin-baseline.json" +PLUGIN_HOOKS = ( + "get_actions", + "get_agent_tools", + "get_api", + "get_auth_provider", + "get_command", + "get_dashboard", + "get_form", + "get_module", + "get_page", + "get_render_mode", + "get_service", + "get_sidebar", + "get_state", + "init_plugin", + "stop_service", +) + + +def discover_modules() -> dict[str, Path]: + """返回宿主 Python 模块与源码路径,排除运行时插件副本。""" + modules: dict[str, Path] = {} + for path in APP_ROOT.rglob("*.py"): + relative = path.relative_to(PROJECT_ROOT).with_suffix("") + parts = list(relative.parts) + if parts[:2] == ["app", "plugins"]: + continue + if parts[-1] == "__init__": + parts.pop() + modules[".".join(parts)] = path + return modules + + +def parse_source(path: Path) -> ast.Module: + """以仓库统一编码解析 Python 源码。""" + return ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + + +def iter_import_candidates( + module_name: str, + path: Path, +) -> list[tuple[str, Optional[str]]]: + """提取模块导入候选,第二项记录 from-import 的具体符号。""" + package = module_name if path.name == "__init__.py" else module_name.rpartition(".")[0] + candidates: list[tuple[str, Optional[str]]] = [] + for node in ast.walk(parse_source(path)): + if isinstance(node, ast.Import): + candidates.extend((alias.name, None) for alias in node.names) + continue + if not isinstance(node, ast.ImportFrom): + continue + if node.level: + package_parts = package.split(".") + base = ".".join(package_parts[: len(package_parts) - node.level + 1]) + imported_module = ".".join( + part for part in (base, node.module or "") if part + ) + else: + imported_module = node.module or "" + if not imported_module: + continue + candidates.extend( + (imported_module, alias.name) + for alias in node.names + if alias.name != "*" + ) + return candidates + + +def resolve_imports( + module_name: str, + path: Path, + known_modules: set[str], +) -> set[str]: + """解析宿主内部静态导入,并计入 Python 必然初始化的父包。""" + dependencies: set[str] = set() + for imported_module, imported_name in iter_import_candidates(module_name, path): + candidates = [imported_module] + if imported_name: + candidates.append(f"{imported_module}.{imported_name}") + for candidate in candidates: + parts = candidate.split(".") + dependencies.update( + parent + for index in range(2, len(parts)) + if (parent := ".".join(parts[:index])) in known_modules + ) + if candidate in known_modules: + dependencies.add(candidate) + dependencies.discard(module_name) + return dependencies + + +def strongly_connected_components( + graph: dict[str, set[str]], +) -> list[list[str]]: + """使用 Tarjan 算法返回稳定排序的非平凡强连通分量。""" + indices: dict[str, int] = {} + low_links: dict[str, int] = {} + stack: list[str] = [] + on_stack: set[str] = set() + components: list[list[str]] = [] + + def visit(module_name: str) -> None: + """深度遍历模块并在根节点收集强连通分量。""" + indices[module_name] = len(indices) + low_links[module_name] = indices[module_name] + stack.append(module_name) + on_stack.add(module_name) + for dependency in sorted(graph[module_name]): + if dependency not in indices: + visit(dependency) + low_links[module_name] = min( + low_links[module_name], low_links[dependency] + ) + elif dependency in on_stack: + low_links[module_name] = min( + low_links[module_name], indices[dependency] + ) + if low_links[module_name] != indices[module_name]: + return + component: list[str] = [] + while stack: + dependency = stack.pop() + on_stack.remove(dependency) + component.append(dependency) + if dependency == module_name: + break + if len(component) > 1: + components.append(sorted(component)) + + for module_name in sorted(graph): + if module_name not in indices: + visit(module_name) + return sorted(components) + + +def collect_boundary_edges( + graph: dict[str, set[str]], + modules: dict[str, Path], +) -> dict[str, list[str]]: + """收集治理文档指定的当前越层边,供后续阶段逐项收缩。""" + boundaries: dict[str, list[str]] = { + "adapters_to_db": [], + "api_endpoints_to_db_models": [], + "api_endpoints_to_sessions": [], + "application_to_agent": [], + "runtime_to_db": [], + } + for source, dependencies in graph.items(): + for target in dependencies: + edge = f"{source} -> {target}" + if source.startswith("app.adapters") and target.startswith("app.db"): + boundaries["adapters_to_db"].append(edge) + if source.startswith("app.runtime") and target.startswith("app.db"): + boundaries["runtime_to_db"].append(edge) + if source.startswith("app.api.endpoints") and target.startswith( + "app.db.models" + ): + boundaries["api_endpoints_to_db_models"].append(edge) + if source.startswith("app.application") and target.startswith("app.agent"): + boundaries["application_to_agent"].append(edge) + for source, path in modules.items(): + if not source.startswith("app.api.endpoints"): + continue + for imported_module, imported_name in iter_import_candidates(source, path): + if imported_module not in { + "sqlalchemy.orm", + "sqlalchemy.ext.asyncio", + }: + continue + if imported_name not in {"Session", "AsyncSession"}: + continue + boundaries["api_endpoints_to_sessions"].append( + f"{source} -> {imported_module}.{imported_name}" + ) + return { + boundary: sorted(set(edges)) + for boundary, edges in sorted(boundaries.items()) + } + + +def collect_dependency_baseline() -> dict[str, Any]: + """生成宿主模块、依赖边、SCC 和越层边的完整基线。""" + modules = discover_modules() + known_modules = set(modules) + graph = { + name: resolve_imports(name, path, known_modules) + for name, path in modules.items() + } + edges = sorted( + f"{source} -> {target}" + for source, dependencies in graph.items() + for target in dependencies + ) + digest = hashlib.sha256("\n".join(edges).encode("utf-8")).hexdigest() + return { + "schema_version": 1, + "scope": "MoviePilot host app excluding app/plugins", + "module_count": len(modules), + "edge_count": len(edges), + "edge_sha256": digest, + "modules": sorted(modules), + "edges": edges, + "strongly_connected_components": strongly_connected_components(graph), + "boundary_edges": collect_boundary_edges(graph, modules), + } + + +def collect_run_module_contracts() -> dict[str, Any]: + """收集字符串模块调度方法及其同步、异步调用位置。""" + calls: dict[str, list[dict[str, Any]]] = defaultdict(list) + dynamic_calls: list[dict[str, Any]] = [] + for module_name, path in discover_modules().items(): + tree = parse_source(path) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + if node.func.attr not in {"run_module", "async_run_module"}: + continue + location = { + "caller": module_name, + "line": node.lineno, + "mode": "async" if node.func.attr == "async_run_module" else "sync", + } + if ( + node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + calls[node.args[0].value].append(location) + else: + dynamic_calls.append(location) + stable_calls = { + method: sorted( + locations, + key=lambda item: (item["caller"], item["line"], item["mode"]), + ) + for method, locations in sorted(calls.items()) + } + return { + "method_count": len(stable_calls), + "call_count": sum(len(locations) for locations in stable_calls.values()), + "dynamic_call_count": len(dynamic_calls), + "methods": stable_calls, + "dynamic_calls": sorted( + dynamic_calls, + key=lambda item: (item["caller"], item["line"], item["mode"]), + ), + } + + +def _event_reference(node: ast.AST) -> str | None: + """从 AST 节点解析 EventType/ChainEventType 的静态成员引用。""" + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in {"EventType", "ChainEventType"} + ): + return f"{node.value.id}.{node.attr}" + return None + + +def _event_enum_members(enum_name: str) -> tuple[str, ...]: + """从 schema 源码读取事件枚举成员,避免基线脚本导入宿主运行时。""" + tree = parse_source(APP_ROOT / "schemas" / "types.py") + enum_class = next( + ( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == enum_name + ), + None, + ) + if enum_class is None: + raise RuntimeError(f"未找到事件枚举:{enum_name}") + return tuple( + target.id + for statement in enum_class.body + if isinstance(statement, (ast.Assign, ast.AnnAssign)) + for target in ( + statement.targets + if isinstance(statement, ast.Assign) + else [statement.target] + ) + if isinstance(target, ast.Name) and not target.id.startswith("_") + ) + + +def collect_event_contracts() -> dict[str, Any]: + """收集宿主事件枚举的生产者、消费者和动态调用位置。""" + event_members = _event_enum_members("EventType") + chain_event_members = _event_enum_members("ChainEventType") + + producers: dict[str, list[dict[str, Any]]] = defaultdict(list) + consumers: dict[str, list[dict[str, Any]]] = defaultdict(list) + dynamic_producers: list[dict[str, Any]] = [] + dynamic_consumers: list[dict[str, Any]] = [] + for module_name, path in discover_modules().items(): + tree = parse_source(path) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance( + node.func, + ast.Attribute, + ): + continue + location = {"caller": module_name, "line": node.lineno} + if node.func.attr in {"send_event", "async_send_event"}: + reference = _event_reference(node.args[0]) if node.args else None + if reference: + producers[reference].append(location) + else: + dynamic_producers.append(location) + continue + if node.func.attr not in {"register", "add_event_listener"}: + continue + references: list[str] = [] + if node.args: + target = node.args[0] + if reference := _event_reference(target): + references.append(reference) + elif isinstance(target, (ast.List, ast.Tuple)): + references.extend( + reference + for item in target.elts + if (reference := _event_reference(item)) + ) + elif ( + isinstance(target, ast.Name) + and target.id in {"EventType", "ChainEventType"} + ): + enum_members = ( + event_members + if target.id == "EventType" + else chain_event_members + ) + references.extend( + f"{target.id}.{member}" for member in enum_members + ) + if references: + for reference in references: + consumers[reference].append(location) + else: + dynamic_consumers.append(location) + + enum_names = [ + *(f"EventType.{member}" for member in event_members), + *(f"ChainEventType.{member}" for member in chain_event_members), + ] + contracts = { + name: { + "producers": sorted( + producers.get(name, []), + key=lambda item: (item["caller"], item["line"]), + ), + "consumers": sorted( + consumers.get(name, []), + key=lambda item: (item["caller"], item["line"]), + ), + } + for name in sorted(enum_names) + } + return { + "event_count": len(contracts), + "producer_count": sum( + len(item["producers"]) for item in contracts.values() + ), + "consumer_count": sum( + len(item["consumers"]) for item in contracts.values() + ), + "events": contracts, + "dynamic_producers": sorted( + dynamic_producers, + key=lambda item: (item["caller"], item["line"]), + ), + "dynamic_consumers": sorted( + dynamic_consumers, + key=lambda item: (item["caller"], item["line"]), + ), + } + + +def collect_sdk_exports() -> dict[str, list[dict[str, str]]]: + """通过 AST 收集顶层 SDK 公开符号,避免导入时物化运行资源。""" + result: dict[str, list[dict[str, str]]] = {} + for path in sorted((APP_ROOT / "sdk").glob("*.py")): + module_name = f"app.sdk.{path.stem}" if path.stem != "__init__" else "app.sdk" + exports: list[dict[str, str]] = [] + for node in parse_source(path).body: + if isinstance(node, ast.ImportFrom) and node.module: + for alias in node.names: + public_name = alias.asname or alias.name + if public_name.startswith("_") or alias.name == "*": + continue + exports.append( + { + "name": public_name, + "kind": "import", + "target": f"{node.module}.{alias.name}", + } + ) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if not node.name.startswith("_"): + exports.append( + {"name": node.name, "kind": type(node).__name__, "target": ""} + ) + result[module_name] = sorted( + exports, + key=lambda item: (item["name"], item["kind"], item["target"]), + ) + return result + + +def json_compatible(value: Any) -> Any: + """把兼容清单中的 dataclass、集合和映射转换为稳定 JSON 数据。""" + if dataclasses.is_dataclass(value): + return { + field.name: json_compatible(getattr(value, field.name)) + for field in dataclasses.fields(value) + } + if isinstance(value, dict): + return { + str(key): json_compatible(item) + for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) + } + if isinstance(value, (set, frozenset, tuple, list)): + items = [json_compatible(item) for item in value] + try: + return sorted(items, key=lambda item: json.dumps(item, sort_keys=True)) + except TypeError: + return items + return value + + +def collect_compat_manifest() -> dict[str, Any]: + """加载仅依赖标准库的兼容清单并序列化公开映射。""" + path = APP_ROOT / "runtime" / "compat" / "manifest.py" + spec = importlib.util.spec_from_file_location("architecture_compat_manifest", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"无法加载兼容清单:{path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + try: + spec.loader.exec_module(module) + finally: + sys.modules.pop(spec.name, None) + names = ( + "MODULE_ALIASES", + "PACKAGE_ALIASES", + "PACKAGE_EXPORTS", + "SYMBOL_ALIASES", + "VIRTUAL_PACKAGES", + ) + return { + name.lower(): json_compatible(getattr(module, name)) + for name in names + } + + +def collect_runtime_baseline() -> dict[str, Any]: + """生成模块调度、SDK 和兼容层公开契约基线。""" + return { + "schema_version": 1, + "run_module": collect_run_module_contracts(), + "events": collect_event_contracts(), + "sdk_exports": collect_sdk_exports(), + "compat_manifest": collect_compat_manifest(), + } + + +def git_head(repository: Path) -> str: + """读取外部插件仓当前提交,失败时返回可诊断占位值。""" + result = subprocess.run( + ["git", "-C", str(repository), "rev-parse", "HEAD"], + capture_output=True, + check=False, + text=True, + ) + return result.stdout.strip() if result.returncode == 0 else "unknown" + + +def collect_plugin_imports(path: Path) -> set[str]: + """收集单个插件文件直接声明的 app 导入模块。""" + imports: set[str] = set() + for node in ast.walk(parse_source(path)): + if isinstance(node, ast.Import): + imports.update( + alias.name for alias in node.names if alias.name.startswith("app.") + ) + elif isinstance(node, ast.ImportFrom) and node.module: + if node.module.startswith("app."): + imports.add(node.module) + return imports + + +def collect_plugin_api_contracts(path: Path) -> list[dict[str, Any]]: + """收集插件 ``get_api`` 中可静态解析的路由与响应模型声明。""" + tree = parse_source(path) + functions = { + node.name: node + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + routes: list[dict[str, Any]] = [] + for function in functions.values(): + if function.name != "get_api": + continue + for node in ast.walk(function): + if not isinstance(node, ast.Dict): + continue + values = { + key.value: value + for key, value in zip(node.keys, node.values) + if isinstance(key, ast.Constant) and isinstance(key.value, str) + } + path_node = values.get("path") + if not isinstance(path_node, ast.Constant) or not isinstance( + path_node.value, str + ): + continue + endpoint_node = values.get("endpoint") + endpoint = ( + endpoint_node.attr + if isinstance(endpoint_node, ast.Attribute) + else ast.unparse(endpoint_node) if endpoint_node else "" + ) + endpoint_function = functions.get(endpoint) + methods_node = values.get("methods") + try: + methods = ast.literal_eval(methods_node) if methods_node else [] + except (TypeError, ValueError): + methods = [ast.unparse(methods_node)] if methods_node else [] + routes.append( + { + "auth": ast.unparse(values["auth"]) if "auth" in values else None, + "endpoint": endpoint, + "endpoint_return": ( + ast.unparse(endpoint_function.returns) + if endpoint_function and endpoint_function.returns + else None + ), + "methods": methods, + "path": path_node.value, + "response_class": ( + ast.unparse(values["response_class"]) + if "response_class" in values + else None + ), + "response_model": ( + ast.unparse(values["response_model"]) + if "response_model" in values + else None + ), + } + ) + return sorted(routes, key=lambda item: (item["path"], item["endpoint"])) + + +def collect_official_plugin_baseline(plugin_repo: Path) -> dict[str, Any]: + """扫描独立官方插件仓的导入面、Hook 和动态 API 契约。""" + roots = [plugin_repo / "plugins.v2", plugin_repo / "plugins.v3"] + paths = sorted( + path + for root in roots + if root.exists() + for path in root.rglob("*.py") + ) + import_files: dict[str, set[str]] = defaultdict(set) + hook_files: dict[str, set[str]] = defaultdict(set) + api_contracts: dict[str, list[dict[str, Any]]] = {} + digest = hashlib.sha256() + for path in paths: + relative = path.relative_to(plugin_repo).as_posix() + content = path.read_bytes() + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(content) + for imported_module in collect_plugin_imports(path): + import_files[imported_module].add(relative) + routes = collect_plugin_api_contracts(path) + if routes: + api_contracts[relative] = routes + tree = ast.parse(content.decode("utf-8-sig"), filename=str(path)) + defined_names = { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + for hook in PLUGIN_HOOKS: + if hook in defined_names: + hook_files[hook].add(relative) + return { + "schema_version": 2, + "source": { + "repository": "MoviePilot-Plugins", + "head": git_head(plugin_repo), + "roots": [root.name for root in roots], + "python_file_count": len(paths), + "source_sha256": digest.hexdigest(), + }, + "imports": { + module: { + "file_count": len(files), + "files": sorted(files), + } + for module, files in sorted(import_files.items()) + }, + "hooks": { + hook: { + "file_count": len(hook_files.get(hook, set())), + "files": sorted(hook_files.get(hook, set())), + } + for hook in PLUGIN_HOOKS + }, + "api_routes": dict(sorted(api_contracts.items())), + } + + +def write_json(path: Path, value: dict[str, Any]) -> None: + """以稳定格式写入生成基线。""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def check_json(path: Path, actual: dict[str, Any]) -> bool: + """比较当前扫描结果和已提交基线并输出可执行提示。""" + expected = json.loads(path.read_text(encoding="utf-8")) + if expected == actual: + return True + print( + f"架构基线已变化:{path.relative_to(PROJECT_ROOT)};" + "确认变更符合边界后运行 scripts/architecture/baseline.py --write", + file=sys.stderr, + ) + return False + + +def parse_args() -> argparse.Namespace: + """解析基线写入、校验和外部插件仓参数。""" + parser = argparse.ArgumentParser(description=__doc__) + action = parser.add_mutually_exclusive_group(required=True) + action.add_argument("--write", action="store_true", help="写入当前架构基线") + action.add_argument("--check", action="store_true", help="校验当前架构基线") + parser.add_argument( + "--plugin-repo", + type=Path, + help="可选的独立 MoviePilot-Plugins 仓路径", + ) + return parser.parse_args() + + +def main() -> int: + """执行本仓基线以及可选官方插件基线的写入或校验。""" + args = parse_args() + baselines = [ + (DEPENDENCY_BASELINE_PATH, collect_dependency_baseline()), + (RUNTIME_BASELINE_PATH, collect_runtime_baseline()), + ] + if args.plugin_repo: + plugin_repo = args.plugin_repo.resolve() + if not plugin_repo.is_dir(): + raise SystemExit(f"插件仓不存在:{plugin_repo}") + baselines.append( + (PLUGIN_BASELINE_PATH, collect_official_plugin_baseline(plugin_repo)) + ) + if args.write: + for path, baseline in baselines: + write_json(path, baseline) + print(f"已写入 {path.relative_to(PROJECT_ROOT)}") + return 0 + checks = [check_json(path, baseline) for path, baseline in baselines] + return 0 if all(checks) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/schema/exports.py b/scripts/schema/exports.py new file mode 100644 index 000000000..54b976b7d --- /dev/null +++ b/scripts/schema/exports.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""生成并校验 ``app.schemas`` 根入口的惰性导出清单。""" + +import argparse +import importlib +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) +OUTPUT_PATH = PROJECT_ROOT / "app" / "schemas" / "exports.py" +SCHEMA_MODULES = ( + "agent", + "cache", + "category", + "common", + "context", + "dashboard", + "download", + "event", + "exception", + "file", + "history", + "llm", + "mediaserver", + "message", + "mfa", + "music", + "monitoring", + "notification", + "plugin", + "response", + "rule", + "search", + "storage", + "openai", + "servarr", + "servcookie", + "site", + "subscribe", + "system", + "tmdb", + "token", + "transfer", + "user", + "workflow", + "mcp", +) + + +def collect_exports() -> tuple[dict[str, tuple[str, str]], dict[str, list[str]]]: + """按旧星号导入顺序收集最终导出所有者和重名来源。""" + exports: dict[str, tuple[str, str]] = {} + sources: dict[str, list[str]] = {} + for module_basename in SCHEMA_MODULES: + module_name = f"app.schemas.{module_basename}" + module = importlib.import_module(module_name) + names = getattr(module, "__all__", None) + if names is None: + names = [name for name in vars(module) if not name.startswith("_")] + for name in names: + if not hasattr(module, name): + continue + exports[name] = (module_name, name) + sources.setdefault(name, []).append(module_name) + conflicts = { + name: module_names + for name, module_names in sources.items() + if len(set(module_names)) > 1 + } + return dict(sorted(exports.items())), dict(sorted(conflicts.items())) + + +def render_manifest() -> str: + """把导出与冲突清单渲染为稳定、可审查的 Python 模块。""" + exports, conflicts = collect_exports() + lines = [ + '"""由 scripts/schema/exports.py 生成,请勿手工编辑。"""', + "", + "SCHEMA_EXPORTS = {", + ] + lines.extend( + f" {name!r}: ({module_name!r}, {symbol_name!r})," + for name, (module_name, symbol_name) in exports.items() + ) + lines.extend(["}", "", "SCHEMA_CONFLICTS = {"]) + lines.extend( + f" {name!r}: {module_names!r}," + for name, module_names in conflicts.items() + ) + lines.extend(["}", ""]) + return "\n".join(lines) + + +def parse_args() -> argparse.Namespace: + """解析写入或校验动作。""" + parser = argparse.ArgumentParser(description=__doc__) + action = parser.add_mutually_exclusive_group(required=True) + action.add_argument("--write", action="store_true") + action.add_argument("--check", action="store_true") + return parser.parse_args() + + +def main() -> int: + """写入清单,或检查当前 schema 公开面是否发生漂移。""" + args = parse_args() + rendered = render_manifest() + if args.write: + OUTPUT_PATH.write_text(rendered, encoding="utf-8") + print(f"已写入 {OUTPUT_PATH.relative_to(PROJECT_ROOT)}") + return 0 + current = OUTPUT_PATH.read_text(encoding="utf-8") + if current == rendered: + return 0 + print( + "schema 导出清单已变化;确认兼容性后运行 " + "scripts/schema/exports.py --write", + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/startup/performance.py b/scripts/startup/performance.py new file mode 100644 index 000000000..0c8791d56 --- /dev/null +++ b/scripts/startup/performance.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""记录 MoviePilot 关键入口的冷导入耗时基线。""" + +import argparse +import json +import os +import platform +import statistics +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_OUTPUT = ( + PROJECT_ROOT + / "tests" + / "fixtures" + / "architecture" + / "startup-performance-baseline.json" +) +IMPORT_TARGETS = ( + "app.startup.lifecycle", + "app.factory", + "app.main", +) +RESULT_PREFIX = "MOVIEPILOT_IMPORT_BASELINE=" +LIFECYCLE_RESULT_PREFIX = "MOVIEPILOT_LIFECYCLE_BASELINE=" + + +def measure_import(target: str) -> dict[str, Any]: + """在独立解释器中测量单个模块的冷导入耗时与模块增量。""" + code = f""" +import importlib +import json +import sys +import time + +before = set(sys.modules) +started_at = time.perf_counter() +importlib.import_module({target!r}) +elapsed_ms = (time.perf_counter() - started_at) * 1000 +print({RESULT_PREFIX!r} + json.dumps({{ + 'elapsed_ms': elapsed_ms, + 'loaded_module_count': len(set(sys.modules) - before), +}})) +""" + environment = os.environ.copy() + environment["PYTHONHASHSEED"] = "0" + result = subprocess.run( + [sys.executable, "-c", code], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"冷导入 {target} 失败:{result.stderr.strip() or result.stdout.strip()}" + ) + payload_line = next( + ( + line + for line in reversed(result.stdout.splitlines()) + if line.startswith(RESULT_PREFIX) + ), + None, + ) + if payload_line is None: + raise RuntimeError(f"冷导入 {target} 未输出测量结果") + return json.loads(payload_line.removeprefix(RESULT_PREFIX)) + + +def measure_lifecycle(safe_mode: bool) -> dict[str, Any]: + """在隔离的无 I/O 生命周期中测量正常/安全模式编排和资源增量。 + + 这里故意把每个组件回调替换为 no-op:基线用于比较生命周期编排、阶段计时、任务 + 和线程是否泄漏,不应在生成基线时启动真实插件、调度器或连接用户数据库。 + """ + code = f""" +import asyncio +import dataclasses +import json +import threading +import time + +from fastapi import FastAPI + +from app.testing.bootstrap import ensure_sites_stub + +ensure_sites_stub() +from app.startup import lifecycle + + +def _noop(): + return None + + +async def _async_noop(): + return None + + +async def _probe(): + lifecycle.settings.MOVIEPILOT_SAFE_MODE = {safe_mode!r} + lifecycle.init_extra = _async_noop + lifecycle.global_vars.set_loop = lambda loop: None + lifecycle.global_vars.stop_system = lambda: None + lifecycle.LoggerManager.shutdown = lambda: None + original_components = lifecycle.build_lifecycle_components(FastAPI()) + isolated_components = tuple( + dataclasses.replace( + component, + start=_noop if component.start is not None else None, + stop=_noop if component.stop is not None else None, + ) + for component in original_components + ) + lifecycle.build_lifecycle_components = lambda _app: isolated_components + stage_ms = {{}} + original_step = lifecycle.run_startup_step + + async def timed_step(name, callback, timeout_seconds=None): + started = time.perf_counter() + result = await original_step(name, callback, timeout_seconds) + stage_ms[name] = round((time.perf_counter() - started) * 1000, 3) + return result + + lifecycle.run_startup_step = timed_step + before_threads = threading.active_count() + before_tasks = len(asyncio.all_tasks()) + started = time.perf_counter() + async with lifecycle.lifespan(FastAPI()): + startup_ms = (time.perf_counter() - started) * 1000 + started_threads = threading.active_count() + started_tasks = len(asyncio.all_tasks()) + finished_ms = (time.perf_counter() - started) * 1000 + print({LIFECYCLE_RESULT_PREFIX!r} + json.dumps({{ + 'mode': 'safe' if {safe_mode!r} else 'normal', + 'enabled_component_count': len([ + component for component in isolated_components + if component.enabled({safe_mode!r}) + ]), + 'startup_ms': round(startup_ms, 3), + 'full_lifespan_ms': round(finished_ms, 3), + 'stage_ms': stage_ms, + 'threads_before': before_threads, + 'threads_started': started_threads, + 'threads_after': threading.active_count(), + 'tasks_before': before_tasks, + 'tasks_started': started_tasks, + 'tasks_after': len(asyncio.all_tasks()), + # no-op 采样不建立数据库连接;字段显式记录采样范围,避免误读为生产连接数。 + 'database_connections_started': 0, + }})) + + +asyncio.run(_probe()) +""" + result = subprocess.run( + [sys.executable, "-c", code], + cwd=PROJECT_ROOT, + env={**os.environ, "PYTHONHASHSEED": "0"}, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"{('安全' if safe_mode else '正常')}模式生命周期采样失败:" + f"{result.stderr.strip() or result.stdout.strip()}" + ) + payload_line = next( + ( + line + for line in reversed(result.stdout.splitlines()) + if line.startswith(LIFECYCLE_RESULT_PREFIX) + ), + None, + ) + if payload_line is None: + raise RuntimeError("生命周期采样未输出测量结果") + return json.loads(payload_line.removeprefix(LIFECYCLE_RESULT_PREFIX)) + + +def collect_baseline(repeat: int) -> dict[str, Any]: + """按目标重复采样并生成便于后续对比的统计摘要。""" + targets: dict[str, Any] = {} + for target in IMPORT_TARGETS: + samples = [measure_import(target) for _ in range(repeat)] + elapsed = [sample["elapsed_ms"] for sample in samples] + targets[target] = { + "loaded_module_count": int( + statistics.median( + sample["loaded_module_count"] for sample in samples + ) + ), + "max_ms": round(max(elapsed), 3), + "median_ms": round(statistics.median(elapsed), 3), + "min_ms": round(min(elapsed), 3), + "samples_ms": [round(value, 3) for value in elapsed], + } + lifecycle_modes: dict[str, Any] = {} + for safe_mode, mode_name in ((False, "normal"), (True, "safe")): + samples = [measure_lifecycle(safe_mode) for _ in range(repeat)] + lifecycle_modes[mode_name] = { + "samples": samples, + "median_startup_ms": round( + statistics.median(sample["startup_ms"] for sample in samples), + 3, + ), + "median_full_lifespan_ms": round( + statistics.median(sample["full_lifespan_ms"] for sample in samples), + 3, + ), + "enabled_component_count": samples[0]["enabled_component_count"], + } + return { + "schema_version": 1, + "generated_at": datetime.now(timezone.utc).isoformat(), + "platform": platform.platform(), + "python": platform.python_version(), + "repeat": repeat, + "targets": targets, + "lifecycle": { + "scope": "isolated no-op component callbacks; no plugin/network/database I/O", + "modes": lifecycle_modes, + }, + } + + +def parse_args() -> argparse.Namespace: + """解析输出路径和采样次数。""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repeat", type=int, default=3) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + return parser.parse_args() + + +def main() -> int: + """执行冷导入采样并写入 JSON 基线。""" + args = parse_args() + if args.repeat < 1: + raise SystemExit("--repeat 必须大于等于 1") + output = args.output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(collect_baseline(args.repeat), ensure_ascii=False, indent=2) + + "\n", + encoding="utf-8", + ) + try: + display_path = output.relative_to(PROJECT_ROOT) + except ValueError: + display_path = output + print(f"已写入 {display_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index d06fb9630..08958f3f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,37 @@ prepare_backend() from app.testing.network_guard import block_real_network # noqa: E402,F401 +@pytest.fixture(autouse=True) +def configure_plugin_system_services(): + """为绕过完整启动流程的单元测试装配真实插件系统适配器。""" + from app.adapters.external.market import ( + PluginHelper, + VERSION_BACKWARD_COMPATIBLE_FLAGS, + ) + from app.adapters.external.plugin.client import PluginMarketClient + from app.adapters.system.plugin.dependency import PluginDependencyInstaller + from app.adapters.system.plugin.package import PluginPackageManager + from app.runtime.extensions.plugin.system import ( + PluginSystemServices, + configure_plugin_system, + reset_plugin_system, + ) + + helper = PluginHelper() + configure_plugin_system(PluginSystemServices( + market=PluginMarketClient(helper), + package=PluginPackageManager(helper), + dependency=PluginDependencyInstaller(helper), + compatible_flags=lambda flag: ( + [flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, []) + if flag else [] + ), + frozen=lambda: False, + )) + yield + reset_plugin_system() + + class DbHarness: """真实数据库会话的测试载具。 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json new file mode 100644 index 000000000..e84633cdd --- /dev/null +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -0,0 +1,6930 @@ +{ + "boundary_edges": { + "adapters_to_db": [], + "api_endpoints_to_db_models": [ + "app.api.endpoints.agent -> app.db.models", + "app.api.endpoints.agent -> app.db.models.agentchat", + "app.api.endpoints.auth -> app.db.models", + "app.api.endpoints.auth -> app.db.models.passkey", + "app.api.endpoints.auth -> app.db.models.user", + "app.api.endpoints.dashboard -> app.db.models", + "app.api.endpoints.dashboard -> app.db.models.transferhistory", + "app.api.endpoints.download -> app.db.models", + "app.api.endpoints.download -> app.db.models.user", + "app.api.endpoints.history -> app.db.models", + "app.api.endpoints.history -> app.db.models.downloadhistory", + "app.api.endpoints.history -> app.db.models.transferhistory", + "app.api.endpoints.llm -> app.db.models", + "app.api.endpoints.media -> app.db.models", + "app.api.endpoints.mediaserver -> app.db.models", + "app.api.endpoints.message -> app.db.models", + "app.api.endpoints.mfa -> app.db.models", + "app.api.endpoints.mfa -> app.db.models.passkey", + "app.api.endpoints.mfa -> app.db.models.user", + "app.api.endpoints.music -> app.db.models", + "app.api.endpoints.music -> app.db.models.user", + "app.api.endpoints.notification -> app.db.models", + "app.api.endpoints.plugin -> app.db.models", + "app.api.endpoints.site -> app.db.models", + "app.api.endpoints.site -> app.db.models.site", + "app.api.endpoints.site -> app.db.models.siteicon", + "app.api.endpoints.site -> app.db.models.sitestatistic", + "app.api.endpoints.site -> app.db.models.siteuserdata", + "app.api.endpoints.storage -> app.db.models", + "app.api.endpoints.subscribe -> app.db.models", + "app.api.endpoints.subscribe -> app.db.models.subscribe", + "app.api.endpoints.subscribe -> app.db.models.subscribehistory", + "app.api.endpoints.subscribe -> app.db.models.user", + "app.api.endpoints.system -> app.db.models", + "app.api.endpoints.tmdb -> app.db.models", + "app.api.endpoints.tmdb -> app.db.models.user", + "app.api.endpoints.torrent -> app.db.models", + "app.api.endpoints.transfer -> app.db.models", + "app.api.endpoints.transfer -> app.db.models.transferhistory", + "app.api.endpoints.user -> app.db.models", + "app.api.endpoints.user -> app.db.models.user", + "app.api.endpoints.workflow -> app.db.models" + ], + "api_endpoints_to_sessions": [ + "app.api.endpoints.agent -> sqlalchemy.ext.asyncio.AsyncSession", + "app.api.endpoints.dashboard -> sqlalchemy.orm.Session", + "app.api.endpoints.history -> sqlalchemy.ext.asyncio.AsyncSession", + "app.api.endpoints.history -> sqlalchemy.orm.Session", + "app.api.endpoints.mediaserver -> sqlalchemy.ext.asyncio.AsyncSession", + "app.api.endpoints.message -> sqlalchemy.ext.asyncio.AsyncSession", + "app.api.endpoints.mfa -> sqlalchemy.ext.asyncio.AsyncSession", + "app.api.endpoints.site -> sqlalchemy.ext.asyncio.AsyncSession", + "app.api.endpoints.site -> sqlalchemy.orm.Session", + "app.api.endpoints.subscribe -> sqlalchemy.ext.asyncio.AsyncSession", + "app.api.endpoints.subscribe -> sqlalchemy.orm.Session", + "app.api.endpoints.transfer -> sqlalchemy.orm.Session", + "app.api.endpoints.user -> sqlalchemy.ext.asyncio.AsyncSession", + "app.api.endpoints.workflow -> sqlalchemy.ext.asyncio.AsyncSession" + ], + "application_to_agent": [ + "app.application.messaging.skill -> app.agent", + "app.application.messaging.skill -> app.agent.skills", + "app.application.messaging.skill -> app.agent.skills.registry" + ], + "runtime_to_db": [] + }, + "edge_count": 6071, + "edge_sha256": "3b582e5a31f143d33056f97803f81b7df3452811c5ca64c50555d3479ea0c340", + "edges": [ + "app -> app.runtime", + "app -> app.runtime.compat", + "app -> app.runtime.compat.imports", + "app.adapters.cache.backends -> app.adapters", + "app.adapters.cache.backends -> app.adapters.cache", + "app.adapters.cache.backends -> app.adapters.cache.redis", + "app.adapters.cache.backends -> app.runtime", + "app.adapters.cache.backends -> app.runtime.cache", + "app.adapters.cache.backends -> app.runtime.config", + "app.adapters.cache.redis -> app.foundation", + "app.adapters.cache.redis -> app.foundation.singleton", + "app.adapters.cache.redis -> app.runtime", + "app.adapters.cache.redis -> app.runtime.config", + "app.adapters.cache.redis -> app.runtime.log", + "app.adapters.cache.redis -> app.runtime.reload", + "app.adapters.external.cookiecloud -> app.adapters", + "app.adapters.external.cookiecloud -> app.adapters.network", + "app.adapters.external.cookiecloud -> app.adapters.network.http", + "app.adapters.external.cookiecloud -> app.domain", + "app.adapters.external.cookiecloud -> app.domain.site", + "app.adapters.external.cookiecloud -> app.foundation", + "app.adapters.external.cookiecloud -> app.foundation.crypto", + "app.adapters.external.cookiecloud -> app.foundation.text", + "app.adapters.external.cookiecloud -> app.foundation.url", + "app.adapters.external.cookiecloud -> app.runtime", + "app.adapters.external.cookiecloud -> app.runtime.config", + "app.adapters.external.cookiecloud -> app.runtime.log", + "app.adapters.external.location -> app.adapters", + "app.adapters.external.location -> app.adapters.network", + "app.adapters.external.location -> app.adapters.network.http", + "app.adapters.external.market -> app.adapters", + "app.adapters.external.market -> app.adapters.network", + "app.adapters.external.market -> app.adapters.network.http", + "app.adapters.external.market -> app.adapters.system", + "app.adapters.external.market -> app.adapters.system.host", + "app.adapters.external.market -> app.adapters.system.package", + "app.adapters.external.market -> app.foundation", + "app.adapters.external.market -> app.foundation.singleton", + "app.adapters.external.market -> app.foundation.url", + "app.adapters.external.market -> app.foundation.version", + "app.adapters.external.market -> app.runtime", + "app.adapters.external.market -> app.runtime.cache", + "app.adapters.external.market -> app.runtime.config", + "app.adapters.external.market -> app.runtime.log", + "app.adapters.external.ocr -> app.adapters", + "app.adapters.external.ocr -> app.adapters.network", + "app.adapters.external.ocr -> app.adapters.network.http", + "app.adapters.external.ocr -> app.runtime", + "app.adapters.external.ocr -> app.runtime.config", + "app.adapters.external.plugin.client -> app.adapters", + "app.adapters.external.plugin.client -> app.adapters.external", + "app.adapters.external.plugin.client -> app.adapters.external.market", + "app.adapters.external.plugin.client -> app.runtime", + "app.adapters.external.plugin.client -> app.runtime.cache", + "app.adapters.external.server -> app.adapters", + "app.adapters.external.server -> app.adapters.network", + "app.adapters.external.server -> app.adapters.network.http", + "app.adapters.external.server -> app.adapters.system", + "app.adapters.external.server -> app.adapters.system.host", + "app.adapters.external.server -> app.domain", + "app.adapters.external.server -> app.domain.context", + "app.adapters.external.server -> app.domain.media", + "app.adapters.external.server -> app.domain.meta", + "app.adapters.external.server -> app.domain.meta.metabase", + "app.adapters.external.server -> app.runtime", + "app.adapters.external.server -> app.runtime.cache", + "app.adapters.external.server -> app.runtime.config", + "app.adapters.external.server -> app.runtime.log", + "app.adapters.external.server -> app.schemas", + "app.adapters.external.server -> app.schemas.media", + "app.adapters.external.server -> app.schemas.types", + "app.adapters.network.browser -> app.adapters", + "app.adapters.network.browser -> app.adapters.network", + "app.adapters.network.browser -> app.adapters.network.http", + "app.adapters.network.browser -> app.runtime", + "app.adapters.network.browser -> app.runtime.config", + "app.adapters.network.browser -> app.runtime.log", + "app.adapters.network.browser -> app.runtime.managed_resources", + "app.adapters.network.cloudflare -> app.runtime", + "app.adapters.network.cloudflare -> app.runtime.log", + "app.adapters.network.doh -> app.foundation", + "app.adapters.network.doh -> app.foundation.singleton", + "app.adapters.network.doh -> app.runtime", + "app.adapters.network.doh -> app.runtime.config", + "app.adapters.network.doh -> app.runtime.log", + "app.adapters.network.doh -> app.runtime.reload", + "app.adapters.system.display -> app.foundation", + "app.adapters.system.display -> app.foundation.singleton", + "app.adapters.system.display -> app.runtime", + "app.adapters.system.display -> app.runtime.log", + "app.adapters.system.display -> app.runtime.managed_resources", + "app.adapters.system.display.resource -> app.adapters", + "app.adapters.system.display.resource -> app.adapters.system", + "app.adapters.system.display.resource -> app.adapters.system.host", + "app.adapters.system.display.resource -> app.runtime", + "app.adapters.system.display.resource -> app.runtime.log", + "app.adapters.system.fsproxy -> app.runtime", + "app.adapters.system.fsproxy -> app.runtime.config", + "app.adapters.system.fsproxy -> app.runtime.log", + "app.adapters.system.host -> app.schemas", + "app.adapters.system.host -> app.schemas.dashboard", + "app.adapters.system.plugin.dependency -> app.adapters", + "app.adapters.system.plugin.dependency -> app.adapters.external", + "app.adapters.system.plugin.dependency -> app.adapters.external.market", + "app.adapters.system.plugin.dependency -> app.runtime", + "app.adapters.system.plugin.dependency -> app.runtime.config", + "app.adapters.system.plugin.dependency -> app.runtime.log", + "app.adapters.system.plugin.package -> app.adapters", + "app.adapters.system.plugin.package -> app.adapters.external", + "app.adapters.system.plugin.package -> app.adapters.external.market", + "app.adapters.system.plugin.package -> app.runtime", + "app.adapters.system.plugin.package -> app.runtime.config", + "app.adapters.system.plugin.package -> app.runtime.log", + "app.adapters.system.resource -> app.adapters", + "app.adapters.system.resource -> app.adapters.network", + "app.adapters.system.resource -> app.adapters.network.http", + "app.adapters.system.resource -> app.adapters.system", + "app.adapters.system.resource -> app.adapters.system.host", + "app.adapters.system.resource -> app.foundation", + "app.adapters.system.resource -> app.foundation.version", + "app.adapters.system.resource -> app.runtime", + "app.adapters.system.resource -> app.runtime.config", + "app.adapters.system.resource -> app.runtime.log", + "app.adapters.system.rust -> app.runtime", + "app.adapters.system.rust -> app.runtime.config", + "app.adapters.system.rust -> app.runtime.log", + "app.agent.callback -> app.agent", + "app.agent.callback -> app.agent.policy", + "app.agent.callback -> app.chain", + "app.agent.callback -> app.runtime", + "app.agent.callback -> app.runtime.log", + "app.agent.callback -> app.schemas", + "app.agent.callback -> app.schemas.message", + "app.agent.callback -> app.schemas.types", + "app.agent.capabilities.adapter -> app.agent", + "app.agent.capabilities.adapter -> app.agent.capabilities", + "app.agent.capabilities.adapter -> app.runtime", + "app.agent.capabilities.adapter -> app.runtime.capabilities", + "app.agent.capabilities.adapter -> app.runtime.capabilities.errors", + "app.agent.capabilities.adapter -> app.runtime.capabilities.model", + "app.agent.capabilities.adapter -> app.runtime.capabilities.registry", + "app.agent.capabilities.adapter -> app.runtime.config", + "app.agent.contracts -> app.schemas", + "app.agent.contracts -> app.schemas.types", + "app.agent.llm -> app.agent", + "app.agent.llm -> app.agent.llm.capability", + "app.agent.llm -> app.agent.llm.helper", + "app.agent.llm -> app.agent.llm.provider", + "app.agent.llm.capability -> app.adapters", + "app.agent.llm.capability -> app.adapters.network", + "app.agent.llm.capability -> app.adapters.network.http", + "app.agent.llm.capability -> app.agent", + "app.agent.llm.capability -> app.agent.llm", + "app.agent.llm.capability -> app.agent.llm.helper", + "app.agent.llm.capability -> app.runtime", + "app.agent.llm.capability -> app.runtime.config", + "app.agent.llm.capability -> app.runtime.extensions", + "app.agent.llm.capability -> app.runtime.extensions.service_registry", + "app.agent.llm.capability -> app.runtime.log", + "app.agent.llm.capability -> app.schemas", + "app.agent.llm.capability -> app.schemas.notification", + "app.agent.llm.capability -> app.schemas.types", + "app.agent.llm.helper -> app.agent", + "app.agent.llm.helper -> app.agent.llm", + "app.agent.llm.helper -> app.agent.llm.provider", + "app.agent.llm.helper -> app.agent.llm.server_tools", + "app.agent.llm.helper -> app.runtime", + "app.agent.llm.helper -> app.runtime.config", + "app.agent.llm.helper -> app.runtime.log", + "app.agent.llm.provider -> app.agent", + "app.agent.llm.provider -> app.agent.llm", + "app.agent.llm.provider -> app.agent.llm.helper", + "app.agent.llm.provider -> app.db", + "app.agent.llm.provider -> app.db.oper", + "app.agent.llm.provider -> app.db.oper.systemconfig", + "app.agent.llm.provider -> app.foundation", + "app.agent.llm.provider -> app.foundation.singleton", + "app.agent.llm.provider -> app.runtime", + "app.agent.llm.provider -> app.runtime.config", + "app.agent.llm.provider -> app.runtime.log", + "app.agent.llm.provider -> app.schemas", + "app.agent.llm.provider -> app.schemas.types", + "app.agent.mcp -> app.adapters", + "app.agent.mcp -> app.adapters.network", + "app.agent.mcp -> app.adapters.network.http", + "app.agent.mcp -> app.db", + "app.agent.mcp -> app.db.oper", + "app.agent.mcp -> app.db.oper.systemconfig", + "app.agent.mcp -> app.runtime", + "app.agent.mcp -> app.runtime.log", + "app.agent.mcp -> app.schemas", + "app.agent.mcp -> app.schemas.agent", + "app.agent.mcp -> app.schemas.types", + "app.agent.memory -> app.db", + "app.agent.memory -> app.db.oper", + "app.agent.memory -> app.db.oper.agentchat", + "app.agent.memory -> app.runtime", + "app.agent.memory -> app.runtime.config", + "app.agent.memory -> app.runtime.log", + "app.agent.memory -> app.schemas", + "app.agent.memory -> app.schemas.agent", + "app.agent.middleware.activity_log -> app.agent", + "app.agent.middleware.activity_log -> app.agent.llm", + "app.agent.middleware.activity_log -> app.agent.middleware", + "app.agent.middleware.activity_log -> app.agent.middleware.utils", + "app.agent.middleware.activity_log -> app.agent.policy", + "app.agent.middleware.activity_log -> app.agent.tools", + "app.agent.middleware.activity_log -> app.agent.tools.tags", + "app.agent.middleware.activity_log -> app.runtime", + "app.agent.middleware.activity_log -> app.runtime.log", + "app.agent.middleware.jobs -> app.agent", + "app.agent.middleware.jobs -> app.agent.middleware", + "app.agent.middleware.jobs -> app.agent.middleware.utils", + "app.agent.middleware.jobs -> app.runtime", + "app.agent.middleware.jobs -> app.runtime.log", + "app.agent.middleware.memory -> app.agent", + "app.agent.middleware.memory -> app.agent.middleware", + "app.agent.middleware.memory -> app.agent.middleware.utils", + "app.agent.middleware.memory -> app.runtime", + "app.agent.middleware.memory -> app.runtime.log", + "app.agent.middleware.policy -> app.agent", + "app.agent.middleware.policy -> app.agent.policy", + "app.agent.middleware.policy -> app.agent.tools", + "app.agent.middleware.policy -> app.agent.tools.catalog", + "app.agent.middleware.policy -> app.agent.tools.impl", + "app.agent.middleware.policy -> app.agent.tools.impl.query_system_settings", + "app.agent.middleware.runtime_config -> app.agent", + "app.agent.middleware.runtime_config -> app.agent.middleware", + "app.agent.middleware.runtime_config -> app.agent.middleware.utils", + "app.agent.middleware.runtime_config -> app.agent.runtime", + "app.agent.middleware.skills -> app.agent", + "app.agent.middleware.skills -> app.agent.middleware", + "app.agent.middleware.skills -> app.agent.middleware.utils", + "app.agent.middleware.skills -> app.agent.policy", + "app.agent.middleware.skills -> app.agent.skills", + "app.agent.middleware.skills -> app.agent.skills.metadata", + "app.agent.middleware.skills -> app.agent.tools", + "app.agent.middleware.skills -> app.agent.tools.tags", + "app.agent.middleware.skills -> app.runtime", + "app.agent.middleware.skills -> app.runtime.log", + "app.agent.middleware.subagents -> app.agent", + "app.agent.middleware.subagents -> app.agent.llm", + "app.agent.middleware.subagents -> app.agent.middleware", + "app.agent.middleware.subagents -> app.agent.middleware.policy", + "app.agent.middleware.subagents -> app.agent.middleware.utils", + "app.agent.middleware.subagents -> app.agent.policy", + "app.agent.middleware.subagents -> app.agent.runtime", + "app.agent.middleware.subagents -> app.agent.tools", + "app.agent.middleware.subagents -> app.agent.tools.catalog", + "app.agent.middleware.subagents -> app.agent.tools.tags", + "app.agent.middleware.subagents -> app.runtime", + "app.agent.middleware.subagents -> app.runtime.log", + "app.agent.middleware.summarization -> app.agent", + "app.agent.middleware.summarization -> app.agent.middleware", + "app.agent.middleware.summarization -> app.agent.middleware.usage", + "app.agent.middleware.summarization -> app.runtime", + "app.agent.middleware.summarization -> app.runtime.log", + "app.agent.middleware.tool_selection -> app.agent", + "app.agent.middleware.tool_selection -> app.agent.llm", + "app.agent.middleware.tool_selection -> app.agent.tools", + "app.agent.middleware.tool_selection -> app.agent.tools.tags", + "app.agent.middleware.tool_selection -> app.runtime", + "app.agent.middleware.tool_selection -> app.runtime.log", + "app.agent.middleware.usage -> app.runtime", + "app.agent.middleware.usage -> app.runtime.log", + "app.agent.orchestrator -> app.agent", + "app.agent.orchestrator -> app.agent.callback", + "app.agent.orchestrator -> app.agent.contracts", + "app.agent.orchestrator -> app.agent.llm", + "app.agent.orchestrator -> app.agent.llm.server_tools", + "app.agent.orchestrator -> app.agent.mcp", + "app.agent.orchestrator -> app.agent.memory", + "app.agent.orchestrator -> app.agent.middleware", + "app.agent.orchestrator -> app.agent.middleware.activity_log", + "app.agent.orchestrator -> app.agent.middleware.jobs", + "app.agent.orchestrator -> app.agent.middleware.memory", + "app.agent.orchestrator -> app.agent.middleware.patch_tool_calls", + "app.agent.orchestrator -> app.agent.middleware.policy", + "app.agent.orchestrator -> app.agent.middleware.runtime_config", + "app.agent.orchestrator -> app.agent.middleware.skills", + "app.agent.orchestrator -> app.agent.middleware.subagents", + "app.agent.orchestrator -> app.agent.middleware.summarization", + "app.agent.orchestrator -> app.agent.middleware.tool_selection", + "app.agent.orchestrator -> app.agent.middleware.usage", + "app.agent.orchestrator -> app.agent.policy", + "app.agent.orchestrator -> app.agent.prompt", + "app.agent.orchestrator -> app.agent.runtime", + "app.agent.orchestrator -> app.agent.runtime_loader", + "app.agent.orchestrator -> app.agent.tools", + "app.agent.orchestrator -> app.agent.tools.catalog", + "app.agent.orchestrator -> app.agent.tools.impl", + "app.agent.orchestrator -> app.agent.tools.impl.mcp", + "app.agent.orchestrator -> app.agent.tools.impl.query_system_settings", + "app.agent.orchestrator -> app.chain", + "app.agent.orchestrator -> app.chain.agent", + "app.agent.orchestrator -> app.db", + "app.agent.orchestrator -> app.db.oper", + "app.agent.orchestrator -> app.db.oper.agentchat", + "app.agent.orchestrator -> app.db.oper.agenttask", + "app.agent.orchestrator -> app.db.oper.user", + "app.agent.orchestrator -> app.foundation", + "app.agent.orchestrator -> app.foundation.identity", + "app.agent.orchestrator -> app.runtime", + "app.agent.orchestrator -> app.runtime.config", + "app.agent.orchestrator -> app.runtime.events", + "app.agent.orchestrator -> app.runtime.extensions", + "app.agent.orchestrator -> app.runtime.extensions.plugin_manager", + "app.agent.orchestrator -> app.runtime.log", + "app.agent.orchestrator -> app.schemas", + "app.agent.orchestrator -> app.schemas.event", + "app.agent.orchestrator -> app.schemas.message", + "app.agent.orchestrator -> app.schemas.notification", + "app.agent.orchestrator -> app.schemas.types", + "app.agent.policy -> app.agent", + "app.agent.policy -> app.agent.policy.contracts", + "app.agent.policy -> app.agent.policy.orchestrator", + "app.agent.policy -> app.agent.policy.registry", + "app.agent.policy -> app.agent.policy.sanitizer", + "app.agent.policy.orchestrator -> app.agent", + "app.agent.policy.orchestrator -> app.agent.policy", + "app.agent.policy.orchestrator -> app.agent.policy.contracts", + "app.agent.policy.orchestrator -> app.agent.policy.registry", + "app.agent.policy.orchestrator -> app.agent.policy.sanitizer", + "app.agent.policy.orchestrator -> app.runtime", + "app.agent.policy.orchestrator -> app.runtime.log", + "app.agent.policy.registry -> app.agent", + "app.agent.policy.registry -> app.agent.policy", + "app.agent.policy.registry -> app.agent.policy.contracts", + "app.agent.policy.sanitizer -> app.agent", + "app.agent.policy.sanitizer -> app.agent.policy", + "app.agent.policy.sanitizer -> app.agent.policy.secret_fields", + "app.agent.prompt -> app.adapters", + "app.agent.prompt -> app.adapters.system", + "app.agent.prompt -> app.adapters.system.host", + "app.agent.prompt -> app.agent", + "app.agent.prompt -> app.agent.llm", + "app.agent.prompt -> app.agent.llm.capability", + "app.agent.prompt -> app.runtime", + "app.agent.prompt -> app.runtime.config", + "app.agent.prompt -> app.runtime.log", + "app.agent.prompt -> app.schemas", + "app.agent.prompt -> app.schemas.notification", + "app.agent.prompt.transfer_redo -> app.agent", + "app.agent.prompt.transfer_redo -> app.agent.prompt", + "app.agent.runtime -> app.runtime", + "app.agent.runtime -> app.runtime.config", + "app.agent.runtime -> app.runtime.log", + "app.agent.runtime_loader -> app.agent", + "app.agent.runtime_loader -> app.agent.capabilities", + "app.agent.runtime_loader -> app.agent.capabilities.adapter", + "app.agent.runtime_loader -> app.runtime", + "app.agent.runtime_loader -> app.runtime.capabilities", + "app.agent.runtime_loader -> app.runtime.capabilities.model", + "app.agent.runtime_loader -> app.runtime.capabilities.runtime", + "app.agent.skills.registry -> app.adapters", + "app.agent.skills.registry -> app.adapters.network", + "app.agent.skills.registry -> app.adapters.network.http", + "app.agent.skills.registry -> app.agent", + "app.agent.skills.registry -> app.agent.skills", + "app.agent.skills.registry -> app.agent.skills.metadata", + "app.agent.skills.registry -> app.foundation", + "app.agent.skills.registry -> app.foundation.singleton", + "app.agent.skills.registry -> app.foundation.url", + "app.agent.skills.registry -> app.runtime", + "app.agent.skills.registry -> app.runtime.cache", + "app.agent.skills.registry -> app.runtime.config", + "app.agent.skills.registry -> app.runtime.log", + "app.agent.tools.base -> app.agent", + "app.agent.tools.base -> app.agent.callback", + "app.agent.tools.base -> app.agent.policy", + "app.agent.tools.base -> app.agent.policy.sanitizer", + "app.agent.tools.base -> app.agent.tools", + "app.agent.tools.base -> app.agent.tools.tags", + "app.agent.tools.base -> app.application", + "app.agent.tools.base -> app.application.messaging", + "app.agent.tools.base -> app.application.messaging.agent", + "app.agent.tools.base -> app.chain", + "app.agent.tools.base -> app.runtime", + "app.agent.tools.base -> app.runtime.config", + "app.agent.tools.base -> app.runtime.extensions", + "app.agent.tools.base -> app.runtime.extensions.service_registry", + "app.agent.tools.base -> app.runtime.log", + "app.agent.tools.base -> app.schemas", + "app.agent.tools.base -> app.schemas.message", + "app.agent.tools.base -> app.schemas.types", + "app.agent.tools.catalog -> app.agent", + "app.agent.tools.catalog -> app.agent.policy", + "app.agent.tools.factory -> app.agent", + "app.agent.tools.factory -> app.agent.llm", + "app.agent.tools.factory -> app.agent.llm.capability", + "app.agent.tools.factory -> app.agent.tools", + "app.agent.tools.factory -> app.agent.tools.base", + "app.agent.tools.factory -> app.agent.tools.catalog", + "app.agent.tools.factory -> app.agent.tools.impl", + "app.agent.tools.factory -> app.agent.tools.impl.add_custom_filter_rule", + "app.agent.tools.factory -> app.agent.tools.impl.add_download_tasks", + "app.agent.tools.factory -> app.agent.tools.impl.add_rule_group", + "app.agent.tools.factory -> app.agent.tools.impl.add_subscribe", + "app.agent.tools.factory -> app.agent.tools.impl.apply_patch", + "app.agent.tools.factory -> app.agent.tools.impl.ask_user_choice", + "app.agent.tools.factory -> app.agent.tools.impl.browse_webpage", + "app.agent.tools.factory -> app.agent.tools.impl.create_agent_task", + "app.agent.tools.factory -> app.agent.tools.impl.delete_agent_task", + "app.agent.tools.factory -> app.agent.tools.impl.delete_custom_filter_rule", + "app.agent.tools.factory -> app.agent.tools.impl.delete_download_history", + "app.agent.tools.factory -> app.agent.tools.impl.delete_download_tasks", + "app.agent.tools.factory -> app.agent.tools.impl.delete_rule_group", + "app.agent.tools.factory -> app.agent.tools.impl.delete_subscribe", + "app.agent.tools.factory -> app.agent.tools.impl.delete_transfer_history", + "app.agent.tools.factory -> app.agent.tools.impl.edit_file", + "app.agent.tools.factory -> app.agent.tools.impl.execute_command", + "app.agent.tools.factory -> app.agent.tools.impl.get_recommendations", + "app.agent.tools.factory -> app.agent.tools.impl.get_search_results", + "app.agent.tools.factory -> app.agent.tools.impl.install_plugin", + "app.agent.tools.factory -> app.agent.tools.impl.list_directory", + "app.agent.tools.factory -> app.agent.tools.impl.list_slash_commands", + "app.agent.tools.factory -> app.agent.tools.impl.query_agent_tasks", + "app.agent.tools.factory -> app.agent.tools.impl.query_builtin_filter_rules", + "app.agent.tools.factory -> app.agent.tools.impl.query_custom_filter_rules", + "app.agent.tools.factory -> app.agent.tools.impl.query_custom_identifiers", + "app.agent.tools.factory -> app.agent.tools.impl.query_directory_settings", + "app.agent.tools.factory -> app.agent.tools.impl.query_doctor_report", + "app.agent.tools.factory -> app.agent.tools.impl.query_download_tasks", + "app.agent.tools.factory -> app.agent.tools.impl.query_downloaders", + "app.agent.tools.factory -> app.agent.tools.impl.query_episode_schedule", + "app.agent.tools.factory -> app.agent.tools.impl.query_installed_plugins", + "app.agent.tools.factory -> app.agent.tools.impl.query_library_exists", + "app.agent.tools.factory -> app.agent.tools.impl.query_library_latest", + "app.agent.tools.factory -> app.agent.tools.impl.query_market_plugins", + "app.agent.tools.factory -> app.agent.tools.impl.query_media_detail", + "app.agent.tools.factory -> app.agent.tools.impl.query_personas", + "app.agent.tools.factory -> app.agent.tools.impl.query_plugin_capabilities", + "app.agent.tools.factory -> app.agent.tools.impl.query_plugin_config", + "app.agent.tools.factory -> app.agent.tools.impl.query_plugin_data", + "app.agent.tools.factory -> app.agent.tools.impl.query_popular_subscribes", + "app.agent.tools.factory -> app.agent.tools.impl.query_rule_groups", + "app.agent.tools.factory -> app.agent.tools.impl.query_schedulers", + "app.agent.tools.factory -> app.agent.tools.impl.query_site_userdata", + "app.agent.tools.factory -> app.agent.tools.impl.query_sites", + "app.agent.tools.factory -> app.agent.tools.impl.query_subscribe_history", + "app.agent.tools.factory -> app.agent.tools.impl.query_subscribe_shares", + "app.agent.tools.factory -> app.agent.tools.impl.query_subscribes", + "app.agent.tools.factory -> app.agent.tools.impl.query_system_settings", + "app.agent.tools.factory -> app.agent.tools.impl.query_transfer_history", + "app.agent.tools.factory -> app.agent.tools.impl.query_workflows", + "app.agent.tools.factory -> app.agent.tools.impl.read_file", + "app.agent.tools.factory -> app.agent.tools.impl.recognize_captcha", + "app.agent.tools.factory -> app.agent.tools.impl.recognize_media", + "app.agent.tools.factory -> app.agent.tools.impl.reload_plugin", + "app.agent.tools.factory -> app.agent.tools.impl.run_agent_task", + "app.agent.tools.factory -> app.agent.tools.impl.run_scheduler", + "app.agent.tools.factory -> app.agent.tools.impl.run_slash_command", + "app.agent.tools.factory -> app.agent.tools.impl.run_workflow", + "app.agent.tools.factory -> app.agent.tools.impl.scrape_metadata", + "app.agent.tools.factory -> app.agent.tools.impl.search_media", + "app.agent.tools.factory -> app.agent.tools.impl.search_person", + "app.agent.tools.factory -> app.agent.tools.impl.search_person_credits", + "app.agent.tools.factory -> app.agent.tools.impl.search_subscribe", + "app.agent.tools.factory -> app.agent.tools.impl.search_torrents", + "app.agent.tools.factory -> app.agent.tools.impl.search_web", + "app.agent.tools.factory -> app.agent.tools.impl.send_local_file", + "app.agent.tools.factory -> app.agent.tools.impl.send_message", + "app.agent.tools.factory -> app.agent.tools.impl.send_voice_message", + "app.agent.tools.factory -> app.agent.tools.impl.switch_persona", + "app.agent.tools.factory -> app.agent.tools.impl.test_site", + "app.agent.tools.factory -> app.agent.tools.impl.transfer_file", + "app.agent.tools.factory -> app.agent.tools.impl.uninstall_plugin", + "app.agent.tools.factory -> app.agent.tools.impl.update_agent_task", + "app.agent.tools.factory -> app.agent.tools.impl.update_custom_filter_rule", + "app.agent.tools.factory -> app.agent.tools.impl.update_custom_identifiers", + "app.agent.tools.factory -> app.agent.tools.impl.update_download_tasks", + "app.agent.tools.factory -> app.agent.tools.impl.update_persona_definition", + "app.agent.tools.factory -> app.agent.tools.impl.update_plugin_config", + "app.agent.tools.factory -> app.agent.tools.impl.update_rule_group", + "app.agent.tools.factory -> app.agent.tools.impl.update_site", + "app.agent.tools.factory -> app.agent.tools.impl.update_site_cookie", + "app.agent.tools.factory -> app.agent.tools.impl.update_subscribe", + "app.agent.tools.factory -> app.agent.tools.impl.update_system_settings", + "app.agent.tools.factory -> app.agent.tools.impl.write_file", + "app.agent.tools.factory -> app.runtime", + "app.agent.tools.factory -> app.runtime.extensions", + "app.agent.tools.factory -> app.runtime.extensions.plugin_manager", + "app.agent.tools.factory -> app.runtime.log", + "app.agent.tools.factory -> app.schemas", + "app.agent.tools.factory -> app.schemas.notification", + "app.agent.tools.factory -> app.schemas.types", + "app.agent.tools.impl._filter_rule_utils -> app.application", + "app.agent.tools.impl._filter_rule_utils -> app.application.rules", + "app.agent.tools.impl._filter_rule_utils -> app.db", + "app.agent.tools.impl._filter_rule_utils -> app.db.oper", + "app.agent.tools.impl._filter_rule_utils -> app.db.oper.subscribe", + "app.agent.tools.impl._filter_rule_utils -> app.db.oper.systemconfig", + "app.agent.tools.impl._filter_rule_utils -> app.runtime", + "app.agent.tools.impl._filter_rule_utils -> app.runtime.events", + "app.agent.tools.impl._filter_rule_utils -> app.schemas", + "app.agent.tools.impl._filter_rule_utils -> app.schemas.event", + "app.agent.tools.impl._filter_rule_utils -> app.schemas.rule", + "app.agent.tools.impl._filter_rule_utils -> app.schemas.system", + "app.agent.tools.impl._filter_rule_utils -> app.schemas.types", + "app.agent.tools.impl._music_utils -> app.domain", + "app.agent.tools.impl._music_utils -> app.domain.context", + "app.agent.tools.impl._music_utils -> app.schemas", + "app.agent.tools.impl._music_utils -> app.schemas.types", + "app.agent.tools.impl._plugin_tool_utils -> app.adapters", + "app.agent.tools.impl._plugin_tool_utils -> app.adapters.external", + "app.agent.tools.impl._plugin_tool_utils -> app.adapters.external.market", + "app.agent.tools.impl._plugin_tool_utils -> app.adapters.external.server", + "app.agent.tools.impl._plugin_tool_utils -> app.adapters.system", + "app.agent.tools.impl._plugin_tool_utils -> app.adapters.system.plugin", + "app.agent.tools.impl._plugin_tool_utils -> app.adapters.system.plugin.package", + "app.agent.tools.impl._plugin_tool_utils -> app.agent", + "app.agent.tools.impl._plugin_tool_utils -> app.agent.tools", + "app.agent.tools.impl._plugin_tool_utils -> app.agent.tools.base", + "app.agent.tools.impl._plugin_tool_utils -> app.application", + "app.agent.tools.impl._plugin_tool_utils -> app.application.commands", + "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin", + "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.install", + "app.agent.tools.impl._plugin_tool_utils -> app.application.plugins", + "app.agent.tools.impl._plugin_tool_utils -> app.application.scheduling", + "app.agent.tools.impl._plugin_tool_utils -> app.db", + "app.agent.tools.impl._plugin_tool_utils -> app.db.oper", + "app.agent.tools.impl._plugin_tool_utils -> app.db.oper.systemconfig", + "app.agent.tools.impl._plugin_tool_utils -> app.runtime", + "app.agent.tools.impl._plugin_tool_utils -> app.runtime.config", + "app.agent.tools.impl._plugin_tool_utils -> app.runtime.extensions", + "app.agent.tools.impl._plugin_tool_utils -> app.runtime.extensions.plugin_manager", + "app.agent.tools.impl._plugin_tool_utils -> app.schemas", + "app.agent.tools.impl._plugin_tool_utils -> app.schemas.types", + "app.agent.tools.impl._system_setting_utils -> app.agent", + "app.agent.tools.impl._system_setting_utils -> app.agent.policy", + "app.agent.tools.impl._system_setting_utils -> app.agent.policy.secret_fields", + "app.agent.tools.impl._system_setting_utils -> app.runtime", + "app.agent.tools.impl._system_setting_utils -> app.runtime.config", + "app.agent.tools.impl._system_setting_utils -> app.schemas", + "app.agent.tools.impl._system_setting_utils -> app.schemas.types", + "app.agent.tools.impl._terminal_session -> app.agent", + "app.agent.tools.impl._terminal_session -> app.agent.tools", + "app.agent.tools.impl._terminal_session -> app.agent.tools.impl", + "app.agent.tools.impl._terminal_session -> app.agent.tools.impl._command_safety", + "app.agent.tools.impl._terminal_session -> app.runtime", + "app.agent.tools.impl._terminal_session -> app.runtime.config", + "app.agent.tools.impl._terminal_session -> app.runtime.log", + "app.agent.tools.impl._torrent_search_utils -> app.agent", + "app.agent.tools.impl._torrent_search_utils -> app.agent.tools", + "app.agent.tools.impl._torrent_search_utils -> app.agent.tools.impl", + "app.agent.tools.impl._torrent_search_utils -> app.agent.tools.impl._music_utils", + "app.agent.tools.impl._torrent_search_utils -> app.domain", + "app.agent.tools.impl._torrent_search_utils -> app.domain.context", + "app.agent.tools.impl._torrent_search_utils -> app.foundation", + "app.agent.tools.impl._torrent_search_utils -> app.foundation.crypto", + "app.agent.tools.impl._torrent_search_utils -> app.foundation.size", + "app.agent.tools.impl._torrent_search_utils -> app.schemas", + "app.agent.tools.impl._torrent_search_utils -> app.schemas.types", + "app.agent.tools.impl.add_custom_filter_rule -> app.agent", + "app.agent.tools.impl.add_custom_filter_rule -> app.agent.tools", + "app.agent.tools.impl.add_custom_filter_rule -> app.agent.tools.base", + "app.agent.tools.impl.add_custom_filter_rule -> app.agent.tools.impl", + "app.agent.tools.impl.add_custom_filter_rule -> app.agent.tools.impl._filter_rule_utils", + "app.agent.tools.impl.add_custom_filter_rule -> app.agent.tools.tags", + "app.agent.tools.impl.add_custom_filter_rule -> app.runtime", + "app.agent.tools.impl.add_custom_filter_rule -> app.runtime.log", + "app.agent.tools.impl.add_custom_filter_rule -> app.schemas", + "app.agent.tools.impl.add_custom_filter_rule -> app.schemas.types", + "app.agent.tools.impl.add_download_tasks -> app.agent", + "app.agent.tools.impl.add_download_tasks -> app.agent.tools", + "app.agent.tools.impl.add_download_tasks -> app.agent.tools.base", + "app.agent.tools.impl.add_download_tasks -> app.agent.tools.tags", + "app.agent.tools.impl.add_download_tasks -> app.application", + "app.agent.tools.impl.add_download_tasks -> app.application.directory", + "app.agent.tools.impl.add_download_tasks -> app.chain", + "app.agent.tools.impl.add_download_tasks -> app.chain.download", + "app.agent.tools.impl.add_download_tasks -> app.chain.media", + "app.agent.tools.impl.add_download_tasks -> app.chain.search", + "app.agent.tools.impl.add_download_tasks -> app.db", + "app.agent.tools.impl.add_download_tasks -> app.db.oper", + "app.agent.tools.impl.add_download_tasks -> app.db.oper.site", + "app.agent.tools.impl.add_download_tasks -> app.domain", + "app.agent.tools.impl.add_download_tasks -> app.domain.context", + "app.agent.tools.impl.add_download_tasks -> app.domain.metainfo", + "app.agent.tools.impl.add_download_tasks -> app.foundation", + "app.agent.tools.impl.add_download_tasks -> app.foundation.crypto", + "app.agent.tools.impl.add_download_tasks -> app.runtime", + "app.agent.tools.impl.add_download_tasks -> app.runtime.config", + "app.agent.tools.impl.add_download_tasks -> app.runtime.log", + "app.agent.tools.impl.add_download_tasks -> app.schemas", + "app.agent.tools.impl.add_download_tasks -> app.schemas.file", + "app.agent.tools.impl.add_rule_group -> app.agent", + "app.agent.tools.impl.add_rule_group -> app.agent.tools", + "app.agent.tools.impl.add_rule_group -> app.agent.tools.base", + "app.agent.tools.impl.add_rule_group -> app.agent.tools.impl", + "app.agent.tools.impl.add_rule_group -> app.agent.tools.impl._filter_rule_utils", + "app.agent.tools.impl.add_rule_group -> app.agent.tools.tags", + "app.agent.tools.impl.add_rule_group -> app.runtime", + "app.agent.tools.impl.add_rule_group -> app.runtime.log", + "app.agent.tools.impl.add_rule_group -> app.schemas", + "app.agent.tools.impl.add_rule_group -> app.schemas.types", + "app.agent.tools.impl.add_subscribe -> app.agent", + "app.agent.tools.impl.add_subscribe -> app.agent.tools", + "app.agent.tools.impl.add_subscribe -> app.agent.tools.base", + "app.agent.tools.impl.add_subscribe -> app.agent.tools.tags", + "app.agent.tools.impl.add_subscribe -> app.chain", + "app.agent.tools.impl.add_subscribe -> app.chain.subscribe", + "app.agent.tools.impl.add_subscribe -> app.db", + "app.agent.tools.impl.add_subscribe -> app.db.oper", + "app.agent.tools.impl.add_subscribe -> app.db.oper.user", + "app.agent.tools.impl.add_subscribe -> app.domain", + "app.agent.tools.impl.add_subscribe -> app.domain.media", + "app.agent.tools.impl.add_subscribe -> app.runtime", + "app.agent.tools.impl.add_subscribe -> app.runtime.log", + "app.agent.tools.impl.add_subscribe -> app.schemas", + "app.agent.tools.impl.add_subscribe -> app.schemas.types", + "app.agent.tools.impl.apply_patch -> app.agent", + "app.agent.tools.impl.apply_patch -> app.agent.tools", + "app.agent.tools.impl.apply_patch -> app.agent.tools.base", + "app.agent.tools.impl.apply_patch -> app.agent.tools.impl", + "app.agent.tools.impl.apply_patch -> app.agent.tools.impl._file_write_utils", + "app.agent.tools.impl.apply_patch -> app.agent.tools.tags", + "app.agent.tools.impl.apply_patch -> app.runtime", + "app.agent.tools.impl.apply_patch -> app.runtime.log", + "app.agent.tools.impl.ask_user_choice -> app.agent", + "app.agent.tools.impl.ask_user_choice -> app.agent.tools", + "app.agent.tools.impl.ask_user_choice -> app.agent.tools.base", + "app.agent.tools.impl.ask_user_choice -> app.agent.tools.tags", + "app.agent.tools.impl.ask_user_choice -> app.application", + "app.agent.tools.impl.ask_user_choice -> app.application.messaging", + "app.agent.tools.impl.ask_user_choice -> app.application.messaging.agent", + "app.agent.tools.impl.ask_user_choice -> app.runtime", + "app.agent.tools.impl.ask_user_choice -> app.runtime.log", + "app.agent.tools.impl.ask_user_choice -> app.schemas", + "app.agent.tools.impl.ask_user_choice -> app.schemas.message", + "app.agent.tools.impl.ask_user_choice -> app.schemas.notification", + "app.agent.tools.impl.ask_user_choice -> app.schemas.types", + "app.agent.tools.impl.browse_webpage -> app.adapters", + "app.agent.tools.impl.browse_webpage -> app.adapters.network", + "app.agent.tools.impl.browse_webpage -> app.adapters.network.browser", + "app.agent.tools.impl.browse_webpage -> app.agent", + "app.agent.tools.impl.browse_webpage -> app.agent.tools", + "app.agent.tools.impl.browse_webpage -> app.agent.tools.base", + "app.agent.tools.impl.browse_webpage -> app.agent.tools.tags", + "app.agent.tools.impl.browse_webpage -> app.runtime", + "app.agent.tools.impl.browse_webpage -> app.runtime.log", + "app.agent.tools.impl.create_agent_task -> app.agent", + "app.agent.tools.impl.create_agent_task -> app.agent.tools", + "app.agent.tools.impl.create_agent_task -> app.agent.tools.base", + "app.agent.tools.impl.create_agent_task -> app.agent.tools.tags", + "app.agent.tools.impl.create_agent_task -> app.application", + "app.agent.tools.impl.create_agent_task -> app.application.scheduling", + "app.agent.tools.impl.create_agent_task -> app.db", + "app.agent.tools.impl.create_agent_task -> app.db.oper", + "app.agent.tools.impl.create_agent_task -> app.db.oper.agentchat", + "app.agent.tools.impl.create_agent_task -> app.db.oper.agenttask", + "app.agent.tools.impl.create_agent_task -> app.runtime", + "app.agent.tools.impl.create_agent_task -> app.runtime.config", + "app.agent.tools.impl.create_agent_task -> app.runtime.scheduling", + "app.agent.tools.impl.delete_agent_task -> app.agent", + "app.agent.tools.impl.delete_agent_task -> app.agent.tools", + "app.agent.tools.impl.delete_agent_task -> app.agent.tools.base", + "app.agent.tools.impl.delete_agent_task -> app.agent.tools.tags", + "app.agent.tools.impl.delete_agent_task -> app.application", + "app.agent.tools.impl.delete_agent_task -> app.application.scheduling", + "app.agent.tools.impl.delete_agent_task -> app.db", + "app.agent.tools.impl.delete_agent_task -> app.db.oper", + "app.agent.tools.impl.delete_agent_task -> app.db.oper.agenttask", + "app.agent.tools.impl.delete_custom_filter_rule -> app.agent", + "app.agent.tools.impl.delete_custom_filter_rule -> app.agent.tools", + "app.agent.tools.impl.delete_custom_filter_rule -> app.agent.tools.base", + "app.agent.tools.impl.delete_custom_filter_rule -> app.agent.tools.impl", + "app.agent.tools.impl.delete_custom_filter_rule -> app.agent.tools.impl._filter_rule_utils", + "app.agent.tools.impl.delete_custom_filter_rule -> app.agent.tools.tags", + "app.agent.tools.impl.delete_custom_filter_rule -> app.runtime", + "app.agent.tools.impl.delete_custom_filter_rule -> app.runtime.log", + "app.agent.tools.impl.delete_custom_filter_rule -> app.schemas", + "app.agent.tools.impl.delete_custom_filter_rule -> app.schemas.types", + "app.agent.tools.impl.delete_download_history -> app.agent", + "app.agent.tools.impl.delete_download_history -> app.agent.tools", + "app.agent.tools.impl.delete_download_history -> app.agent.tools.base", + "app.agent.tools.impl.delete_download_history -> app.agent.tools.tags", + "app.agent.tools.impl.delete_download_history -> app.db", + "app.agent.tools.impl.delete_download_history -> app.db.oper", + "app.agent.tools.impl.delete_download_history -> app.db.oper.downloadhistory", + "app.agent.tools.impl.delete_download_history -> app.runtime", + "app.agent.tools.impl.delete_download_history -> app.runtime.log", + "app.agent.tools.impl.delete_download_tasks -> app.agent", + "app.agent.tools.impl.delete_download_tasks -> app.agent.tools", + "app.agent.tools.impl.delete_download_tasks -> app.agent.tools.base", + "app.agent.tools.impl.delete_download_tasks -> app.agent.tools.tags", + "app.agent.tools.impl.delete_download_tasks -> app.chain", + "app.agent.tools.impl.delete_download_tasks -> app.chain.download", + "app.agent.tools.impl.delete_download_tasks -> app.runtime", + "app.agent.tools.impl.delete_download_tasks -> app.runtime.log", + "app.agent.tools.impl.delete_rule_group -> app.agent", + "app.agent.tools.impl.delete_rule_group -> app.agent.tools", + "app.agent.tools.impl.delete_rule_group -> app.agent.tools.base", + "app.agent.tools.impl.delete_rule_group -> app.agent.tools.impl", + "app.agent.tools.impl.delete_rule_group -> app.agent.tools.impl._filter_rule_utils", + "app.agent.tools.impl.delete_rule_group -> app.agent.tools.tags", + "app.agent.tools.impl.delete_rule_group -> app.runtime", + "app.agent.tools.impl.delete_rule_group -> app.runtime.log", + "app.agent.tools.impl.delete_rule_group -> app.schemas", + "app.agent.tools.impl.delete_rule_group -> app.schemas.types", + "app.agent.tools.impl.delete_subscribe -> app.adapters", + "app.agent.tools.impl.delete_subscribe -> app.adapters.external", + "app.agent.tools.impl.delete_subscribe -> app.adapters.external.server", + "app.agent.tools.impl.delete_subscribe -> app.agent", + "app.agent.tools.impl.delete_subscribe -> app.agent.tools", + "app.agent.tools.impl.delete_subscribe -> app.agent.tools.base", + "app.agent.tools.impl.delete_subscribe -> app.agent.tools.tags", + "app.agent.tools.impl.delete_subscribe -> app.db", + "app.agent.tools.impl.delete_subscribe -> app.db.oper", + "app.agent.tools.impl.delete_subscribe -> app.db.oper.subscribe", + "app.agent.tools.impl.delete_subscribe -> app.runtime", + "app.agent.tools.impl.delete_subscribe -> app.runtime.events", + "app.agent.tools.impl.delete_subscribe -> app.runtime.log", + "app.agent.tools.impl.delete_subscribe -> app.schemas", + "app.agent.tools.impl.delete_subscribe -> app.schemas.types", + "app.agent.tools.impl.delete_transfer_history -> app.agent", + "app.agent.tools.impl.delete_transfer_history -> app.agent.tools", + "app.agent.tools.impl.delete_transfer_history -> app.agent.tools.base", + "app.agent.tools.impl.delete_transfer_history -> app.agent.tools.tags", + "app.agent.tools.impl.delete_transfer_history -> app.chain", + "app.agent.tools.impl.delete_transfer_history -> app.chain.storage", + "app.agent.tools.impl.delete_transfer_history -> app.db", + "app.agent.tools.impl.delete_transfer_history -> app.db.oper", + "app.agent.tools.impl.delete_transfer_history -> app.db.oper.transferhistory", + "app.agent.tools.impl.delete_transfer_history -> app.runtime", + "app.agent.tools.impl.delete_transfer_history -> app.runtime.log", + "app.agent.tools.impl.delete_transfer_history -> app.schemas", + "app.agent.tools.impl.delete_transfer_history -> app.schemas.workflow", + "app.agent.tools.impl.edit_file -> app.agent", + "app.agent.tools.impl.edit_file -> app.agent.tools", + "app.agent.tools.impl.edit_file -> app.agent.tools.base", + "app.agent.tools.impl.edit_file -> app.agent.tools.impl", + "app.agent.tools.impl.edit_file -> app.agent.tools.impl._file_write_utils", + "app.agent.tools.impl.edit_file -> app.agent.tools.tags", + "app.agent.tools.impl.edit_file -> app.runtime", + "app.agent.tools.impl.edit_file -> app.runtime.log", + "app.agent.tools.impl.execute_command -> app.agent", + "app.agent.tools.impl.execute_command -> app.agent.tools", + "app.agent.tools.impl.execute_command -> app.agent.tools.base", + "app.agent.tools.impl.execute_command -> app.agent.tools.impl", + "app.agent.tools.impl.execute_command -> app.agent.tools.impl._command_safety", + "app.agent.tools.impl.execute_command -> app.agent.tools.impl._terminal_session", + "app.agent.tools.impl.execute_command -> app.agent.tools.tags", + "app.agent.tools.impl.execute_command -> app.runtime", + "app.agent.tools.impl.execute_command -> app.runtime.log", + "app.agent.tools.impl.get_recommendations -> app.agent", + "app.agent.tools.impl.get_recommendations -> app.agent.tools", + "app.agent.tools.impl.get_recommendations -> app.agent.tools.base", + "app.agent.tools.impl.get_recommendations -> app.agent.tools.impl", + "app.agent.tools.impl.get_recommendations -> app.agent.tools.impl._music_utils", + "app.agent.tools.impl.get_recommendations -> app.agent.tools.tags", + "app.agent.tools.impl.get_recommendations -> app.chain", + "app.agent.tools.impl.get_recommendations -> app.chain.listenbrainz", + "app.agent.tools.impl.get_recommendations -> app.chain.recommend", + "app.agent.tools.impl.get_recommendations -> app.domain", + "app.agent.tools.impl.get_recommendations -> app.domain.media", + "app.agent.tools.impl.get_recommendations -> app.runtime", + "app.agent.tools.impl.get_recommendations -> app.runtime.log", + "app.agent.tools.impl.get_recommendations -> app.schemas", + "app.agent.tools.impl.get_recommendations -> app.schemas.types", + "app.agent.tools.impl.get_search_results -> app.agent", + "app.agent.tools.impl.get_search_results -> app.agent.tools", + "app.agent.tools.impl.get_search_results -> app.agent.tools.base", + "app.agent.tools.impl.get_search_results -> app.agent.tools.impl", + "app.agent.tools.impl.get_search_results -> app.agent.tools.impl._torrent_search_utils", + "app.agent.tools.impl.get_search_results -> app.agent.tools.tags", + "app.agent.tools.impl.get_search_results -> app.chain", + "app.agent.tools.impl.get_search_results -> app.chain.search", + "app.agent.tools.impl.get_search_results -> app.runtime", + "app.agent.tools.impl.get_search_results -> app.runtime.log", + "app.agent.tools.impl.install_plugin -> app.agent", + "app.agent.tools.impl.install_plugin -> app.agent.tools", + "app.agent.tools.impl.install_plugin -> app.agent.tools.base", + "app.agent.tools.impl.install_plugin -> app.agent.tools.impl", + "app.agent.tools.impl.install_plugin -> app.agent.tools.impl._plugin_tool_utils", + "app.agent.tools.impl.install_plugin -> app.agent.tools.tags", + "app.agent.tools.impl.install_plugin -> app.runtime", + "app.agent.tools.impl.install_plugin -> app.runtime.log", + "app.agent.tools.impl.list_directory -> app.agent", + "app.agent.tools.impl.list_directory -> app.agent.tools", + "app.agent.tools.impl.list_directory -> app.agent.tools.base", + "app.agent.tools.impl.list_directory -> app.agent.tools.tags", + "app.agent.tools.impl.list_directory -> app.chain", + "app.agent.tools.impl.list_directory -> app.chain.storage", + "app.agent.tools.impl.list_directory -> app.foundation", + "app.agent.tools.impl.list_directory -> app.foundation.size", + "app.agent.tools.impl.list_directory -> app.foundation.text", + "app.agent.tools.impl.list_directory -> app.runtime", + "app.agent.tools.impl.list_directory -> app.runtime.log", + "app.agent.tools.impl.list_directory -> app.schemas", + "app.agent.tools.impl.list_directory -> app.schemas.file", + "app.agent.tools.impl.list_slash_commands -> app.agent", + "app.agent.tools.impl.list_slash_commands -> app.agent.tools", + "app.agent.tools.impl.list_slash_commands -> app.agent.tools.base", + "app.agent.tools.impl.list_slash_commands -> app.agent.tools.tags", + "app.agent.tools.impl.list_slash_commands -> app.application", + "app.agent.tools.impl.list_slash_commands -> app.application.commands", + "app.agent.tools.impl.list_slash_commands -> app.runtime", + "app.agent.tools.impl.list_slash_commands -> app.runtime.log", + "app.agent.tools.impl.mcp -> app.agent", + "app.agent.tools.impl.mcp -> app.agent.mcp", + "app.agent.tools.impl.mcp -> app.agent.tools", + "app.agent.tools.impl.mcp -> app.agent.tools.base", + "app.agent.tools.impl.mcp -> app.agent.tools.tags", + "app.agent.tools.impl.query_agent_tasks -> app.agent", + "app.agent.tools.impl.query_agent_tasks -> app.agent.tools", + "app.agent.tools.impl.query_agent_tasks -> app.agent.tools.base", + "app.agent.tools.impl.query_agent_tasks -> app.agent.tools.tags", + "app.agent.tools.impl.query_agent_tasks -> app.application", + "app.agent.tools.impl.query_agent_tasks -> app.application.scheduling", + "app.agent.tools.impl.query_agent_tasks -> app.db", + "app.agent.tools.impl.query_agent_tasks -> app.db.oper", + "app.agent.tools.impl.query_agent_tasks -> app.db.oper.agenttask", + "app.agent.tools.impl.query_agent_tasks -> app.runtime", + "app.agent.tools.impl.query_agent_tasks -> app.runtime.config", + "app.agent.tools.impl.query_builtin_filter_rules -> app.agent", + "app.agent.tools.impl.query_builtin_filter_rules -> app.agent.tools", + "app.agent.tools.impl.query_builtin_filter_rules -> app.agent.tools.base", + "app.agent.tools.impl.query_builtin_filter_rules -> app.agent.tools.impl", + "app.agent.tools.impl.query_builtin_filter_rules -> app.agent.tools.impl._filter_rule_utils", + "app.agent.tools.impl.query_builtin_filter_rules -> app.agent.tools.tags", + "app.agent.tools.impl.query_builtin_filter_rules -> app.runtime", + "app.agent.tools.impl.query_builtin_filter_rules -> app.runtime.log", + "app.agent.tools.impl.query_custom_filter_rules -> app.agent", + "app.agent.tools.impl.query_custom_filter_rules -> app.agent.tools", + "app.agent.tools.impl.query_custom_filter_rules -> app.agent.tools.base", + "app.agent.tools.impl.query_custom_filter_rules -> app.agent.tools.impl", + "app.agent.tools.impl.query_custom_filter_rules -> app.agent.tools.impl._filter_rule_utils", + "app.agent.tools.impl.query_custom_filter_rules -> app.agent.tools.tags", + "app.agent.tools.impl.query_custom_filter_rules -> app.runtime", + "app.agent.tools.impl.query_custom_filter_rules -> app.runtime.log", + "app.agent.tools.impl.query_custom_identifiers -> app.agent", + "app.agent.tools.impl.query_custom_identifiers -> app.agent.tools", + "app.agent.tools.impl.query_custom_identifiers -> app.agent.tools.base", + "app.agent.tools.impl.query_custom_identifiers -> app.agent.tools.tags", + "app.agent.tools.impl.query_custom_identifiers -> app.db", + "app.agent.tools.impl.query_custom_identifiers -> app.db.oper", + "app.agent.tools.impl.query_custom_identifiers -> app.db.oper.systemconfig", + "app.agent.tools.impl.query_custom_identifiers -> app.runtime", + "app.agent.tools.impl.query_custom_identifiers -> app.runtime.log", + "app.agent.tools.impl.query_custom_identifiers -> app.schemas", + "app.agent.tools.impl.query_custom_identifiers -> app.schemas.types", + "app.agent.tools.impl.query_directory_settings -> app.agent", + "app.agent.tools.impl.query_directory_settings -> app.agent.tools", + "app.agent.tools.impl.query_directory_settings -> app.agent.tools.base", + "app.agent.tools.impl.query_directory_settings -> app.agent.tools.tags", + "app.agent.tools.impl.query_directory_settings -> app.application", + "app.agent.tools.impl.query_directory_settings -> app.application.directory", + "app.agent.tools.impl.query_directory_settings -> app.runtime", + "app.agent.tools.impl.query_directory_settings -> app.runtime.log", + "app.agent.tools.impl.query_doctor_report -> app.agent", + "app.agent.tools.impl.query_doctor_report -> app.agent.tools", + "app.agent.tools.impl.query_doctor_report -> app.agent.tools.base", + "app.agent.tools.impl.query_doctor_report -> app.agent.tools.tags", + "app.agent.tools.impl.query_doctor_report -> app.doctor", + "app.agent.tools.impl.query_doctor_report -> app.runtime", + "app.agent.tools.impl.query_doctor_report -> app.runtime.log", + "app.agent.tools.impl.query_download_tasks -> app.agent", + "app.agent.tools.impl.query_download_tasks -> app.agent.tools", + "app.agent.tools.impl.query_download_tasks -> app.agent.tools.base", + "app.agent.tools.impl.query_download_tasks -> app.agent.tools.tags", + "app.agent.tools.impl.query_download_tasks -> app.chain", + "app.agent.tools.impl.query_download_tasks -> app.chain.download", + "app.agent.tools.impl.query_download_tasks -> app.db", + "app.agent.tools.impl.query_download_tasks -> app.db.oper", + "app.agent.tools.impl.query_download_tasks -> app.db.oper.downloadhistory", + "app.agent.tools.impl.query_download_tasks -> app.runtime", + "app.agent.tools.impl.query_download_tasks -> app.runtime.log", + "app.agent.tools.impl.query_download_tasks -> app.schemas", + "app.agent.tools.impl.query_download_tasks -> app.schemas.transfer", + "app.agent.tools.impl.query_download_tasks -> app.schemas.types", + "app.agent.tools.impl.query_downloaders -> app.agent", + "app.agent.tools.impl.query_downloaders -> app.agent.tools", + "app.agent.tools.impl.query_downloaders -> app.agent.tools.base", + "app.agent.tools.impl.query_downloaders -> app.agent.tools.tags", + "app.agent.tools.impl.query_downloaders -> app.db", + "app.agent.tools.impl.query_downloaders -> app.db.oper", + "app.agent.tools.impl.query_downloaders -> app.db.oper.systemconfig", + "app.agent.tools.impl.query_downloaders -> app.runtime", + "app.agent.tools.impl.query_downloaders -> app.runtime.log", + "app.agent.tools.impl.query_downloaders -> app.schemas", + "app.agent.tools.impl.query_downloaders -> app.schemas.types", + "app.agent.tools.impl.query_episode_schedule -> app.agent", + "app.agent.tools.impl.query_episode_schedule -> app.agent.tools", + "app.agent.tools.impl.query_episode_schedule -> app.agent.tools.base", + "app.agent.tools.impl.query_episode_schedule -> app.agent.tools.tags", + "app.agent.tools.impl.query_episode_schedule -> app.chain", + "app.agent.tools.impl.query_episode_schedule -> app.chain.tmdb", + "app.agent.tools.impl.query_episode_schedule -> app.runtime", + "app.agent.tools.impl.query_episode_schedule -> app.runtime.log", + "app.agent.tools.impl.query_installed_plugins -> app.agent", + "app.agent.tools.impl.query_installed_plugins -> app.agent.tools", + "app.agent.tools.impl.query_installed_plugins -> app.agent.tools.base", + "app.agent.tools.impl.query_installed_plugins -> app.agent.tools.impl", + "app.agent.tools.impl.query_installed_plugins -> app.agent.tools.impl._plugin_tool_utils", + "app.agent.tools.impl.query_installed_plugins -> app.agent.tools.tags", + "app.agent.tools.impl.query_installed_plugins -> app.runtime", + "app.agent.tools.impl.query_installed_plugins -> app.runtime.log", + "app.agent.tools.impl.query_library_exists -> app.agent", + "app.agent.tools.impl.query_library_exists -> app.agent.tools", + "app.agent.tools.impl.query_library_exists -> app.agent.tools.base", + "app.agent.tools.impl.query_library_exists -> app.agent.tools.tags", + "app.agent.tools.impl.query_library_exists -> app.application", + "app.agent.tools.impl.query_library_exists -> app.application.mediaserver", + "app.agent.tools.impl.query_library_exists -> app.chain", + "app.agent.tools.impl.query_library_exists -> app.chain.mediaserver", + "app.agent.tools.impl.query_library_exists -> app.domain", + "app.agent.tools.impl.query_library_exists -> app.domain.media", + "app.agent.tools.impl.query_library_exists -> app.runtime", + "app.agent.tools.impl.query_library_exists -> app.runtime.log", + "app.agent.tools.impl.query_library_exists -> app.schemas", + "app.agent.tools.impl.query_library_exists -> app.schemas.types", + "app.agent.tools.impl.query_library_latest -> app.agent", + "app.agent.tools.impl.query_library_latest -> app.agent.tools", + "app.agent.tools.impl.query_library_latest -> app.agent.tools.base", + "app.agent.tools.impl.query_library_latest -> app.agent.tools.tags", + "app.agent.tools.impl.query_library_latest -> app.chain", + "app.agent.tools.impl.query_library_latest -> app.chain.mediaserver", + "app.agent.tools.impl.query_library_latest -> app.runtime", + "app.agent.tools.impl.query_library_latest -> app.runtime.extensions", + "app.agent.tools.impl.query_library_latest -> app.runtime.extensions.service_registry", + "app.agent.tools.impl.query_library_latest -> app.runtime.log", + "app.agent.tools.impl.query_market_plugins -> app.agent", + "app.agent.tools.impl.query_market_plugins -> app.agent.tools", + "app.agent.tools.impl.query_market_plugins -> app.agent.tools.base", + "app.agent.tools.impl.query_market_plugins -> app.agent.tools.impl", + "app.agent.tools.impl.query_market_plugins -> app.agent.tools.impl._plugin_tool_utils", + "app.agent.tools.impl.query_market_plugins -> app.agent.tools.tags", + "app.agent.tools.impl.query_market_plugins -> app.runtime", + "app.agent.tools.impl.query_market_plugins -> app.runtime.log", + "app.agent.tools.impl.query_media_detail -> app.agent", + "app.agent.tools.impl.query_media_detail -> app.agent.tools", + "app.agent.tools.impl.query_media_detail -> app.agent.tools.base", + "app.agent.tools.impl.query_media_detail -> app.agent.tools.impl", + "app.agent.tools.impl.query_media_detail -> app.agent.tools.impl._music_utils", + "app.agent.tools.impl.query_media_detail -> app.agent.tools.tags", + "app.agent.tools.impl.query_media_detail -> app.chain", + "app.agent.tools.impl.query_media_detail -> app.chain.media", + "app.agent.tools.impl.query_media_detail -> app.domain", + "app.agent.tools.impl.query_media_detail -> app.domain.media", + "app.agent.tools.impl.query_media_detail -> app.runtime", + "app.agent.tools.impl.query_media_detail -> app.runtime.log", + "app.agent.tools.impl.query_media_detail -> app.schemas", + "app.agent.tools.impl.query_media_detail -> app.schemas.types", + "app.agent.tools.impl.query_personas -> app.agent", + "app.agent.tools.impl.query_personas -> app.agent.runtime", + "app.agent.tools.impl.query_personas -> app.agent.tools", + "app.agent.tools.impl.query_personas -> app.agent.tools.base", + "app.agent.tools.impl.query_personas -> app.agent.tools.tags", + "app.agent.tools.impl.query_personas -> app.runtime", + "app.agent.tools.impl.query_personas -> app.runtime.log", + "app.agent.tools.impl.query_plugin_capabilities -> app.agent", + "app.agent.tools.impl.query_plugin_capabilities -> app.agent.tools", + "app.agent.tools.impl.query_plugin_capabilities -> app.agent.tools.base", + "app.agent.tools.impl.query_plugin_capabilities -> app.agent.tools.tags", + "app.agent.tools.impl.query_plugin_capabilities -> app.runtime", + "app.agent.tools.impl.query_plugin_capabilities -> app.runtime.extensions", + "app.agent.tools.impl.query_plugin_capabilities -> app.runtime.extensions.plugin_manager", + "app.agent.tools.impl.query_plugin_capabilities -> app.runtime.log", + "app.agent.tools.impl.query_plugin_config -> app.agent", + "app.agent.tools.impl.query_plugin_config -> app.agent.tools", + "app.agent.tools.impl.query_plugin_config -> app.agent.tools.base", + "app.agent.tools.impl.query_plugin_config -> app.agent.tools.impl", + "app.agent.tools.impl.query_plugin_config -> app.agent.tools.impl._plugin_tool_utils", + "app.agent.tools.impl.query_plugin_config -> app.agent.tools.tags", + "app.agent.tools.impl.query_plugin_config -> app.runtime", + "app.agent.tools.impl.query_plugin_config -> app.runtime.extensions", + "app.agent.tools.impl.query_plugin_config -> app.runtime.extensions.plugin_manager", + "app.agent.tools.impl.query_plugin_config -> app.runtime.log", + "app.agent.tools.impl.query_plugin_data -> app.agent", + "app.agent.tools.impl.query_plugin_data -> app.agent.tools", + "app.agent.tools.impl.query_plugin_data -> app.agent.tools.base", + "app.agent.tools.impl.query_plugin_data -> app.agent.tools.impl", + "app.agent.tools.impl.query_plugin_data -> app.agent.tools.impl._plugin_tool_utils", + "app.agent.tools.impl.query_plugin_data -> app.agent.tools.tags", + "app.agent.tools.impl.query_plugin_data -> app.db", + "app.agent.tools.impl.query_plugin_data -> app.db.oper", + "app.agent.tools.impl.query_plugin_data -> app.db.oper.plugindata", + "app.agent.tools.impl.query_plugin_data -> app.runtime", + "app.agent.tools.impl.query_plugin_data -> app.runtime.log", + "app.agent.tools.impl.query_popular_subscribes -> app.adapters", + "app.agent.tools.impl.query_popular_subscribes -> app.adapters.external", + "app.agent.tools.impl.query_popular_subscribes -> app.adapters.external.server", + "app.agent.tools.impl.query_popular_subscribes -> app.agent", + "app.agent.tools.impl.query_popular_subscribes -> app.agent.tools", + "app.agent.tools.impl.query_popular_subscribes -> app.agent.tools.base", + "app.agent.tools.impl.query_popular_subscribes -> app.agent.tools.tags", + "app.agent.tools.impl.query_popular_subscribes -> app.domain", + "app.agent.tools.impl.query_popular_subscribes -> app.domain.context", + "app.agent.tools.impl.query_popular_subscribes -> app.domain.media", + "app.agent.tools.impl.query_popular_subscribes -> app.runtime", + "app.agent.tools.impl.query_popular_subscribes -> app.runtime.log", + "app.agent.tools.impl.query_popular_subscribes -> app.schemas", + "app.agent.tools.impl.query_popular_subscribes -> app.schemas.types", + "app.agent.tools.impl.query_rule_groups -> app.agent", + "app.agent.tools.impl.query_rule_groups -> app.agent.tools", + "app.agent.tools.impl.query_rule_groups -> app.agent.tools.base", + "app.agent.tools.impl.query_rule_groups -> app.agent.tools.impl", + "app.agent.tools.impl.query_rule_groups -> app.agent.tools.impl._filter_rule_utils", + "app.agent.tools.impl.query_rule_groups -> app.agent.tools.tags", + "app.agent.tools.impl.query_rule_groups -> app.runtime", + "app.agent.tools.impl.query_rule_groups -> app.runtime.log", + "app.agent.tools.impl.query_schedulers -> app.agent", + "app.agent.tools.impl.query_schedulers -> app.agent.tools", + "app.agent.tools.impl.query_schedulers -> app.agent.tools.base", + "app.agent.tools.impl.query_schedulers -> app.agent.tools.tags", + "app.agent.tools.impl.query_schedulers -> app.application", + "app.agent.tools.impl.query_schedulers -> app.application.scheduling", + "app.agent.tools.impl.query_schedulers -> app.runtime", + "app.agent.tools.impl.query_schedulers -> app.runtime.log", + "app.agent.tools.impl.query_site_userdata -> app.agent", + "app.agent.tools.impl.query_site_userdata -> app.agent.tools", + "app.agent.tools.impl.query_site_userdata -> app.agent.tools.base", + "app.agent.tools.impl.query_site_userdata -> app.agent.tools.tags", + "app.agent.tools.impl.query_site_userdata -> app.db", + "app.agent.tools.impl.query_site_userdata -> app.db.oper", + "app.agent.tools.impl.query_site_userdata -> app.db.oper.site", + "app.agent.tools.impl.query_site_userdata -> app.runtime", + "app.agent.tools.impl.query_site_userdata -> app.runtime.log", + "app.agent.tools.impl.query_sites -> app.agent", + "app.agent.tools.impl.query_sites -> app.agent.tools", + "app.agent.tools.impl.query_sites -> app.agent.tools.base", + "app.agent.tools.impl.query_sites -> app.agent.tools.tags", + "app.agent.tools.impl.query_sites -> app.db", + "app.agent.tools.impl.query_sites -> app.db.oper", + "app.agent.tools.impl.query_sites -> app.db.oper.site", + "app.agent.tools.impl.query_sites -> app.runtime", + "app.agent.tools.impl.query_sites -> app.runtime.log", + "app.agent.tools.impl.query_subscribe_history -> app.agent", + "app.agent.tools.impl.query_subscribe_history -> app.agent.tools", + "app.agent.tools.impl.query_subscribe_history -> app.agent.tools.base", + "app.agent.tools.impl.query_subscribe_history -> app.agent.tools.tags", + "app.agent.tools.impl.query_subscribe_history -> app.db", + "app.agent.tools.impl.query_subscribe_history -> app.db.oper", + "app.agent.tools.impl.query_subscribe_history -> app.db.oper.subscribehistory", + "app.agent.tools.impl.query_subscribe_history -> app.domain", + "app.agent.tools.impl.query_subscribe_history -> app.domain.media", + "app.agent.tools.impl.query_subscribe_history -> app.runtime", + "app.agent.tools.impl.query_subscribe_history -> app.runtime.log", + "app.agent.tools.impl.query_subscribe_history -> app.schemas", + "app.agent.tools.impl.query_subscribe_history -> app.schemas.types", + "app.agent.tools.impl.query_subscribe_shares -> app.adapters", + "app.agent.tools.impl.query_subscribe_shares -> app.adapters.external", + "app.agent.tools.impl.query_subscribe_shares -> app.adapters.external.server", + "app.agent.tools.impl.query_subscribe_shares -> app.agent", + "app.agent.tools.impl.query_subscribe_shares -> app.agent.tools", + "app.agent.tools.impl.query_subscribe_shares -> app.agent.tools.base", + "app.agent.tools.impl.query_subscribe_shares -> app.agent.tools.tags", + "app.agent.tools.impl.query_subscribe_shares -> app.domain", + "app.agent.tools.impl.query_subscribe_shares -> app.domain.media", + "app.agent.tools.impl.query_subscribe_shares -> app.runtime", + "app.agent.tools.impl.query_subscribe_shares -> app.runtime.log", + "app.agent.tools.impl.query_subscribe_shares -> app.schemas", + "app.agent.tools.impl.query_subscribe_shares -> app.schemas.types", + "app.agent.tools.impl.query_subscribes -> app.agent", + "app.agent.tools.impl.query_subscribes -> app.agent.tools", + "app.agent.tools.impl.query_subscribes -> app.agent.tools.base", + "app.agent.tools.impl.query_subscribes -> app.agent.tools.tags", + "app.agent.tools.impl.query_subscribes -> app.db", + "app.agent.tools.impl.query_subscribes -> app.db.oper", + "app.agent.tools.impl.query_subscribes -> app.db.oper.subscribe", + "app.agent.tools.impl.query_subscribes -> app.domain", + "app.agent.tools.impl.query_subscribes -> app.domain.media", + "app.agent.tools.impl.query_subscribes -> app.runtime", + "app.agent.tools.impl.query_subscribes -> app.runtime.log", + "app.agent.tools.impl.query_subscribes -> app.schemas", + "app.agent.tools.impl.query_subscribes -> app.schemas.subscribe", + "app.agent.tools.impl.query_subscribes -> app.schemas.types", + "app.agent.tools.impl.query_system_settings -> app.agent", + "app.agent.tools.impl.query_system_settings -> app.agent.tools", + "app.agent.tools.impl.query_system_settings -> app.agent.tools.base", + "app.agent.tools.impl.query_system_settings -> app.agent.tools.impl", + "app.agent.tools.impl.query_system_settings -> app.agent.tools.impl._system_setting_utils", + "app.agent.tools.impl.query_system_settings -> app.agent.tools.tags", + "app.agent.tools.impl.query_system_settings -> app.db", + "app.agent.tools.impl.query_system_settings -> app.db.oper", + "app.agent.tools.impl.query_system_settings -> app.db.oper.systemconfig", + "app.agent.tools.impl.query_system_settings -> app.runtime", + "app.agent.tools.impl.query_system_settings -> app.runtime.config", + "app.agent.tools.impl.query_system_settings -> app.runtime.log", + "app.agent.tools.impl.query_transfer_history -> app.agent", + "app.agent.tools.impl.query_transfer_history -> app.agent.tools", + "app.agent.tools.impl.query_transfer_history -> app.agent.tools.base", + "app.agent.tools.impl.query_transfer_history -> app.agent.tools.tags", + "app.agent.tools.impl.query_transfer_history -> app.db", + "app.agent.tools.impl.query_transfer_history -> app.db.oper", + "app.agent.tools.impl.query_transfer_history -> app.db.oper.transferhistory", + "app.agent.tools.impl.query_transfer_history -> app.foundation", + "app.agent.tools.impl.query_transfer_history -> app.foundation.text", + "app.agent.tools.impl.query_transfer_history -> app.runtime", + "app.agent.tools.impl.query_transfer_history -> app.runtime.log", + "app.agent.tools.impl.query_transfer_history -> app.schemas", + "app.agent.tools.impl.query_transfer_history -> app.schemas.types", + "app.agent.tools.impl.query_workflows -> app.agent", + "app.agent.tools.impl.query_workflows -> app.agent.tools", + "app.agent.tools.impl.query_workflows -> app.agent.tools.base", + "app.agent.tools.impl.query_workflows -> app.agent.tools.tags", + "app.agent.tools.impl.query_workflows -> app.db", + "app.agent.tools.impl.query_workflows -> app.db.oper", + "app.agent.tools.impl.query_workflows -> app.db.oper.workflow", + "app.agent.tools.impl.query_workflows -> app.runtime", + "app.agent.tools.impl.query_workflows -> app.runtime.log", + "app.agent.tools.impl.read_file -> app.agent", + "app.agent.tools.impl.read_file -> app.agent.tools", + "app.agent.tools.impl.read_file -> app.agent.tools.base", + "app.agent.tools.impl.read_file -> app.agent.tools.tags", + "app.agent.tools.impl.read_file -> app.runtime", + "app.agent.tools.impl.read_file -> app.runtime.log", + "app.agent.tools.impl.recognize_captcha -> app.adapters", + "app.agent.tools.impl.recognize_captcha -> app.adapters.external", + "app.agent.tools.impl.recognize_captcha -> app.adapters.external.ocr", + "app.agent.tools.impl.recognize_captcha -> app.adapters.network", + "app.agent.tools.impl.recognize_captcha -> app.adapters.network.browser", + "app.agent.tools.impl.recognize_captcha -> app.agent", + "app.agent.tools.impl.recognize_captcha -> app.agent.tools", + "app.agent.tools.impl.recognize_captcha -> app.agent.tools.base", + "app.agent.tools.impl.recognize_captcha -> app.agent.tools.tags", + "app.agent.tools.impl.recognize_captcha -> app.runtime", + "app.agent.tools.impl.recognize_captcha -> app.runtime.log", + "app.agent.tools.impl.recognize_media -> app.agent", + "app.agent.tools.impl.recognize_media -> app.agent.tools", + "app.agent.tools.impl.recognize_media -> app.agent.tools.base", + "app.agent.tools.impl.recognize_media -> app.agent.tools.impl", + "app.agent.tools.impl.recognize_media -> app.agent.tools.impl._music_utils", + "app.agent.tools.impl.recognize_media -> app.agent.tools.tags", + "app.agent.tools.impl.recognize_media -> app.chain", + "app.agent.tools.impl.recognize_media -> app.chain.media", + "app.agent.tools.impl.recognize_media -> app.domain", + "app.agent.tools.impl.recognize_media -> app.domain.context", + "app.agent.tools.impl.recognize_media -> app.domain.meta", + "app.agent.tools.impl.recognize_media -> app.domain.meta.metamusic", + "app.agent.tools.impl.recognize_media -> app.domain.metainfo", + "app.agent.tools.impl.recognize_media -> app.runtime", + "app.agent.tools.impl.recognize_media -> app.runtime.config", + "app.agent.tools.impl.recognize_media -> app.runtime.log", + "app.agent.tools.impl.recognize_media -> app.schemas", + "app.agent.tools.impl.recognize_media -> app.schemas.types", + "app.agent.tools.impl.reload_plugin -> app.agent", + "app.agent.tools.impl.reload_plugin -> app.agent.tools", + "app.agent.tools.impl.reload_plugin -> app.agent.tools.base", + "app.agent.tools.impl.reload_plugin -> app.agent.tools.impl", + "app.agent.tools.impl.reload_plugin -> app.agent.tools.impl._plugin_tool_utils", + "app.agent.tools.impl.reload_plugin -> app.agent.tools.tags", + "app.agent.tools.impl.reload_plugin -> app.runtime", + "app.agent.tools.impl.reload_plugin -> app.runtime.log", + "app.agent.tools.impl.run_agent_task -> app.agent", + "app.agent.tools.impl.run_agent_task -> app.agent.tools", + "app.agent.tools.impl.run_agent_task -> app.agent.tools.base", + "app.agent.tools.impl.run_agent_task -> app.agent.tools.tags", + "app.agent.tools.impl.run_agent_task -> app.application", + "app.agent.tools.impl.run_agent_task -> app.application.scheduling", + "app.agent.tools.impl.run_agent_task -> app.db", + "app.agent.tools.impl.run_agent_task -> app.db.oper", + "app.agent.tools.impl.run_agent_task -> app.db.oper.agenttask", + "app.agent.tools.impl.run_scheduler -> app.agent", + "app.agent.tools.impl.run_scheduler -> app.agent.tools", + "app.agent.tools.impl.run_scheduler -> app.agent.tools.base", + "app.agent.tools.impl.run_scheduler -> app.agent.tools.tags", + "app.agent.tools.impl.run_scheduler -> app.application", + "app.agent.tools.impl.run_scheduler -> app.application.scheduling", + "app.agent.tools.impl.run_scheduler -> app.runtime", + "app.agent.tools.impl.run_scheduler -> app.runtime.log", + "app.agent.tools.impl.run_slash_command -> app.agent", + "app.agent.tools.impl.run_slash_command -> app.agent.tools", + "app.agent.tools.impl.run_slash_command -> app.agent.tools.base", + "app.agent.tools.impl.run_slash_command -> app.agent.tools.tags", + "app.agent.tools.impl.run_slash_command -> app.application", + "app.agent.tools.impl.run_slash_command -> app.application.commands", + "app.agent.tools.impl.run_slash_command -> app.runtime", + "app.agent.tools.impl.run_slash_command -> app.runtime.events", + "app.agent.tools.impl.run_slash_command -> app.runtime.log", + "app.agent.tools.impl.run_slash_command -> app.schemas", + "app.agent.tools.impl.run_slash_command -> app.schemas.types", + "app.agent.tools.impl.run_workflow -> app.agent", + "app.agent.tools.impl.run_workflow -> app.agent.tools", + "app.agent.tools.impl.run_workflow -> app.agent.tools.base", + "app.agent.tools.impl.run_workflow -> app.agent.tools.tags", + "app.agent.tools.impl.run_workflow -> app.chain", + "app.agent.tools.impl.run_workflow -> app.chain.workflow", + "app.agent.tools.impl.run_workflow -> app.db", + "app.agent.tools.impl.run_workflow -> app.db.oper", + "app.agent.tools.impl.run_workflow -> app.db.oper.workflow", + "app.agent.tools.impl.run_workflow -> app.runtime", + "app.agent.tools.impl.run_workflow -> app.runtime.log", + "app.agent.tools.impl.scrape_metadata -> app.agent", + "app.agent.tools.impl.scrape_metadata -> app.agent.tools", + "app.agent.tools.impl.scrape_metadata -> app.agent.tools.base", + "app.agent.tools.impl.scrape_metadata -> app.agent.tools.impl", + "app.agent.tools.impl.scrape_metadata -> app.agent.tools.impl._music_utils", + "app.agent.tools.impl.scrape_metadata -> app.agent.tools.tags", + "app.agent.tools.impl.scrape_metadata -> app.chain", + "app.agent.tools.impl.scrape_metadata -> app.chain.media", + "app.agent.tools.impl.scrape_metadata -> app.chain.scraping", + "app.agent.tools.impl.scrape_metadata -> app.domain", + "app.agent.tools.impl.scrape_metadata -> app.domain.media", + "app.agent.tools.impl.scrape_metadata -> app.runtime", + "app.agent.tools.impl.scrape_metadata -> app.runtime.config", + "app.agent.tools.impl.scrape_metadata -> app.runtime.log", + "app.agent.tools.impl.scrape_metadata -> app.schemas", + "app.agent.tools.impl.scrape_metadata -> app.schemas.media", + "app.agent.tools.impl.scrape_metadata -> app.schemas.types", + "app.agent.tools.impl.scrape_metadata -> app.schemas.workflow", + "app.agent.tools.impl.search_media -> app.agent", + "app.agent.tools.impl.search_media -> app.agent.tools", + "app.agent.tools.impl.search_media -> app.agent.tools.base", + "app.agent.tools.impl.search_media -> app.agent.tools.impl", + "app.agent.tools.impl.search_media -> app.agent.tools.impl._music_utils", + "app.agent.tools.impl.search_media -> app.agent.tools.tags", + "app.agent.tools.impl.search_media -> app.chain", + "app.agent.tools.impl.search_media -> app.chain.media", + "app.agent.tools.impl.search_media -> app.domain", + "app.agent.tools.impl.search_media -> app.domain.media", + "app.agent.tools.impl.search_media -> app.runtime", + "app.agent.tools.impl.search_media -> app.runtime.log", + "app.agent.tools.impl.search_media -> app.schemas", + "app.agent.tools.impl.search_media -> app.schemas.media", + "app.agent.tools.impl.search_media -> app.schemas.types", + "app.agent.tools.impl.search_person -> app.agent", + "app.agent.tools.impl.search_person -> app.agent.tools", + "app.agent.tools.impl.search_person -> app.agent.tools.base", + "app.agent.tools.impl.search_person -> app.agent.tools.tags", + "app.agent.tools.impl.search_person -> app.chain", + "app.agent.tools.impl.search_person -> app.chain.media", + "app.agent.tools.impl.search_person -> app.runtime", + "app.agent.tools.impl.search_person -> app.runtime.log", + "app.agent.tools.impl.search_person_credits -> app.agent", + "app.agent.tools.impl.search_person_credits -> app.agent.tools", + "app.agent.tools.impl.search_person_credits -> app.agent.tools.base", + "app.agent.tools.impl.search_person_credits -> app.agent.tools.tags", + "app.agent.tools.impl.search_person_credits -> app.chain", + "app.agent.tools.impl.search_person_credits -> app.chain.bangumi", + "app.agent.tools.impl.search_person_credits -> app.chain.douban", + "app.agent.tools.impl.search_person_credits -> app.chain.tmdb", + "app.agent.tools.impl.search_person_credits -> app.runtime", + "app.agent.tools.impl.search_person_credits -> app.runtime.log", + "app.agent.tools.impl.search_person_credits -> app.schemas", + "app.agent.tools.impl.search_person_credits -> app.schemas.media", + "app.agent.tools.impl.search_subscribe -> app.agent", + "app.agent.tools.impl.search_subscribe -> app.agent.tools", + "app.agent.tools.impl.search_subscribe -> app.agent.tools.base", + "app.agent.tools.impl.search_subscribe -> app.agent.tools.tags", + "app.agent.tools.impl.search_subscribe -> app.chain", + "app.agent.tools.impl.search_subscribe -> app.chain.subscribe", + "app.agent.tools.impl.search_subscribe -> app.db", + "app.agent.tools.impl.search_subscribe -> app.db.oper", + "app.agent.tools.impl.search_subscribe -> app.db.oper.subscribe", + "app.agent.tools.impl.search_subscribe -> app.runtime", + "app.agent.tools.impl.search_subscribe -> app.runtime.log", + "app.agent.tools.impl.search_subscribe -> app.schemas", + "app.agent.tools.impl.search_subscribe -> app.schemas.types", + "app.agent.tools.impl.search_torrents -> app.agent", + "app.agent.tools.impl.search_torrents -> app.agent.tools", + "app.agent.tools.impl.search_torrents -> app.agent.tools.base", + "app.agent.tools.impl.search_torrents -> app.agent.tools.impl", + "app.agent.tools.impl.search_torrents -> app.agent.tools.impl._torrent_search_utils", + "app.agent.tools.impl.search_torrents -> app.agent.tools.tags", + "app.agent.tools.impl.search_torrents -> app.application", + "app.agent.tools.impl.search_torrents -> app.application.site", + "app.agent.tools.impl.search_torrents -> app.chain", + "app.agent.tools.impl.search_torrents -> app.chain.search", + "app.agent.tools.impl.search_torrents -> app.db", + "app.agent.tools.impl.search_torrents -> app.db.oper", + "app.agent.tools.impl.search_torrents -> app.db.oper.systemconfig", + "app.agent.tools.impl.search_torrents -> app.domain", + "app.agent.tools.impl.search_torrents -> app.domain.media", + "app.agent.tools.impl.search_torrents -> app.runtime", + "app.agent.tools.impl.search_torrents -> app.runtime.log", + "app.agent.tools.impl.search_torrents -> app.schemas", + "app.agent.tools.impl.search_torrents -> app.schemas.types", + "app.agent.tools.impl.search_web -> app.agent", + "app.agent.tools.impl.search_web -> app.agent.tools", + "app.agent.tools.impl.search_web -> app.agent.tools.base", + "app.agent.tools.impl.search_web -> app.agent.tools.tags", + "app.agent.tools.impl.search_web -> app.runtime", + "app.agent.tools.impl.search_web -> app.runtime.config", + "app.agent.tools.impl.search_web -> app.runtime.log", + "app.agent.tools.impl.send_local_file -> app.agent", + "app.agent.tools.impl.send_local_file -> app.agent.tools", + "app.agent.tools.impl.send_local_file -> app.agent.tools.base", + "app.agent.tools.impl.send_local_file -> app.agent.tools.tags", + "app.agent.tools.impl.send_local_file -> app.runtime", + "app.agent.tools.impl.send_local_file -> app.runtime.log", + "app.agent.tools.impl.send_local_file -> app.schemas", + "app.agent.tools.impl.send_local_file -> app.schemas.message", + "app.agent.tools.impl.send_local_file -> app.schemas.notification", + "app.agent.tools.impl.send_local_file -> app.schemas.types", + "app.agent.tools.impl.send_message -> app.agent", + "app.agent.tools.impl.send_message -> app.agent.tools", + "app.agent.tools.impl.send_message -> app.agent.tools.base", + "app.agent.tools.impl.send_message -> app.agent.tools.tags", + "app.agent.tools.impl.send_message -> app.runtime", + "app.agent.tools.impl.send_message -> app.runtime.log", + "app.agent.tools.impl.send_message -> app.schemas", + "app.agent.tools.impl.send_message -> app.schemas.message", + "app.agent.tools.impl.send_message -> app.schemas.types", + "app.agent.tools.impl.send_voice_message -> app.agent", + "app.agent.tools.impl.send_voice_message -> app.agent.llm", + "app.agent.tools.impl.send_voice_message -> app.agent.llm.capability", + "app.agent.tools.impl.send_voice_message -> app.agent.tools", + "app.agent.tools.impl.send_voice_message -> app.agent.tools.base", + "app.agent.tools.impl.send_voice_message -> app.agent.tools.tags", + "app.agent.tools.impl.send_voice_message -> app.runtime", + "app.agent.tools.impl.send_voice_message -> app.runtime.config", + "app.agent.tools.impl.send_voice_message -> app.runtime.log", + "app.agent.tools.impl.send_voice_message -> app.schemas", + "app.agent.tools.impl.send_voice_message -> app.schemas.message", + "app.agent.tools.impl.switch_persona -> app.agent", + "app.agent.tools.impl.switch_persona -> app.agent.runtime", + "app.agent.tools.impl.switch_persona -> app.agent.tools", + "app.agent.tools.impl.switch_persona -> app.agent.tools.base", + "app.agent.tools.impl.switch_persona -> app.agent.tools.tags", + "app.agent.tools.impl.switch_persona -> app.runtime", + "app.agent.tools.impl.switch_persona -> app.runtime.log", + "app.agent.tools.impl.test_site -> app.agent", + "app.agent.tools.impl.test_site -> app.agent.tools", + "app.agent.tools.impl.test_site -> app.agent.tools.base", + "app.agent.tools.impl.test_site -> app.agent.tools.tags", + "app.agent.tools.impl.test_site -> app.chain", + "app.agent.tools.impl.test_site -> app.chain.site", + "app.agent.tools.impl.test_site -> app.db", + "app.agent.tools.impl.test_site -> app.db.oper", + "app.agent.tools.impl.test_site -> app.db.oper.site", + "app.agent.tools.impl.test_site -> app.runtime", + "app.agent.tools.impl.test_site -> app.runtime.log", + "app.agent.tools.impl.transfer_file -> app.agent", + "app.agent.tools.impl.transfer_file -> app.agent.tools", + "app.agent.tools.impl.transfer_file -> app.agent.tools.base", + "app.agent.tools.impl.transfer_file -> app.agent.tools.tags", + "app.agent.tools.impl.transfer_file -> app.chain", + "app.agent.tools.impl.transfer_file -> app.chain.transfer", + "app.agent.tools.impl.transfer_file -> app.domain", + "app.agent.tools.impl.transfer_file -> app.domain.media", + "app.agent.tools.impl.transfer_file -> app.runtime", + "app.agent.tools.impl.transfer_file -> app.runtime.log", + "app.agent.tools.impl.transfer_file -> app.schemas", + "app.agent.tools.impl.transfer_file -> app.schemas.types", + "app.agent.tools.impl.transfer_file -> app.schemas.workflow", + "app.agent.tools.impl.uninstall_plugin -> app.agent", + "app.agent.tools.impl.uninstall_plugin -> app.agent.tools", + "app.agent.tools.impl.uninstall_plugin -> app.agent.tools.base", + "app.agent.tools.impl.uninstall_plugin -> app.agent.tools.impl", + "app.agent.tools.impl.uninstall_plugin -> app.agent.tools.impl._plugin_tool_utils", + "app.agent.tools.impl.uninstall_plugin -> app.agent.tools.tags", + "app.agent.tools.impl.uninstall_plugin -> app.runtime", + "app.agent.tools.impl.uninstall_plugin -> app.runtime.log", + "app.agent.tools.impl.update_agent_task -> app.agent", + "app.agent.tools.impl.update_agent_task -> app.agent.tools", + "app.agent.tools.impl.update_agent_task -> app.agent.tools.base", + "app.agent.tools.impl.update_agent_task -> app.agent.tools.tags", + "app.agent.tools.impl.update_agent_task -> app.application", + "app.agent.tools.impl.update_agent_task -> app.application.scheduling", + "app.agent.tools.impl.update_agent_task -> app.db", + "app.agent.tools.impl.update_agent_task -> app.db.oper", + "app.agent.tools.impl.update_agent_task -> app.db.oper.agenttask", + "app.agent.tools.impl.update_agent_task -> app.runtime", + "app.agent.tools.impl.update_agent_task -> app.runtime.config", + "app.agent.tools.impl.update_agent_task -> app.runtime.scheduling", + "app.agent.tools.impl.update_custom_filter_rule -> app.agent", + "app.agent.tools.impl.update_custom_filter_rule -> app.agent.tools", + "app.agent.tools.impl.update_custom_filter_rule -> app.agent.tools.base", + "app.agent.tools.impl.update_custom_filter_rule -> app.agent.tools.impl", + "app.agent.tools.impl.update_custom_filter_rule -> app.agent.tools.impl._filter_rule_utils", + "app.agent.tools.impl.update_custom_filter_rule -> app.agent.tools.tags", + "app.agent.tools.impl.update_custom_filter_rule -> app.runtime", + "app.agent.tools.impl.update_custom_filter_rule -> app.runtime.log", + "app.agent.tools.impl.update_custom_filter_rule -> app.schemas", + "app.agent.tools.impl.update_custom_filter_rule -> app.schemas.types", + "app.agent.tools.impl.update_custom_identifiers -> app.agent", + "app.agent.tools.impl.update_custom_identifiers -> app.agent.tools", + "app.agent.tools.impl.update_custom_identifiers -> app.agent.tools.base", + "app.agent.tools.impl.update_custom_identifiers -> app.agent.tools.tags", + "app.agent.tools.impl.update_custom_identifiers -> app.db", + "app.agent.tools.impl.update_custom_identifiers -> app.db.oper", + "app.agent.tools.impl.update_custom_identifiers -> app.db.oper.systemconfig", + "app.agent.tools.impl.update_custom_identifiers -> app.domain", + "app.agent.tools.impl.update_custom_identifiers -> app.domain.metainfo", + "app.agent.tools.impl.update_custom_identifiers -> app.runtime", + "app.agent.tools.impl.update_custom_identifiers -> app.runtime.log", + "app.agent.tools.impl.update_custom_identifiers -> app.schemas", + "app.agent.tools.impl.update_custom_identifiers -> app.schemas.types", + "app.agent.tools.impl.update_download_tasks -> app.agent", + "app.agent.tools.impl.update_download_tasks -> app.agent.tools", + "app.agent.tools.impl.update_download_tasks -> app.agent.tools.base", + "app.agent.tools.impl.update_download_tasks -> app.agent.tools.tags", + "app.agent.tools.impl.update_download_tasks -> app.application", + "app.agent.tools.impl.update_download_tasks -> app.application.directory", + "app.agent.tools.impl.update_download_tasks -> app.chain", + "app.agent.tools.impl.update_download_tasks -> app.chain.download", + "app.agent.tools.impl.update_download_tasks -> app.runtime", + "app.agent.tools.impl.update_download_tasks -> app.runtime.log", + "app.agent.tools.impl.update_persona_definition -> app.agent", + "app.agent.tools.impl.update_persona_definition -> app.agent.runtime", + "app.agent.tools.impl.update_persona_definition -> app.agent.tools", + "app.agent.tools.impl.update_persona_definition -> app.agent.tools.base", + "app.agent.tools.impl.update_persona_definition -> app.agent.tools.tags", + "app.agent.tools.impl.update_persona_definition -> app.runtime", + "app.agent.tools.impl.update_persona_definition -> app.runtime.log", + "app.agent.tools.impl.update_plugin_config -> app.agent", + "app.agent.tools.impl.update_plugin_config -> app.agent.tools", + "app.agent.tools.impl.update_plugin_config -> app.agent.tools.base", + "app.agent.tools.impl.update_plugin_config -> app.agent.tools.impl", + "app.agent.tools.impl.update_plugin_config -> app.agent.tools.impl._plugin_tool_utils", + "app.agent.tools.impl.update_plugin_config -> app.agent.tools.tags", + "app.agent.tools.impl.update_plugin_config -> app.runtime", + "app.agent.tools.impl.update_plugin_config -> app.runtime.extensions", + "app.agent.tools.impl.update_plugin_config -> app.runtime.extensions.plugin_manager", + "app.agent.tools.impl.update_plugin_config -> app.runtime.log", + "app.agent.tools.impl.update_rule_group -> app.agent", + "app.agent.tools.impl.update_rule_group -> app.agent.tools", + "app.agent.tools.impl.update_rule_group -> app.agent.tools.base", + "app.agent.tools.impl.update_rule_group -> app.agent.tools.impl", + "app.agent.tools.impl.update_rule_group -> app.agent.tools.impl._filter_rule_utils", + "app.agent.tools.impl.update_rule_group -> app.agent.tools.tags", + "app.agent.tools.impl.update_rule_group -> app.runtime", + "app.agent.tools.impl.update_rule_group -> app.runtime.log", + "app.agent.tools.impl.update_rule_group -> app.schemas", + "app.agent.tools.impl.update_rule_group -> app.schemas.types", + "app.agent.tools.impl.update_site -> app.agent", + "app.agent.tools.impl.update_site -> app.agent.tools", + "app.agent.tools.impl.update_site -> app.agent.tools.base", + "app.agent.tools.impl.update_site -> app.agent.tools.tags", + "app.agent.tools.impl.update_site -> app.db", + "app.agent.tools.impl.update_site -> app.db.oper", + "app.agent.tools.impl.update_site -> app.db.oper.site", + "app.agent.tools.impl.update_site -> app.foundation", + "app.agent.tools.impl.update_site -> app.foundation.url", + "app.agent.tools.impl.update_site -> app.runtime", + "app.agent.tools.impl.update_site -> app.runtime.events", + "app.agent.tools.impl.update_site -> app.runtime.log", + "app.agent.tools.impl.update_site -> app.schemas", + "app.agent.tools.impl.update_site -> app.schemas.types", + "app.agent.tools.impl.update_site_cookie -> app.agent", + "app.agent.tools.impl.update_site_cookie -> app.agent.tools", + "app.agent.tools.impl.update_site_cookie -> app.agent.tools.base", + "app.agent.tools.impl.update_site_cookie -> app.agent.tools.tags", + "app.agent.tools.impl.update_site_cookie -> app.chain", + "app.agent.tools.impl.update_site_cookie -> app.chain.site", + "app.agent.tools.impl.update_site_cookie -> app.db", + "app.agent.tools.impl.update_site_cookie -> app.db.oper", + "app.agent.tools.impl.update_site_cookie -> app.db.oper.site", + "app.agent.tools.impl.update_site_cookie -> app.runtime", + "app.agent.tools.impl.update_site_cookie -> app.runtime.log", + "app.agent.tools.impl.update_subscribe -> app.agent", + "app.agent.tools.impl.update_subscribe -> app.agent.tools", + "app.agent.tools.impl.update_subscribe -> app.agent.tools.base", + "app.agent.tools.impl.update_subscribe -> app.agent.tools.tags", + "app.agent.tools.impl.update_subscribe -> app.db", + "app.agent.tools.impl.update_subscribe -> app.db.oper", + "app.agent.tools.impl.update_subscribe -> app.db.oper.subscribe", + "app.agent.tools.impl.update_subscribe -> app.runtime", + "app.agent.tools.impl.update_subscribe -> app.runtime.events", + "app.agent.tools.impl.update_subscribe -> app.runtime.log", + "app.agent.tools.impl.update_subscribe -> app.schemas", + "app.agent.tools.impl.update_subscribe -> app.schemas.event", + "app.agent.tools.impl.update_subscribe -> app.schemas.types", + "app.agent.tools.impl.update_system_settings -> app.agent", + "app.agent.tools.impl.update_system_settings -> app.agent.tools", + "app.agent.tools.impl.update_system_settings -> app.agent.tools.base", + "app.agent.tools.impl.update_system_settings -> app.agent.tools.impl", + "app.agent.tools.impl.update_system_settings -> app.agent.tools.impl._system_setting_utils", + "app.agent.tools.impl.update_system_settings -> app.agent.tools.tags", + "app.agent.tools.impl.update_system_settings -> app.db", + "app.agent.tools.impl.update_system_settings -> app.db.oper", + "app.agent.tools.impl.update_system_settings -> app.db.oper.systemconfig", + "app.agent.tools.impl.update_system_settings -> app.runtime", + "app.agent.tools.impl.update_system_settings -> app.runtime.config", + "app.agent.tools.impl.update_system_settings -> app.runtime.events", + "app.agent.tools.impl.update_system_settings -> app.runtime.log", + "app.agent.tools.impl.update_system_settings -> app.schemas", + "app.agent.tools.impl.update_system_settings -> app.schemas.event", + "app.agent.tools.impl.update_system_settings -> app.schemas.types", + "app.agent.tools.impl.write_file -> app.agent", + "app.agent.tools.impl.write_file -> app.agent.tools", + "app.agent.tools.impl.write_file -> app.agent.tools.base", + "app.agent.tools.impl.write_file -> app.agent.tools.impl", + "app.agent.tools.impl.write_file -> app.agent.tools.impl._file_write_utils", + "app.agent.tools.impl.write_file -> app.agent.tools.tags", + "app.agent.tools.impl.write_file -> app.runtime", + "app.agent.tools.impl.write_file -> app.runtime.log", + "app.agent.tools.manager -> app.agent", + "app.agent.tools.manager -> app.agent.policy", + "app.agent.tools.manager -> app.agent.runtime_loader", + "app.agent.tools.manager -> app.agent.tools", + "app.agent.tools.manager -> app.agent.tools.base", + "app.agent.tools.manager -> app.agent.tools.catalog", + "app.agent.tools.manager -> app.runtime", + "app.agent.tools.manager -> app.runtime.extensions", + "app.agent.tools.manager -> app.runtime.extensions.plugin_manager", + "app.agent.tools.manager -> app.runtime.log", + "app.api.apiv1 -> app.api", + "app.api.apiv1 -> app.api.router_specs", + "app.api.deps -> app.adapters", + "app.api.deps -> app.adapters.external", + "app.api.deps -> app.adapters.external.server", + "app.api.deps -> app.application", + "app.api.deps -> app.application.commands", + "app.api.deps -> app.application.history", + "app.api.deps -> app.application.plugin", + "app.api.deps -> app.application.plugin.config", + "app.api.deps -> app.application.plugins", + "app.api.deps -> app.application.scheduling", + "app.api.deps -> app.application.security", + "app.api.deps -> app.application.security.access", + "app.api.deps -> app.application.site", + "app.api.deps -> app.application.site.mutation", + "app.api.deps -> app.application.subscription", + "app.api.deps -> app.application.subscription.delete", + "app.api.deps -> app.application.subscription.identity", + "app.api.deps -> app.application.subscription.search", + "app.api.deps -> app.application.workflow", + "app.api.deps -> app.chain", + "app.api.deps -> app.chain.storage", + "app.api.deps -> app.db", + "app.api.deps -> app.db.models", + "app.api.deps -> app.db.models.user", + "app.api.deps -> app.db.oper", + "app.api.deps -> app.db.oper.downloadhistory", + "app.api.deps -> app.db.oper.site", + "app.api.deps -> app.db.oper.subscribe", + "app.api.deps -> app.db.oper.systemconfig", + "app.api.deps -> app.db.oper.transferhistory", + "app.api.deps -> app.db.oper.workflow", + "app.api.deps -> app.db.uow", + "app.api.deps -> app.domain", + "app.api.deps -> app.domain.site", + "app.api.deps -> app.foundation", + "app.api.deps -> app.foundation.url", + "app.api.deps -> app.runtime", + "app.api.deps -> app.runtime.config", + "app.api.deps -> app.runtime.events", + "app.api.deps -> app.runtime.extensions", + "app.api.deps -> app.runtime.extensions.plugin_manager", + "app.api.deps -> app.runtime.log", + "app.api.deps -> app.scheduler", + "app.api.deps -> app.schemas", + "app.api.deps -> app.schemas.event", + "app.api.deps -> app.schemas.token", + "app.api.deps -> app.schemas.types", + "app.api.deps -> app.schemas.workflow", + "app.api.deps -> app.workflow", + "app.api.endpoints.agent -> app.agent", + "app.api.endpoints.agent -> app.agent.callback", + "app.api.endpoints.agent -> app.agent.contracts", + "app.api.endpoints.agent -> app.agent.llm", + "app.api.endpoints.agent -> app.agent.llm.capability", + "app.api.endpoints.agent -> app.agent.mcp", + "app.api.endpoints.agent -> app.agent.runtime_loader", + "app.api.endpoints.agent -> app.api", + "app.api.endpoints.agent -> app.api.deps", + "app.api.endpoints.agent -> app.api.response", + "app.api.endpoints.agent -> app.application", + "app.api.endpoints.agent -> app.application.messaging", + "app.api.endpoints.agent -> app.application.messaging.agent", + "app.api.endpoints.agent -> app.application.messaging.router", + "app.api.endpoints.agent -> app.chain", + "app.api.endpoints.agent -> app.chain.message", + "app.api.endpoints.agent -> app.command", + "app.api.endpoints.agent -> app.db", + "app.api.endpoints.agent -> app.db.models", + "app.api.endpoints.agent -> app.db.models.agentchat", + "app.api.endpoints.agent -> app.db.oper", + "app.api.endpoints.agent -> app.db.oper.agentchat", + "app.api.endpoints.agent -> app.db.oper.user", + "app.api.endpoints.agent -> app.runtime", + "app.api.endpoints.agent -> app.runtime.config", + "app.api.endpoints.agent -> app.runtime.events", + "app.api.endpoints.agent -> app.runtime.localization", + "app.api.endpoints.agent -> app.runtime.log", + "app.api.endpoints.agent -> app.schemas", + "app.api.endpoints.agent -> app.schemas.agent", + "app.api.endpoints.agent -> app.schemas.message", + "app.api.endpoints.agent -> app.schemas.response", + "app.api.endpoints.agent -> app.schemas.types", + "app.api.endpoints.anilist -> app.api", + "app.api.endpoints.anilist -> app.api.response", + "app.api.endpoints.anilist -> app.application", + "app.api.endpoints.anilist -> app.application.security", + "app.api.endpoints.anilist -> app.application.security.access", + "app.api.endpoints.anilist -> app.chain", + "app.api.endpoints.anilist -> app.chain.anilist", + "app.api.endpoints.anilist -> app.domain", + "app.api.endpoints.anilist -> app.domain.context", + "app.api.endpoints.anilist -> app.schemas", + "app.api.endpoints.anilist -> app.schemas.context", + "app.api.endpoints.anilist -> app.schemas.token", + "app.api.endpoints.anilist -> app.schemas.workflow", + "app.api.endpoints.anthropic -> app.agent", + "app.api.endpoints.anthropic -> app.agent.runtime_loader", + "app.api.endpoints.anthropic -> app.api", + "app.api.endpoints.anthropic -> app.api.endpoints", + "app.api.endpoints.anthropic -> app.api.endpoints.openai", + "app.api.endpoints.anthropic -> app.api.openai_utils", + "app.api.endpoints.anthropic -> app.application", + "app.api.endpoints.anthropic -> app.application.security", + "app.api.endpoints.anthropic -> app.application.security.access", + "app.api.endpoints.anthropic -> app.runtime", + "app.api.endpoints.anthropic -> app.runtime.config", + "app.api.endpoints.anthropic -> app.schemas", + "app.api.endpoints.anthropic -> app.schemas.openai", + "app.api.endpoints.auth -> app.api", + "app.api.endpoints.auth -> app.api.response", + "app.api.endpoints.auth -> app.application", + "app.api.endpoints.auth -> app.application.security", + "app.api.endpoints.auth -> app.application.security.auth", + "app.api.endpoints.auth -> app.db", + "app.api.endpoints.auth -> app.db.models", + "app.api.endpoints.auth -> app.db.models.passkey", + "app.api.endpoints.auth -> app.db.models.user", + "app.api.endpoints.auth -> app.runtime", + "app.api.endpoints.auth -> app.runtime.extensions", + "app.api.endpoints.auth -> app.runtime.extensions.plugin_manager", + "app.api.endpoints.auth -> app.schemas", + "app.api.endpoints.auth -> app.schemas.token", + "app.api.endpoints.auth -> app.schemas.user", + "app.api.endpoints.bangumi -> app.api", + "app.api.endpoints.bangumi -> app.api.response", + "app.api.endpoints.bangumi -> app.application", + "app.api.endpoints.bangumi -> app.application.security", + "app.api.endpoints.bangumi -> app.application.security.access", + "app.api.endpoints.bangumi -> app.chain", + "app.api.endpoints.bangumi -> app.chain.bangumi", + "app.api.endpoints.bangumi -> app.domain", + "app.api.endpoints.bangumi -> app.domain.context", + "app.api.endpoints.bangumi -> app.schemas", + "app.api.endpoints.bangumi -> app.schemas.context", + "app.api.endpoints.bangumi -> app.schemas.token", + "app.api.endpoints.bangumi -> app.schemas.workflow", + "app.api.endpoints.dashboard -> app.adapters", + "app.api.endpoints.dashboard -> app.adapters.system", + "app.api.endpoints.dashboard -> app.adapters.system.host", + "app.api.endpoints.dashboard -> app.api", + "app.api.endpoints.dashboard -> app.api.deps", + "app.api.endpoints.dashboard -> app.api.response", + "app.api.endpoints.dashboard -> app.application", + "app.api.endpoints.dashboard -> app.application.directory", + "app.api.endpoints.dashboard -> app.application.security", + "app.api.endpoints.dashboard -> app.application.security.access", + "app.api.endpoints.dashboard -> app.chain", + "app.api.endpoints.dashboard -> app.chain.dashboard", + "app.api.endpoints.dashboard -> app.chain.storage", + "app.api.endpoints.dashboard -> app.db", + "app.api.endpoints.dashboard -> app.db.models", + "app.api.endpoints.dashboard -> app.db.models.transferhistory", + "app.api.endpoints.dashboard -> app.runtime", + "app.api.endpoints.dashboard -> app.runtime.config", + "app.api.endpoints.dashboard -> app.scheduler", + "app.api.endpoints.dashboard -> app.schemas", + "app.api.endpoints.dashboard -> app.schemas.dashboard", + "app.api.endpoints.dashboard -> app.schemas.response", + "app.api.endpoints.dashboard -> app.schemas.types", + "app.api.endpoints.discover -> app.api", + "app.api.endpoints.discover -> app.api.response", + "app.api.endpoints.discover -> app.application", + "app.api.endpoints.discover -> app.application.security", + "app.api.endpoints.discover -> app.application.security.access", + "app.api.endpoints.discover -> app.chain", + "app.api.endpoints.discover -> app.chain.bangumi", + "app.api.endpoints.discover -> app.chain.douban", + "app.api.endpoints.discover -> app.chain.tmdb", + "app.api.endpoints.discover -> app.runtime", + "app.api.endpoints.discover -> app.runtime.events", + "app.api.endpoints.discover -> app.schemas", + "app.api.endpoints.discover -> app.schemas.event", + "app.api.endpoints.discover -> app.schemas.token", + "app.api.endpoints.discover -> app.schemas.types", + "app.api.endpoints.discover -> app.schemas.workflow", + "app.api.endpoints.douban -> app.api", + "app.api.endpoints.douban -> app.api.response", + "app.api.endpoints.douban -> app.application", + "app.api.endpoints.douban -> app.application.security", + "app.api.endpoints.douban -> app.application.security.access", + "app.api.endpoints.douban -> app.chain", + "app.api.endpoints.douban -> app.chain.douban", + "app.api.endpoints.douban -> app.domain", + "app.api.endpoints.douban -> app.domain.context", + "app.api.endpoints.douban -> app.schemas", + "app.api.endpoints.douban -> app.schemas.context", + "app.api.endpoints.douban -> app.schemas.token", + "app.api.endpoints.douban -> app.schemas.types", + "app.api.endpoints.douban -> app.schemas.workflow", + "app.api.endpoints.download -> app.api", + "app.api.endpoints.download -> app.api.deps", + "app.api.endpoints.download -> app.api.response", + "app.api.endpoints.download -> app.application", + "app.api.endpoints.download -> app.application.directory", + "app.api.endpoints.download -> app.application.security", + "app.api.endpoints.download -> app.application.security.access", + "app.api.endpoints.download -> app.application.security.url", + "app.api.endpoints.download -> app.chain", + "app.api.endpoints.download -> app.chain.download", + "app.api.endpoints.download -> app.chain.media", + "app.api.endpoints.download -> app.db", + "app.api.endpoints.download -> app.db.models", + "app.api.endpoints.download -> app.db.models.user", + "app.api.endpoints.download -> app.db.oper", + "app.api.endpoints.download -> app.db.oper.site", + "app.api.endpoints.download -> app.db.oper.systemconfig", + "app.api.endpoints.download -> app.domain", + "app.api.endpoints.download -> app.domain.context", + "app.api.endpoints.download -> app.domain.media", + "app.api.endpoints.download -> app.domain.meta", + "app.api.endpoints.download -> app.domain.meta.metamusic", + "app.api.endpoints.download -> app.domain.metainfo", + "app.api.endpoints.download -> app.schemas", + "app.api.endpoints.download -> app.schemas.common", + "app.api.endpoints.download -> app.schemas.download", + "app.api.endpoints.download -> app.schemas.file", + "app.api.endpoints.download -> app.schemas.response", + "app.api.endpoints.download -> app.schemas.search", + "app.api.endpoints.download -> app.schemas.system", + "app.api.endpoints.download -> app.schemas.token", + "app.api.endpoints.download -> app.schemas.transfer", + "app.api.endpoints.download -> app.schemas.types", + "app.api.endpoints.download -> app.schemas.workflow", + "app.api.endpoints.history -> app.agent", + "app.api.endpoints.history -> app.agent.contracts", + "app.api.endpoints.history -> app.agent.prompt", + "app.api.endpoints.history -> app.agent.prompt.transfer_redo", + "app.api.endpoints.history -> app.agent.runtime_loader", + "app.api.endpoints.history -> app.api", + "app.api.endpoints.history -> app.api.deps", + "app.api.endpoints.history -> app.api.response", + "app.api.endpoints.history -> app.application", + "app.api.endpoints.history -> app.application.history", + "app.api.endpoints.history -> app.application.security", + "app.api.endpoints.history -> app.application.security.access", + "app.api.endpoints.history -> app.db", + "app.api.endpoints.history -> app.db.models", + "app.api.endpoints.history -> app.db.models.downloadhistory", + "app.api.endpoints.history -> app.db.models.transferhistory", + "app.api.endpoints.history -> app.foundation", + "app.api.endpoints.history -> app.foundation.text", + "app.api.endpoints.history -> app.runtime", + "app.api.endpoints.history -> app.runtime.config", + "app.api.endpoints.history -> app.runtime.log", + "app.api.endpoints.history -> app.runtime.progress", + "app.api.endpoints.history -> app.schemas", + "app.api.endpoints.history -> app.schemas.common", + "app.api.endpoints.history -> app.schemas.history", + "app.api.endpoints.history -> app.schemas.response", + "app.api.endpoints.history -> app.schemas.token", + "app.api.endpoints.llm -> app.agent", + "app.api.endpoints.llm -> app.agent.llm", + "app.api.endpoints.llm -> app.agent.llm.provider", + "app.api.endpoints.llm -> app.api", + "app.api.endpoints.llm -> app.api.deps", + "app.api.endpoints.llm -> app.api.response", + "app.api.endpoints.llm -> app.db", + "app.api.endpoints.llm -> app.db.models", + "app.api.endpoints.llm -> app.schemas", + "app.api.endpoints.llm -> app.schemas.common", + "app.api.endpoints.llm -> app.schemas.response", + "app.api.endpoints.login -> app.api", + "app.api.endpoints.login -> app.api.response", + "app.api.endpoints.login -> app.application", + "app.api.endpoints.login -> app.application.image", + "app.api.endpoints.login -> app.application.security", + "app.api.endpoints.login -> app.application.security.access", + "app.api.endpoints.login -> app.application.site", + "app.api.endpoints.login -> app.chain", + "app.api.endpoints.login -> app.chain.user", + "app.api.endpoints.login -> app.db", + "app.api.endpoints.login -> app.db.oper", + "app.api.endpoints.login -> app.db.oper.systemconfig", + "app.api.endpoints.login -> app.runtime", + "app.api.endpoints.login -> app.runtime.config", + "app.api.endpoints.login -> app.schemas", + "app.api.endpoints.login -> app.schemas.response", + "app.api.endpoints.login -> app.schemas.token", + "app.api.endpoints.login -> app.schemas.types", + "app.api.endpoints.mcp -> app.agent", + "app.api.endpoints.mcp -> app.agent.tools", + "app.api.endpoints.mcp -> app.agent.tools.manager", + "app.api.endpoints.mcp -> app.api", + "app.api.endpoints.mcp -> app.api.response", + "app.api.endpoints.mcp -> app.application", + "app.api.endpoints.mcp -> app.application.security", + "app.api.endpoints.mcp -> app.application.security.access", + "app.api.endpoints.mcp -> app.runtime", + "app.api.endpoints.mcp -> app.runtime.log", + "app.api.endpoints.mcp -> app.schemas", + "app.api.endpoints.mcp -> app.schemas.mcp", + "app.api.endpoints.mcp -> app.schemas.response", + "app.api.endpoints.media -> app.api", + "app.api.endpoints.media -> app.api.deps", + "app.api.endpoints.media -> app.api.response", + "app.api.endpoints.media -> app.application", + "app.api.endpoints.media -> app.application.security", + "app.api.endpoints.media -> app.application.security.access", + "app.api.endpoints.media -> app.chain", + "app.api.endpoints.media -> app.chain.media", + "app.api.endpoints.media -> app.chain.scraping", + "app.api.endpoints.media -> app.chain.tmdb", + "app.api.endpoints.media -> app.db", + "app.api.endpoints.media -> app.db.models", + "app.api.endpoints.media -> app.domain", + "app.api.endpoints.media -> app.domain.context", + "app.api.endpoints.media -> app.domain.media", + "app.api.endpoints.media -> app.domain.meta", + "app.api.endpoints.media -> app.domain.meta.metabase", + "app.api.endpoints.media -> app.domain.meta.metamusic", + "app.api.endpoints.media -> app.domain.metainfo", + "app.api.endpoints.media -> app.runtime", + "app.api.endpoints.media -> app.runtime.config", + "app.api.endpoints.media -> app.schemas", + "app.api.endpoints.media -> app.schemas.category", + "app.api.endpoints.media -> app.schemas.context", + "app.api.endpoints.media -> app.schemas.media", + "app.api.endpoints.media -> app.schemas.response", + "app.api.endpoints.media -> app.schemas.token", + "app.api.endpoints.media -> app.schemas.types", + "app.api.endpoints.media -> app.schemas.workflow", + "app.api.endpoints.mediaserver -> app.api", + "app.api.endpoints.mediaserver -> app.api.response", + "app.api.endpoints.mediaserver -> app.application", + "app.api.endpoints.mediaserver -> app.application.mediaserver", + "app.api.endpoints.mediaserver -> app.application.security", + "app.api.endpoints.mediaserver -> app.application.security.access", + "app.api.endpoints.mediaserver -> app.chain", + "app.api.endpoints.mediaserver -> app.chain.download", + "app.api.endpoints.mediaserver -> app.chain.mediaserver", + "app.api.endpoints.mediaserver -> app.db", + "app.api.endpoints.mediaserver -> app.db.models", + "app.api.endpoints.mediaserver -> app.db.oper", + "app.api.endpoints.mediaserver -> app.db.oper.mediaserver", + "app.api.endpoints.mediaserver -> app.db.oper.systemconfig", + "app.api.endpoints.mediaserver -> app.domain", + "app.api.endpoints.mediaserver -> app.domain.context", + "app.api.endpoints.mediaserver -> app.domain.metainfo", + "app.api.endpoints.mediaserver -> app.schemas", + "app.api.endpoints.mediaserver -> app.schemas.common", + "app.api.endpoints.mediaserver -> app.schemas.media", + "app.api.endpoints.mediaserver -> app.schemas.mediaserver", + "app.api.endpoints.mediaserver -> app.schemas.response", + "app.api.endpoints.mediaserver -> app.schemas.token", + "app.api.endpoints.mediaserver -> app.schemas.types", + "app.api.endpoints.mediaserver -> app.schemas.workflow", + "app.api.endpoints.message -> app.adapters", + "app.api.endpoints.message -> app.adapters.external", + "app.api.endpoints.message -> app.adapters.external.wechat_crypt", + "app.api.endpoints.message -> app.api", + "app.api.endpoints.message -> app.api.deps", + "app.api.endpoints.message -> app.api.response", + "app.api.endpoints.message -> app.application", + "app.api.endpoints.message -> app.application.security", + "app.api.endpoints.message -> app.application.security.access", + "app.api.endpoints.message -> app.chain", + "app.api.endpoints.message -> app.chain.message", + "app.api.endpoints.message -> app.db", + "app.api.endpoints.message -> app.db.models", + "app.api.endpoints.message -> app.db.oper", + "app.api.endpoints.message -> app.db.oper.message", + "app.api.endpoints.message -> app.db.oper.systemconfig", + "app.api.endpoints.message -> app.runtime", + "app.api.endpoints.message -> app.runtime.config", + "app.api.endpoints.message -> app.runtime.extensions", + "app.api.endpoints.message -> app.runtime.extensions.service_registry", + "app.api.endpoints.message -> app.runtime.log", + "app.api.endpoints.message -> app.schemas", + "app.api.endpoints.message -> app.schemas.message", + "app.api.endpoints.message -> app.schemas.response", + "app.api.endpoints.message -> app.schemas.token", + "app.api.endpoints.message -> app.schemas.types", + "app.api.endpoints.mfa -> app.api", + "app.api.endpoints.mfa -> app.api.deps", + "app.api.endpoints.mfa -> app.api.response", + "app.api.endpoints.mfa -> app.application", + "app.api.endpoints.mfa -> app.application.security", + "app.api.endpoints.mfa -> app.application.security.access", + "app.api.endpoints.mfa -> app.application.security.otp", + "app.api.endpoints.mfa -> app.application.security.passkey", + "app.api.endpoints.mfa -> app.application.site", + "app.api.endpoints.mfa -> app.db", + "app.api.endpoints.mfa -> app.db.models", + "app.api.endpoints.mfa -> app.db.models.passkey", + "app.api.endpoints.mfa -> app.db.models.user", + "app.api.endpoints.mfa -> app.db.oper", + "app.api.endpoints.mfa -> app.db.oper.systemconfig", + "app.api.endpoints.mfa -> app.runtime", + "app.api.endpoints.mfa -> app.runtime.config", + "app.api.endpoints.mfa -> app.runtime.log", + "app.api.endpoints.mfa -> app.schemas", + "app.api.endpoints.mfa -> app.schemas.mcp", + "app.api.endpoints.mfa -> app.schemas.mfa", + "app.api.endpoints.mfa -> app.schemas.response", + "app.api.endpoints.mfa -> app.schemas.token", + "app.api.endpoints.mfa -> app.schemas.types", + "app.api.endpoints.music -> app.api", + "app.api.endpoints.music -> app.api.deps", + "app.api.endpoints.music -> app.api.response", + "app.api.endpoints.music -> app.application", + "app.api.endpoints.music -> app.application.security", + "app.api.endpoints.music -> app.application.security.access", + "app.api.endpoints.music -> app.chain", + "app.api.endpoints.music -> app.chain.listenbrainz", + "app.api.endpoints.music -> app.chain.media", + "app.api.endpoints.music -> app.chain.musicbrainz", + "app.api.endpoints.music -> app.chain.recommend", + "app.api.endpoints.music -> app.db", + "app.api.endpoints.music -> app.db.models", + "app.api.endpoints.music -> app.db.models.user", + "app.api.endpoints.music -> app.domain", + "app.api.endpoints.music -> app.domain.context", + "app.api.endpoints.music -> app.schemas", + "app.api.endpoints.music -> app.schemas.music", + "app.api.endpoints.music -> app.schemas.response", + "app.api.endpoints.music -> app.schemas.token", + "app.api.endpoints.music -> app.schemas.transfer", + "app.api.endpoints.music -> app.schemas.types", + "app.api.endpoints.notification -> app.api", + "app.api.endpoints.notification -> app.api.deps", + "app.api.endpoints.notification -> app.api.response", + "app.api.endpoints.notification -> app.chain", + "app.api.endpoints.notification -> app.chain.notification", + "app.api.endpoints.notification -> app.db", + "app.api.endpoints.notification -> app.db.models", + "app.api.endpoints.notification -> app.schemas", + "app.api.endpoints.notification -> app.schemas.common", + "app.api.endpoints.notification -> app.schemas.response", + "app.api.endpoints.openai -> app.agent", + "app.api.endpoints.openai -> app.agent.callback", + "app.api.endpoints.openai -> app.agent.contracts", + "app.api.endpoints.openai -> app.agent.runtime_loader", + "app.api.endpoints.openai -> app.api", + "app.api.endpoints.openai -> app.api.openai_utils", + "app.api.endpoints.openai -> app.application", + "app.api.endpoints.openai -> app.application.security", + "app.api.endpoints.openai -> app.application.security.access", + "app.api.endpoints.openai -> app.runtime", + "app.api.endpoints.openai -> app.runtime.config", + "app.api.endpoints.openai -> app.schemas", + "app.api.endpoints.openai -> app.schemas.openai", + "app.api.endpoints.openai -> app.schemas.types", + "app.api.endpoints.plugin -> app.adapters", + "app.api.endpoints.plugin -> app.adapters.external", + "app.api.endpoints.plugin -> app.adapters.external.market", + "app.api.endpoints.plugin -> app.adapters.external.server", + "app.api.endpoints.plugin -> app.adapters.system", + "app.api.endpoints.plugin -> app.adapters.system.plugin", + "app.api.endpoints.plugin -> app.adapters.system.plugin.package", + "app.api.endpoints.plugin -> app.api", + "app.api.endpoints.plugin -> app.api.deps", + "app.api.endpoints.plugin -> app.api.response", + "app.api.endpoints.plugin -> app.application", + "app.api.endpoints.plugin -> app.application.commands", + "app.api.endpoints.plugin -> app.application.plugin", + "app.api.endpoints.plugin -> app.application.plugin.config", + "app.api.endpoints.plugin -> app.application.plugin.install", + "app.api.endpoints.plugin -> app.application.plugins", + "app.api.endpoints.plugin -> app.application.scheduling", + "app.api.endpoints.plugin -> app.application.security", + "app.api.endpoints.plugin -> app.application.security.access", + "app.api.endpoints.plugin -> app.db", + "app.api.endpoints.plugin -> app.db.models", + "app.api.endpoints.plugin -> app.db.oper", + "app.api.endpoints.plugin -> app.db.oper.systemconfig", + "app.api.endpoints.plugin -> app.runtime", + "app.api.endpoints.plugin -> app.runtime.cache", + "app.api.endpoints.plugin -> app.runtime.config", + "app.api.endpoints.plugin -> app.runtime.extensions", + "app.api.endpoints.plugin -> app.runtime.extensions.plugin_manager", + "app.api.endpoints.plugin -> app.runtime.log", + "app.api.endpoints.plugin -> app.schemas", + "app.api.endpoints.plugin -> app.schemas.common", + "app.api.endpoints.plugin -> app.schemas.plugin", + "app.api.endpoints.plugin -> app.schemas.response", + "app.api.endpoints.plugin -> app.schemas.token", + "app.api.endpoints.plugin -> app.schemas.types", + "app.api.endpoints.recommend -> app.api", + "app.api.endpoints.recommend -> app.api.response", + "app.api.endpoints.recommend -> app.application", + "app.api.endpoints.recommend -> app.application.security", + "app.api.endpoints.recommend -> app.application.security.access", + "app.api.endpoints.recommend -> app.chain", + "app.api.endpoints.recommend -> app.chain.recommend", + "app.api.endpoints.recommend -> app.runtime", + "app.api.endpoints.recommend -> app.runtime.events", + "app.api.endpoints.recommend -> app.schemas", + "app.api.endpoints.recommend -> app.schemas.event", + "app.api.endpoints.recommend -> app.schemas.exception", + "app.api.endpoints.recommend -> app.schemas.token", + "app.api.endpoints.recommend -> app.schemas.transfer", + "app.api.endpoints.recommend -> app.schemas.types", + "app.api.endpoints.recommend -> app.schemas.workflow", + "app.api.endpoints.search -> app.api", + "app.api.endpoints.search -> app.api.response", + "app.api.endpoints.search -> app.application", + "app.api.endpoints.search -> app.application.security", + "app.api.endpoints.search -> app.application.security.access", + "app.api.endpoints.search -> app.application.security.url", + "app.api.endpoints.search -> app.chain", + "app.api.endpoints.search -> app.chain.search", + "app.api.endpoints.search -> app.domain", + "app.api.endpoints.search -> app.domain.media", + "app.api.endpoints.search -> app.runtime", + "app.api.endpoints.search -> app.runtime.localization", + "app.api.endpoints.search -> app.runtime.log", + "app.api.endpoints.search -> app.schemas", + "app.api.endpoints.search -> app.schemas.media", + "app.api.endpoints.search -> app.schemas.response", + "app.api.endpoints.search -> app.schemas.search", + "app.api.endpoints.search -> app.schemas.system", + "app.api.endpoints.search -> app.schemas.token", + "app.api.endpoints.search -> app.schemas.types", + "app.api.endpoints.search -> app.schemas.workflow", + "app.api.endpoints.site -> app.api", + "app.api.endpoints.site -> app.api.deps", + "app.api.endpoints.site -> app.api.endpoints", + "app.api.endpoints.site -> app.api.endpoints.plugin", + "app.api.endpoints.site -> app.api.response", + "app.api.endpoints.site -> app.application", + "app.api.endpoints.site -> app.application.security", + "app.api.endpoints.site -> app.application.security.access", + "app.api.endpoints.site -> app.application.site", + "app.api.endpoints.site -> app.application.site.mutation", + "app.api.endpoints.site -> app.chain", + "app.api.endpoints.site -> app.chain.site", + "app.api.endpoints.site -> app.chain.torrents", + "app.api.endpoints.site -> app.command", + "app.api.endpoints.site -> app.db", + "app.api.endpoints.site -> app.db.models", + "app.api.endpoints.site -> app.db.models.site", + "app.api.endpoints.site -> app.db.models.siteicon", + "app.api.endpoints.site -> app.db.models.sitestatistic", + "app.api.endpoints.site -> app.db.models.siteuserdata", + "app.api.endpoints.site -> app.db.oper", + "app.api.endpoints.site -> app.db.oper.site", + "app.api.endpoints.site -> app.db.oper.systemconfig", + "app.api.endpoints.site -> app.domain", + "app.api.endpoints.site -> app.domain.site", + "app.api.endpoints.site -> app.runtime", + "app.api.endpoints.site -> app.runtime.events", + "app.api.endpoints.site -> app.runtime.extensions", + "app.api.endpoints.site -> app.runtime.extensions.plugin_manager", + "app.api.endpoints.site -> app.runtime.log", + "app.api.endpoints.site -> app.scheduler", + "app.api.endpoints.site -> app.schemas", + "app.api.endpoints.site -> app.schemas.common", + "app.api.endpoints.site -> app.schemas.response", + "app.api.endpoints.site -> app.schemas.site", + "app.api.endpoints.site -> app.schemas.system", + "app.api.endpoints.site -> app.schemas.token", + "app.api.endpoints.site -> app.schemas.types", + "app.api.endpoints.site -> app.schemas.workflow", + "app.api.endpoints.storage -> app.api", + "app.api.endpoints.storage -> app.api.deps", + "app.api.endpoints.storage -> app.api.response", + "app.api.endpoints.storage -> app.chain", + "app.api.endpoints.storage -> app.chain.media", + "app.api.endpoints.storage -> app.chain.storage", + "app.api.endpoints.storage -> app.chain.transfer", + "app.api.endpoints.storage -> app.db", + "app.api.endpoints.storage -> app.db.models", + "app.api.endpoints.storage -> app.foundation", + "app.api.endpoints.storage -> app.foundation.text", + "app.api.endpoints.storage -> app.runtime", + "app.api.endpoints.storage -> app.runtime.config", + "app.api.endpoints.storage -> app.runtime.progress", + "app.api.endpoints.storage -> app.schemas", + "app.api.endpoints.storage -> app.schemas.common", + "app.api.endpoints.storage -> app.schemas.response", + "app.api.endpoints.storage -> app.schemas.types", + "app.api.endpoints.storage -> app.schemas.workflow", + "app.api.endpoints.subscribe -> app.adapters", + "app.api.endpoints.subscribe -> app.adapters.external", + "app.api.endpoints.subscribe -> app.adapters.external.server", + "app.api.endpoints.subscribe -> app.api", + "app.api.endpoints.subscribe -> app.api.deps", + "app.api.endpoints.subscribe -> app.api.response", + "app.api.endpoints.subscribe -> app.application", + "app.api.endpoints.subscribe -> app.application.security", + "app.api.endpoints.subscribe -> app.application.security.access", + "app.api.endpoints.subscribe -> app.application.subscription", + "app.api.endpoints.subscribe -> app.application.subscription.delete", + "app.api.endpoints.subscribe -> app.application.subscription.identity", + "app.api.endpoints.subscribe -> app.application.subscription.search", + "app.api.endpoints.subscribe -> app.chain", + "app.api.endpoints.subscribe -> app.chain.subscribe", + "app.api.endpoints.subscribe -> app.db", + "app.api.endpoints.subscribe -> app.db.models", + "app.api.endpoints.subscribe -> app.db.models.subscribe", + "app.api.endpoints.subscribe -> app.db.models.subscribehistory", + "app.api.endpoints.subscribe -> app.db.models.user", + "app.api.endpoints.subscribe -> app.db.oper", + "app.api.endpoints.subscribe -> app.db.oper.systemconfig", + "app.api.endpoints.subscribe -> app.domain", + "app.api.endpoints.subscribe -> app.domain.context", + "app.api.endpoints.subscribe -> app.domain.metainfo", + "app.api.endpoints.subscribe -> app.runtime", + "app.api.endpoints.subscribe -> app.runtime.config", + "app.api.endpoints.subscribe -> app.runtime.events", + "app.api.endpoints.subscribe -> app.scheduler", + "app.api.endpoints.subscribe -> app.schemas", + "app.api.endpoints.subscribe -> app.schemas.common", + "app.api.endpoints.subscribe -> app.schemas.event", + "app.api.endpoints.subscribe -> app.schemas.media", + "app.api.endpoints.subscribe -> app.schemas.response", + "app.api.endpoints.subscribe -> app.schemas.subscribe", + "app.api.endpoints.subscribe -> app.schemas.token", + "app.api.endpoints.subscribe -> app.schemas.types", + "app.api.endpoints.subscribe -> app.schemas.workflow", + "app.api.endpoints.system -> app.adapters", + "app.api.endpoints.system -> app.adapters.external", + "app.api.endpoints.system -> app.adapters.external.market", + "app.api.endpoints.system -> app.adapters.external.server", + "app.api.endpoints.system -> app.adapters.network", + "app.api.endpoints.system -> app.adapters.network.http", + "app.api.endpoints.system -> app.adapters.system", + "app.api.endpoints.system -> app.adapters.system.rust", + "app.api.endpoints.system -> app.agent", + "app.api.endpoints.system -> app.agent.llm", + "app.api.endpoints.system -> app.agent.llm.server_tools", + "app.api.endpoints.system -> app.api", + "app.api.endpoints.system -> app.api.deps", + "app.api.endpoints.system -> app.api.response", + "app.api.endpoints.system -> app.application", + "app.api.endpoints.system -> app.application.image", + "app.api.endpoints.system -> app.application.messaging", + "app.api.endpoints.system -> app.application.messaging.message", + "app.api.endpoints.system -> app.application.rules", + "app.api.endpoints.system -> app.application.security", + "app.api.endpoints.system -> app.application.security.access", + "app.api.endpoints.system -> app.application.security.url", + "app.api.endpoints.system -> app.application.site", + "app.api.endpoints.system -> app.chain", + "app.api.endpoints.system -> app.chain.media", + "app.api.endpoints.system -> app.chain.mediaserver", + "app.api.endpoints.system -> app.chain.search", + "app.api.endpoints.system -> app.chain.system", + "app.api.endpoints.system -> app.db", + "app.api.endpoints.system -> app.db.models", + "app.api.endpoints.system -> app.db.oper", + "app.api.endpoints.system -> app.db.oper.systemconfig", + "app.api.endpoints.system -> app.domain", + "app.api.endpoints.system -> app.domain.metainfo", + "app.api.endpoints.system -> app.foundation", + "app.api.endpoints.system -> app.foundation.crypto", + "app.api.endpoints.system -> app.foundation.url", + "app.api.endpoints.system -> app.runtime", + "app.api.endpoints.system -> app.runtime.config", + "app.api.endpoints.system -> app.runtime.events", + "app.api.endpoints.system -> app.runtime.extensions", + "app.api.endpoints.system -> app.runtime.extensions.module_manager", + "app.api.endpoints.system -> app.runtime.localization", + "app.api.endpoints.system -> app.runtime.log", + "app.api.endpoints.system -> app.runtime.progress", + "app.api.endpoints.system -> app.runtime.state", + "app.api.endpoints.system -> app.scheduler", + "app.api.endpoints.system -> app.schemas", + "app.api.endpoints.system -> app.schemas.common", + "app.api.endpoints.system -> app.schemas.event", + "app.api.endpoints.system -> app.schemas.response", + "app.api.endpoints.system -> app.schemas.system", + "app.api.endpoints.system -> app.schemas.token", + "app.api.endpoints.system -> app.schemas.types", + "app.api.endpoints.tmdb -> app.api", + "app.api.endpoints.tmdb -> app.api.deps", + "app.api.endpoints.tmdb -> app.api.response", + "app.api.endpoints.tmdb -> app.application", + "app.api.endpoints.tmdb -> app.application.security", + "app.api.endpoints.tmdb -> app.application.security.access", + "app.api.endpoints.tmdb -> app.chain", + "app.api.endpoints.tmdb -> app.chain.tmdb", + "app.api.endpoints.tmdb -> app.db", + "app.api.endpoints.tmdb -> app.db.models", + "app.api.endpoints.tmdb -> app.db.models.user", + "app.api.endpoints.tmdb -> app.db.oper", + "app.api.endpoints.tmdb -> app.db.oper.systemconfig", + "app.api.endpoints.tmdb -> app.runtime", + "app.api.endpoints.tmdb -> app.runtime.config", + "app.api.endpoints.tmdb -> app.schemas", + "app.api.endpoints.tmdb -> app.schemas.context", + "app.api.endpoints.tmdb -> app.schemas.response", + "app.api.endpoints.tmdb -> app.schemas.tmdb", + "app.api.endpoints.tmdb -> app.schemas.token", + "app.api.endpoints.tmdb -> app.schemas.types", + "app.api.endpoints.tmdb -> app.schemas.workflow", + "app.api.endpoints.torrent -> app.api", + "app.api.endpoints.torrent -> app.api.deps", + "app.api.endpoints.torrent -> app.api.response", + "app.api.endpoints.torrent -> app.chain", + "app.api.endpoints.torrent -> app.chain.media", + "app.api.endpoints.torrent -> app.chain.torrents", + "app.api.endpoints.torrent -> app.db", + "app.api.endpoints.torrent -> app.db.models", + "app.api.endpoints.torrent -> app.domain", + "app.api.endpoints.torrent -> app.domain.context", + "app.api.endpoints.torrent -> app.domain.media", + "app.api.endpoints.torrent -> app.domain.meta", + "app.api.endpoints.torrent -> app.domain.meta.metamusic", + "app.api.endpoints.torrent -> app.domain.metainfo", + "app.api.endpoints.torrent -> app.foundation", + "app.api.endpoints.torrent -> app.foundation.crypto", + "app.api.endpoints.torrent -> app.runtime", + "app.api.endpoints.torrent -> app.runtime.config", + "app.api.endpoints.torrent -> app.schemas", + "app.api.endpoints.torrent -> app.schemas.cache", + "app.api.endpoints.torrent -> app.schemas.media", + "app.api.endpoints.torrent -> app.schemas.response", + "app.api.endpoints.torrent -> app.schemas.types", + "app.api.endpoints.transfer -> app.api", + "app.api.endpoints.transfer -> app.api.deps", + "app.api.endpoints.transfer -> app.api.response", + "app.api.endpoints.transfer -> app.application", + "app.api.endpoints.transfer -> app.application.directory", + "app.api.endpoints.transfer -> app.application.security", + "app.api.endpoints.transfer -> app.application.security.access", + "app.api.endpoints.transfer -> app.chain", + "app.api.endpoints.transfer -> app.chain.media", + "app.api.endpoints.transfer -> app.chain.transfer", + "app.api.endpoints.transfer -> app.db", + "app.api.endpoints.transfer -> app.db.models", + "app.api.endpoints.transfer -> app.db.models.transferhistory", + "app.api.endpoints.transfer -> app.runtime", + "app.api.endpoints.transfer -> app.runtime.config", + "app.api.endpoints.transfer -> app.runtime.log", + "app.api.endpoints.transfer -> app.schemas", + "app.api.endpoints.transfer -> app.schemas.common", + "app.api.endpoints.transfer -> app.schemas.response", + "app.api.endpoints.transfer -> app.schemas.system", + "app.api.endpoints.transfer -> app.schemas.token", + "app.api.endpoints.transfer -> app.schemas.transfer", + "app.api.endpoints.transfer -> app.schemas.types", + "app.api.endpoints.transfer -> app.schemas.workflow", + "app.api.endpoints.user -> app.api", + "app.api.endpoints.user -> app.api.deps", + "app.api.endpoints.user -> app.api.response", + "app.api.endpoints.user -> app.application", + "app.api.endpoints.user -> app.application.security", + "app.api.endpoints.user -> app.application.security.access", + "app.api.endpoints.user -> app.db", + "app.api.endpoints.user -> app.db.models", + "app.api.endpoints.user -> app.db.models.user", + "app.api.endpoints.user -> app.db.oper", + "app.api.endpoints.user -> app.db.oper.userconfig", + "app.api.endpoints.user -> app.schemas", + "app.api.endpoints.user -> app.schemas.common", + "app.api.endpoints.user -> app.schemas.response", + "app.api.endpoints.user -> app.schemas.user", + "app.api.endpoints.webhook -> app.api", + "app.api.endpoints.webhook -> app.api.response", + "app.api.endpoints.webhook -> app.application", + "app.api.endpoints.webhook -> app.application.security", + "app.api.endpoints.webhook -> app.application.security.access", + "app.api.endpoints.webhook -> app.chain", + "app.api.endpoints.webhook -> app.chain.webhook", + "app.api.endpoints.webhook -> app.schemas", + "app.api.endpoints.webhook -> app.schemas.response", + "app.api.endpoints.workflow -> app.adapters", + "app.api.endpoints.workflow -> app.adapters.external", + "app.api.endpoints.workflow -> app.adapters.external.server", + "app.api.endpoints.workflow -> app.api", + "app.api.endpoints.workflow -> app.api.deps", + "app.api.endpoints.workflow -> app.api.response", + "app.api.endpoints.workflow -> app.application", + "app.api.endpoints.workflow -> app.application.workflow", + "app.api.endpoints.workflow -> app.chain", + "app.api.endpoints.workflow -> app.chain.workflow", + "app.api.endpoints.workflow -> app.db", + "app.api.endpoints.workflow -> app.db.models", + "app.api.endpoints.workflow -> app.db.oper", + "app.api.endpoints.workflow -> app.db.oper.workflow", + "app.api.endpoints.workflow -> app.runtime", + "app.api.endpoints.workflow -> app.runtime.extensions", + "app.api.endpoints.workflow -> app.runtime.extensions.plugin_manager", + "app.api.endpoints.workflow -> app.schemas", + "app.api.endpoints.workflow -> app.schemas.response", + "app.api.endpoints.workflow -> app.schemas.types", + "app.api.endpoints.workflow -> app.schemas.workflow", + "app.api.endpoints.workflow -> app.workflow", + "app.api.response -> app.schemas", + "app.api.response -> app.schemas.common", + "app.api.response -> app.schemas.response", + "app.api.router_specs -> app.api", + "app.api.router_specs -> app.api.endpoints", + "app.api.router_specs -> app.api.endpoints.agent", + "app.api.router_specs -> app.api.endpoints.anilist", + "app.api.router_specs -> app.api.endpoints.anthropic", + "app.api.router_specs -> app.api.endpoints.auth", + "app.api.router_specs -> app.api.endpoints.bangumi", + "app.api.router_specs -> app.api.endpoints.dashboard", + "app.api.router_specs -> app.api.endpoints.discover", + "app.api.router_specs -> app.api.endpoints.douban", + "app.api.router_specs -> app.api.endpoints.download", + "app.api.router_specs -> app.api.endpoints.history", + "app.api.router_specs -> app.api.endpoints.llm", + "app.api.router_specs -> app.api.endpoints.login", + "app.api.router_specs -> app.api.endpoints.mcp", + "app.api.router_specs -> app.api.endpoints.media", + "app.api.router_specs -> app.api.endpoints.mediaserver", + "app.api.router_specs -> app.api.endpoints.message", + "app.api.router_specs -> app.api.endpoints.mfa", + "app.api.router_specs -> app.api.endpoints.music", + "app.api.router_specs -> app.api.endpoints.notification", + "app.api.router_specs -> app.api.endpoints.openai", + "app.api.router_specs -> app.api.endpoints.plugin", + "app.api.router_specs -> app.api.endpoints.recommend", + "app.api.router_specs -> app.api.endpoints.search", + "app.api.router_specs -> app.api.endpoints.site", + "app.api.router_specs -> app.api.endpoints.storage", + "app.api.router_specs -> app.api.endpoints.subscribe", + "app.api.router_specs -> app.api.endpoints.system", + "app.api.router_specs -> app.api.endpoints.tmdb", + "app.api.router_specs -> app.api.endpoints.torrent", + "app.api.router_specs -> app.api.endpoints.transfer", + "app.api.router_specs -> app.api.endpoints.user", + "app.api.router_specs -> app.api.endpoints.webhook", + "app.api.router_specs -> app.api.endpoints.workflow", + "app.api.servarr -> app.api", + "app.api.servarr -> app.api.response", + "app.api.servarr -> app.application", + "app.api.servarr -> app.application.security", + "app.api.servarr -> app.application.security.access", + "app.api.servarr -> app.chain", + "app.api.servarr -> app.chain.media", + "app.api.servarr -> app.chain.subscribe", + "app.api.servarr -> app.chain.tvdb", + "app.api.servarr -> app.db", + "app.api.servarr -> app.db.models", + "app.api.servarr -> app.db.models.subscribe", + "app.api.servarr -> app.domain", + "app.api.servarr -> app.domain.context", + "app.api.servarr -> app.domain.metainfo", + "app.api.servarr -> app.schemas", + "app.api.servarr -> app.schemas.response", + "app.api.servarr -> app.schemas.servarr", + "app.api.servarr -> app.schemas.types", + "app.api.servcookie -> app.api", + "app.api.servcookie -> app.api.response", + "app.api.servcookie -> app.foundation", + "app.api.servcookie -> app.foundation.crypto", + "app.api.servcookie -> app.runtime", + "app.api.servcookie -> app.runtime.config", + "app.api.servcookie -> app.runtime.log", + "app.api.servcookie -> app.schemas", + "app.api.servcookie -> app.schemas.servcookie", + "app.application.audio -> app.domain", + "app.application.audio -> app.domain.context", + "app.application.audio -> app.domain.meta", + "app.application.audio -> app.domain.meta.metamusic", + "app.application.audio -> app.runtime", + "app.application.audio -> app.runtime.log", + "app.application.audio -> app.schemas", + "app.application.audio -> app.schemas.types", + "app.application.chain.context -> app.application", + "app.application.chain.context -> app.application.messaging", + "app.application.chain.context -> app.application.messaging.message", + "app.application.chain.context -> app.db", + "app.application.chain.context -> app.db.oper", + "app.application.chain.context -> app.db.oper.message", + "app.application.chain.context -> app.runtime", + "app.application.chain.context -> app.runtime.cache", + "app.application.chain.context -> app.runtime.events", + "app.application.chain.context -> app.runtime.extensions", + "app.application.chain.context -> app.runtime.extensions.module_manager", + "app.application.chain.context -> app.runtime.extensions.plugin_manager", + "app.application.directory -> app.adapters", + "app.application.directory -> app.adapters.system", + "app.application.directory -> app.adapters.system.host", + "app.application.directory -> app.db", + "app.application.directory -> app.db.oper", + "app.application.directory -> app.db.oper.systemconfig", + "app.application.directory -> app.domain", + "app.application.directory -> app.domain.context", + "app.application.directory -> app.runtime", + "app.application.directory -> app.runtime.log", + "app.application.directory -> app.schemas", + "app.application.directory -> app.schemas.file", + "app.application.directory -> app.schemas.system", + "app.application.directory -> app.schemas.types", + "app.application.download.tasks -> app.schemas", + "app.application.download.tasks -> app.schemas.transfer", + "app.application.download.tasks -> app.schemas.types", + "app.application.downloader -> app.runtime", + "app.application.downloader -> app.runtime.extensions", + "app.application.downloader -> app.runtime.extensions.service_registry", + "app.application.downloader -> app.schemas", + "app.application.downloader -> app.schemas.system", + "app.application.downloader -> app.schemas.types", + "app.application.formatting -> app.domain", + "app.application.formatting -> app.domain.meta", + "app.application.formatting -> app.domain.meta.metabase", + "app.application.formatting -> app.domain.metainfo", + "app.application.formatting -> app.runtime", + "app.application.formatting -> app.runtime.config", + "app.application.formatting -> app.runtime.log", + "app.application.formatting -> app.schemas", + "app.application.formatting -> app.schemas.transfer", + "app.application.formatting -> app.schemas.workflow", + "app.application.history -> app.db", + "app.application.history -> app.db.models", + "app.application.history -> app.db.models.transferhistory", + "app.application.history -> app.db.oper", + "app.application.history -> app.db.oper.transferhistory", + "app.application.history -> app.domain", + "app.application.history -> app.domain.context", + "app.application.history -> app.domain.meta", + "app.application.history -> app.domain.meta.metabase", + "app.application.history -> app.domain.meta.metamusic", + "app.application.history -> app.runtime", + "app.application.history -> app.runtime.cache", + "app.application.history -> app.runtime.config", + "app.application.history -> app.runtime.log", + "app.application.history -> app.schemas", + "app.application.history -> app.schemas.media", + "app.application.history -> app.schemas.transfer", + "app.application.history -> app.schemas.types", + "app.application.history -> app.schemas.workflow", + "app.application.image -> app.adapters", + "app.application.image -> app.adapters.network", + "app.application.image -> app.adapters.network.http", + "app.application.image -> app.adapters.network.ip", + "app.application.image -> app.application", + "app.application.image -> app.application.security", + "app.application.image -> app.application.security.url", + "app.application.image -> app.foundation", + "app.application.image -> app.foundation.singleton", + "app.application.image -> app.runtime", + "app.application.image -> app.runtime.cache", + "app.application.image -> app.runtime.config", + "app.application.image -> app.runtime.log", + "app.application.maintenance -> app.db", + "app.application.maintenance -> app.db.maintenance", + "app.application.maintenance -> app.db.session", + "app.application.maintenance -> app.runtime", + "app.application.maintenance -> app.runtime.config", + "app.application.maintenance -> app.runtime.log", + "app.application.mediaserver -> app.domain", + "app.application.mediaserver -> app.domain.context", + "app.application.mediaserver -> app.runtime", + "app.application.mediaserver -> app.runtime.extensions", + "app.application.mediaserver -> app.runtime.extensions.service_registry", + "app.application.mediaserver -> app.schemas", + "app.application.mediaserver -> app.schemas.media", + "app.application.mediaserver -> app.schemas.mediaserver", + "app.application.mediaserver -> app.schemas.system", + "app.application.mediaserver -> app.schemas.types", + "app.application.messaging.agent -> app.schemas", + "app.application.messaging.agent -> app.schemas.types", + "app.application.messaging.interaction -> app.schemas", + "app.application.messaging.interaction -> app.schemas.message", + "app.application.messaging.interaction -> app.schemas.notification", + "app.application.messaging.interaction -> app.schemas.types", + "app.application.messaging.media -> app.domain", + "app.application.messaging.media -> app.domain.context", + "app.application.messaging.media -> app.domain.meta", + "app.application.messaging.media -> app.domain.meta.metabase", + "app.application.messaging.media -> app.schemas", + "app.application.messaging.media -> app.schemas.types", + "app.application.messaging.message -> app.db", + "app.application.messaging.message -> app.db.oper", + "app.application.messaging.message -> app.db.oper.systemconfig", + "app.application.messaging.message -> app.domain", + "app.application.messaging.message -> app.domain.context", + "app.application.messaging.message -> app.domain.meta", + "app.application.messaging.message -> app.domain.meta.metabase", + "app.application.messaging.message -> app.domain.meta.metamusic", + "app.application.messaging.message -> app.foundation", + "app.application.messaging.message -> app.foundation.crypto", + "app.application.messaging.message -> app.foundation.singleton", + "app.application.messaging.message -> app.foundation.size", + "app.application.messaging.message -> app.runtime", + "app.application.messaging.message -> app.runtime.cache", + "app.application.messaging.message -> app.runtime.config", + "app.application.messaging.message -> app.runtime.log", + "app.application.messaging.message -> app.schemas", + "app.application.messaging.message -> app.schemas.message", + "app.application.messaging.message -> app.schemas.tmdb", + "app.application.messaging.message -> app.schemas.transfer", + "app.application.messaging.message -> app.schemas.types", + "app.application.messaging.plugin -> app.application", + "app.application.messaging.plugin -> app.application.messaging", + "app.application.messaging.plugin -> app.application.messaging.interaction", + "app.application.messaging.plugin -> app.runtime", + "app.application.messaging.plugin -> app.runtime.events", + "app.application.messaging.plugin -> app.schemas", + "app.application.messaging.plugin -> app.schemas.message", + "app.application.messaging.plugin -> app.schemas.types", + "app.application.messaging.router -> app.application", + "app.application.messaging.router -> app.application.messaging", + "app.application.messaging.router -> app.application.messaging.interaction", + "app.application.messaging.router -> app.application.messaging.media", + "app.application.messaging.router -> app.application.messaging.site", + "app.application.messaging.router -> app.application.messaging.skill", + "app.application.messaging.router -> app.application.messaging.subscribe", + "app.application.messaging.site -> app.application", + "app.application.messaging.site -> app.application.messaging", + "app.application.messaging.site -> app.application.messaging.interaction", + "app.application.messaging.site -> app.db", + "app.application.messaging.site -> app.db.models", + "app.application.messaging.site -> app.db.models.site", + "app.application.messaging.site -> app.db.oper", + "app.application.messaging.site -> app.db.oper.site", + "app.application.messaging.site -> app.domain", + "app.application.messaging.site -> app.domain.site", + "app.application.messaging.site -> app.runtime", + "app.application.messaging.site -> app.runtime.log", + "app.application.messaging.site -> app.schemas", + "app.application.messaging.site -> app.schemas.message", + "app.application.messaging.site -> app.schemas.types", + "app.application.messaging.skill -> app.agent", + "app.application.messaging.skill -> app.agent.skills", + "app.application.messaging.skill -> app.agent.skills.registry", + "app.application.messaging.skill -> app.application", + "app.application.messaging.skill -> app.application.messaging", + "app.application.messaging.skill -> app.application.messaging.interaction", + "app.application.messaging.skill -> app.schemas", + "app.application.messaging.skill -> app.schemas.message", + "app.application.messaging.skill -> app.schemas.types", + "app.application.messaging.subscribe -> app.adapters", + "app.application.messaging.subscribe -> app.adapters.external", + "app.application.messaging.subscribe -> app.adapters.external.server", + "app.application.messaging.subscribe -> app.application", + "app.application.messaging.subscribe -> app.application.messaging", + "app.application.messaging.subscribe -> app.application.messaging.interaction", + "app.application.messaging.subscribe -> app.db", + "app.application.messaging.subscribe -> app.db.models", + "app.application.messaging.subscribe -> app.db.models.subscribe", + "app.application.messaging.subscribe -> app.db.oper", + "app.application.messaging.subscribe -> app.db.oper.subscribe", + "app.application.messaging.subscribe -> app.schemas", + "app.application.messaging.subscribe -> app.schemas.message", + "app.application.messaging.subscribe -> app.schemas.types", + "app.application.music.catalog -> app.domain", + "app.application.music.catalog -> app.domain.context", + "app.application.music.catalog -> app.domain.meta", + "app.application.music.catalog -> app.domain.meta.metamusic", + "app.application.music.catalog -> app.schemas", + "app.application.music.catalog -> app.schemas.media", + "app.application.music.catalog -> app.schemas.types", + "app.application.notification -> app.runtime", + "app.application.notification -> app.runtime.extensions", + "app.application.notification -> app.runtime.extensions.service_registry", + "app.application.notification -> app.schemas", + "app.application.notification -> app.schemas.system", + "app.application.notification -> app.schemas.types", + "app.application.plugins -> app.adapters", + "app.application.plugins -> app.adapters.web", + "app.application.plugins -> app.adapters.web.plugin", + "app.application.plugins -> app.adapters.web.plugin.routes", + "app.application.plugins -> app.application", + "app.application.plugins -> app.application.security", + "app.application.plugins -> app.application.security.access", + "app.application.plugins -> app.db", + "app.application.plugins -> app.db.oper", + "app.application.plugins -> app.db.oper.systemconfig", + "app.application.plugins -> app.runtime", + "app.application.plugins -> app.runtime.config", + "app.application.plugins -> app.runtime.extensions", + "app.application.plugins -> app.runtime.extensions.plugin_manager", + "app.application.plugins -> app.runtime.log", + "app.application.plugins -> app.schemas", + "app.application.plugins -> app.schemas.types", + "app.application.recognition -> app.db", + "app.application.recognition -> app.db.oper", + "app.application.recognition -> app.db.oper.systemconfig", + "app.application.recognition -> app.schemas", + "app.application.recognition -> app.schemas.types", + "app.application.rss -> app.adapters", + "app.application.rss -> app.adapters.network", + "app.application.rss -> app.adapters.network.browser", + "app.application.rss -> app.adapters.network.http", + "app.application.rss -> app.adapters.system", + "app.application.rss -> app.adapters.system.rust", + "app.application.rss -> app.runtime", + "app.application.rss -> app.runtime.config", + "app.application.rss -> app.runtime.log", + "app.application.rules -> app.adapters", + "app.application.rules -> app.adapters.system", + "app.application.rules -> app.adapters.system.rust", + "app.application.rules -> app.db", + "app.application.rules -> app.db.oper", + "app.application.rules -> app.db.oper.systemconfig", + "app.application.rules -> app.domain", + "app.application.rules -> app.domain.context", + "app.application.rules -> app.schemas", + "app.application.rules -> app.schemas.rule", + "app.application.rules -> app.schemas.system", + "app.application.rules -> app.schemas.types", + "app.application.search.state -> app.schemas", + "app.application.search.state -> app.schemas.media", + "app.application.search.state -> app.schemas.types", + "app.application.security.access -> app.runtime", + "app.application.security.access -> app.runtime.cache", + "app.application.security.access -> app.runtime.config", + "app.application.security.access -> app.runtime.log", + "app.application.security.access -> app.schemas", + "app.application.security.access -> app.schemas.token", + "app.application.security.auth -> app.application", + "app.application.security.auth -> app.application.security", + "app.application.security.auth -> app.application.security.access", + "app.application.security.auth -> app.application.site", + "app.application.security.auth -> app.db", + "app.application.security.auth -> app.db.models", + "app.application.security.auth -> app.db.models.user", + "app.application.security.auth -> app.db.oper", + "app.application.security.auth -> app.db.oper.systemconfig", + "app.application.security.auth -> app.db.oper.user", + "app.application.security.auth -> app.foundation", + "app.application.security.auth -> app.foundation.singleton", + "app.application.security.auth -> app.runtime", + "app.application.security.auth -> app.runtime.config", + "app.application.security.auth -> app.schemas", + "app.application.security.auth -> app.schemas.token", + "app.application.security.auth -> app.schemas.types", + "app.application.security.cookie -> app.adapters", + "app.application.security.cookie -> app.adapters.external", + "app.application.security.cookie -> app.adapters.external.ocr", + "app.application.security.cookie -> app.adapters.network", + "app.application.security.cookie -> app.adapters.network.browser", + "app.application.security.cookie -> app.adapters.network.http", + "app.application.security.cookie -> app.application", + "app.application.security.cookie -> app.application.security", + "app.application.security.cookie -> app.application.security.twofactor", + "app.application.security.cookie -> app.domain", + "app.application.security.cookie -> app.domain.site", + "app.application.security.cookie -> app.foundation", + "app.application.security.cookie -> app.foundation.url", + "app.application.security.cookie -> app.runtime", + "app.application.security.cookie -> app.runtime.log", + "app.application.security.passkey -> app.adapters", + "app.application.security.passkey -> app.adapters.cache", + "app.application.security.passkey -> app.adapters.cache.redis", + "app.application.security.passkey -> app.runtime", + "app.application.security.passkey -> app.runtime.cache", + "app.application.security.passkey -> app.runtime.config", + "app.application.security.passkey -> app.runtime.log", + "app.application.security.twofactor -> app.runtime", + "app.application.security.twofactor -> app.runtime.log", + "app.application.security.url -> app.runtime", + "app.application.security.url -> app.runtime.coalesce", + "app.application.security.url -> app.runtime.config", + "app.application.security.url -> app.runtime.log", + "app.application.server.report -> app.schemas", + "app.application.server.report -> app.schemas.media", + "app.application.server.share -> app.schemas", + "app.application.server.share -> app.schemas.media", + "app.application.site.mutation -> app.application", + "app.application.site.mutation -> app.application.subscription", + "app.application.site.mutation -> app.application.subscription.delete", + "app.application.storage -> app.db", + "app.application.storage -> app.db.oper", + "app.application.storage -> app.db.oper.systemconfig", + "app.application.storage -> app.schemas", + "app.application.storage -> app.schemas.system", + "app.application.storage -> app.schemas.types", + "app.application.subscribe -> app.db", + "app.application.subscribe -> app.db.oper", + "app.application.subscribe -> app.db.oper.subscribe", + "app.application.subscribe -> app.domain", + "app.application.subscribe -> app.domain.context", + "app.application.subscribe -> app.schemas", + "app.application.subscribe -> app.schemas.media", + "app.application.subscribe -> app.schemas.types", + "app.application.subscription.contract -> app.domain", + "app.application.subscription.contract -> app.domain.meta", + "app.application.subscription.contract -> app.domain.meta.metabase", + "app.application.subscription.contract -> app.domain.meta.metamusic", + "app.application.subscription.contract -> app.domain.metainfo", + "app.application.subscription.contract -> app.schemas", + "app.application.subscription.contract -> app.schemas.media", + "app.application.subscription.contract -> app.schemas.types", + "app.application.subscription.identity -> app.application", + "app.application.subscription.identity -> app.application.subscription", + "app.application.subscription.identity -> app.application.subscription.delete", + "app.application.subscription.identity -> app.schemas", + "app.application.subscription.identity -> app.schemas.types", + "app.application.subscription.query -> app.domain", + "app.application.subscription.query -> app.domain.context", + "app.application.subscription.query -> app.domain.meta", + "app.application.subscription.query -> app.domain.meta.metabase", + "app.application.subscription.query -> app.schemas", + "app.application.subscription.query -> app.schemas.media", + "app.application.subscription.query -> app.schemas.types", + "app.application.subscription.search -> app.application", + "app.application.subscription.search -> app.application.subscription", + "app.application.subscription.search -> app.application.subscription.delete", + "app.application.torrent -> app.adapters", + "app.application.torrent -> app.adapters.network", + "app.application.torrent -> app.adapters.network.http", + "app.application.torrent -> app.db", + "app.application.torrent -> app.db.oper", + "app.application.torrent -> app.db.oper.site", + "app.application.torrent -> app.db.oper.systemconfig", + "app.application.torrent -> app.domain", + "app.application.torrent -> app.domain.context", + "app.application.torrent -> app.domain.meta", + "app.application.torrent -> app.domain.meta.metabase", + "app.application.torrent -> app.domain.meta.metamusic", + "app.application.torrent -> app.domain.metainfo", + "app.application.torrent -> app.domain.torrent", + "app.application.torrent -> app.foundation", + "app.application.torrent -> app.foundation.crypto", + "app.application.torrent -> app.foundation.text", + "app.application.torrent -> app.runtime", + "app.application.torrent -> app.runtime.cache", + "app.application.torrent -> app.runtime.config", + "app.application.torrent -> app.runtime.log", + "app.application.torrent -> app.schemas", + "app.application.torrent -> app.schemas.media", + "app.application.torrent -> app.schemas.types", + "app.application.transfer -> app.adapters", + "app.application.transfer -> app.adapters.system", + "app.application.transfer -> app.adapters.system.host", + "app.application.transfer -> app.application", + "app.application.transfer -> app.application.agent", + "app.application.transfer -> app.domain", + "app.application.transfer -> app.domain.context", + "app.application.transfer -> app.domain.media", + "app.application.transfer -> app.domain.meta", + "app.application.transfer -> app.domain.meta.metabase", + "app.application.transfer -> app.domain.meta.metamusic", + "app.application.transfer -> app.foundation", + "app.application.transfer -> app.foundation.text", + "app.application.transfer -> app.runtime", + "app.application.transfer -> app.runtime.log", + "app.application.transfer -> app.schemas", + "app.application.transfer -> app.schemas.file", + "app.application.transfer -> app.schemas.history", + "app.application.transfer -> app.schemas.media", + "app.application.transfer -> app.schemas.system", + "app.application.transfer -> app.schemas.tmdb", + "app.application.transfer -> app.schemas.transfer", + "app.application.transfer -> app.schemas.types", + "app.application.transfer -> app.schemas.workflow", + "app.chain -> app.application", + "app.chain -> app.application.chain", + "app.chain -> app.application.chain.context", + "app.chain -> app.chain._messaging", + "app.chain -> app.chain._recognition", + "app.chain -> app.domain", + "app.chain -> app.domain.context", + "app.chain -> app.domain.meta", + "app.chain -> app.domain.meta.metabase", + "app.chain -> app.runtime", + "app.chain -> app.runtime.extensions", + "app.chain -> app.runtime.extensions.module", + "app.chain -> app.runtime.extensions.module.dispatcher", + "app.chain -> app.runtime.log", + "app.chain -> app.schemas", + "app.chain -> app.schemas.category", + "app.chain -> app.schemas.context", + "app.chain -> app.schemas.exception", + "app.chain -> app.schemas.mediaserver", + "app.chain -> app.schemas.message", + "app.chain -> app.schemas.system", + "app.chain -> app.schemas.tmdb", + "app.chain -> app.schemas.transfer", + "app.chain -> app.schemas.types", + "app.chain -> app.schemas.workflow", + "app.chain._interaction -> app.schemas", + "app.chain._interaction -> app.schemas.types", + "app.chain._messaging -> app.application", + "app.chain._messaging -> app.application.messaging", + "app.chain._messaging -> app.application.messaging.agent", + "app.chain._messaging -> app.application.messaging.message", + "app.chain._messaging -> app.db", + "app.chain._messaging -> app.db.oper", + "app.chain._messaging -> app.db.oper.user", + "app.chain._messaging -> app.domain", + "app.chain._messaging -> app.domain.context", + "app.chain._messaging -> app.domain.meta", + "app.chain._messaging -> app.domain.meta.metabase", + "app.chain._messaging -> app.foundation", + "app.chain._messaging -> app.foundation.identity", + "app.chain._messaging -> app.runtime", + "app.chain._messaging -> app.runtime.config", + "app.chain._messaging -> app.runtime.extensions", + "app.chain._messaging -> app.runtime.extensions.service_registry", + "app.chain._messaging -> app.runtime.log", + "app.chain._messaging -> app.schemas", + "app.chain._messaging -> app.schemas.message", + "app.chain._messaging -> app.schemas.transfer", + "app.chain._messaging -> app.schemas.types", + "app.chain._music -> app.application", + "app.chain._music -> app.application.subscription", + "app.chain._music -> app.application.subscription.contract", + "app.chain._music -> app.application.torrent", + "app.chain._music -> app.chain", + "app.chain._music -> app.chain.download", + "app.chain._music -> app.chain.media", + "app.chain._music -> app.chain.search", + "app.chain._music -> app.db", + "app.chain._music -> app.db.models", + "app.chain._music -> app.db.models.subscribe", + "app.chain._music -> app.db.oper", + "app.chain._music -> app.db.oper.subscribe", + "app.chain._music -> app.db.oper.systemconfig", + "app.chain._music -> app.domain", + "app.chain._music -> app.domain.context", + "app.chain._music -> app.domain.media", + "app.chain._music -> app.domain.meta", + "app.chain._music -> app.domain.meta.metamusic", + "app.chain._music -> app.runtime", + "app.chain._music -> app.runtime.log", + "app.chain._music -> app.schemas", + "app.chain._music -> app.schemas.types", + "app.chain._recognition -> app.adapters", + "app.chain._recognition -> app.adapters.external", + "app.chain._recognition -> app.adapters.external.server", + "app.chain._recognition -> app.db", + "app.chain._recognition -> app.db.oper", + "app.chain._recognition -> app.db.oper.systemconfig", + "app.chain._recognition -> app.domain", + "app.chain._recognition -> app.domain.context", + "app.chain._recognition -> app.domain.meta", + "app.chain._recognition -> app.domain.meta.metabase", + "app.chain._recognition -> app.domain.meta.metamusic", + "app.chain._recognition -> app.runtime", + "app.chain._recognition -> app.runtime.cache", + "app.chain._recognition -> app.runtime.config", + "app.chain._recognition -> app.runtime.events", + "app.chain._recognition -> app.runtime.log", + "app.chain._recognition -> app.schemas", + "app.chain._recognition -> app.schemas.media", + "app.chain._recognition -> app.schemas.types", + "app.chain._transfer -> app.adapters", + "app.chain._transfer -> app.adapters.system", + "app.chain._transfer -> app.adapters.system.host", + "app.chain._transfer -> app.application", + "app.chain._transfer -> app.application.agent", + "app.chain._transfer -> app.application.formatting", + "app.chain._transfer -> app.application.history", + "app.chain._transfer -> app.application.transfer", + "app.chain._transfer -> app.chain", + "app.chain._transfer -> app.chain.media", + "app.chain._transfer -> app.chain.storage", + "app.chain._transfer -> app.chain.subscribe", + "app.chain._transfer -> app.db", + "app.chain._transfer -> app.db.models", + "app.chain._transfer -> app.db.models.downloadhistory", + "app.chain._transfer -> app.db.models.transferhistory", + "app.chain._transfer -> app.db.oper", + "app.chain._transfer -> app.db.oper.downloadhistory", + "app.chain._transfer -> app.db.oper.systemconfig", + "app.chain._transfer -> app.db.oper.transferhistory", + "app.chain._transfer -> app.domain", + "app.chain._transfer -> app.domain.context", + "app.chain._transfer -> app.domain.media", + "app.chain._transfer -> app.domain.meta", + "app.chain._transfer -> app.domain.meta.metabase", + "app.chain._transfer -> app.domain.meta.metamusic", + "app.chain._transfer -> app.foundation", + "app.chain._transfer -> app.foundation.text", + "app.chain._transfer -> app.runtime", + "app.chain._transfer -> app.runtime.config", + "app.chain._transfer -> app.runtime.log", + "app.chain._transfer -> app.schemas", + "app.chain._transfer -> app.schemas.history", + "app.chain._transfer -> app.schemas.message", + "app.chain._transfer -> app.schemas.tmdb", + "app.chain._transfer -> app.schemas.transfer", + "app.chain._transfer -> app.schemas.types", + "app.chain._transfer -> app.schemas.workflow", + "app.chain.acoustid -> app.chain", + "app.chain.agent -> app.chain", + "app.chain.anilist -> app.chain", + "app.chain.anilist -> app.domain", + "app.chain.anilist -> app.domain.context", + "app.chain.anilist -> app.schemas", + "app.chain.anilist -> app.schemas.context", + "app.chain.bangumi -> app.chain", + "app.chain.bangumi -> app.domain", + "app.chain.bangumi -> app.domain.context", + "app.chain.bangumi -> app.schemas", + "app.chain.bangumi -> app.schemas.context", + "app.chain.dashboard -> app.chain", + "app.chain.dashboard -> app.schemas", + "app.chain.dashboard -> app.schemas.dashboard", + "app.chain.douban -> app.chain", + "app.chain.douban -> app.domain", + "app.chain.douban -> app.domain.context", + "app.chain.douban -> app.domain.meta", + "app.chain.douban -> app.domain.meta.metamusic", + "app.chain.douban -> app.schemas", + "app.chain.douban -> app.schemas.context", + "app.chain.douban -> app.schemas.types", + "app.chain.download -> app.adapters", + "app.chain.download -> app.adapters.network", + "app.chain.download -> app.adapters.network.http", + "app.chain.download -> app.adapters.system", + "app.chain.download -> app.adapters.system.host", + "app.chain.download -> app.application", + "app.chain.download -> app.application.directory", + "app.chain.download -> app.application.download", + "app.chain.download -> app.application.download.tasks", + "app.chain.download -> app.application.torrent", + "app.chain.download -> app.chain", + "app.chain.download -> app.chain.media", + "app.chain.download -> app.chain.storage", + "app.chain.download -> app.db", + "app.chain.download -> app.db.models", + "app.chain.download -> app.db.models.downloadfailure", + "app.chain.download -> app.db.oper", + "app.chain.download -> app.db.oper.downloadfailure", + "app.chain.download -> app.db.oper.downloadhistory", + "app.chain.download -> app.db.oper.mediaserver", + "app.chain.download -> app.domain", + "app.chain.download -> app.domain.context", + "app.chain.download -> app.domain.episode", + "app.chain.download -> app.domain.meta", + "app.chain.download -> app.domain.meta.metabase", + "app.chain.download -> app.domain.meta.metamusic", + "app.chain.download -> app.domain.metainfo", + "app.chain.download -> app.foundation", + "app.chain.download -> app.foundation.size", + "app.chain.download -> app.foundation.text", + "app.chain.download -> app.runtime", + "app.chain.download -> app.runtime.cache", + "app.chain.download -> app.runtime.config", + "app.chain.download -> app.runtime.events", + "app.chain.download -> app.runtime.log", + "app.chain.download -> app.runtime.thread", + "app.chain.download -> app.schemas", + "app.chain.download -> app.schemas.event", + "app.chain.download -> app.schemas.file", + "app.chain.download -> app.schemas.media", + "app.chain.download -> app.schemas.mediaserver", + "app.chain.download -> app.schemas.message", + "app.chain.download -> app.schemas.system", + "app.chain.download -> app.schemas.transfer", + "app.chain.download -> app.schemas.types", + "app.chain.download -> app.schemas.workflow", + "app.chain.interaction -> app.application", + "app.chain.interaction -> app.application.directory", + "app.chain.interaction -> app.application.messaging", + "app.chain.interaction -> app.application.messaging.media", + "app.chain.interaction -> app.application.torrent", + "app.chain.interaction -> app.chain", + "app.chain.interaction -> app.chain.download", + "app.chain.interaction -> app.chain.media", + "app.chain.interaction -> app.chain.search", + "app.chain.interaction -> app.chain.subscribe", + "app.chain.interaction -> app.db", + "app.chain.interaction -> app.db.oper", + "app.chain.interaction -> app.db.oper.user", + "app.chain.interaction -> app.domain", + "app.chain.interaction -> app.domain.context", + "app.chain.interaction -> app.domain.episode", + "app.chain.interaction -> app.domain.meta", + "app.chain.interaction -> app.domain.meta.metabase", + "app.chain.interaction -> app.domain.title", + "app.chain.interaction -> app.foundation", + "app.chain.interaction -> app.foundation.url", + "app.chain.interaction -> app.runtime", + "app.chain.interaction -> app.runtime.config", + "app.chain.interaction -> app.runtime.log", + "app.chain.interaction -> app.schemas", + "app.chain.interaction -> app.schemas.download", + "app.chain.interaction -> app.schemas.file", + "app.chain.interaction -> app.schemas.media", + "app.chain.interaction -> app.schemas.mediaserver", + "app.chain.interaction -> app.schemas.message", + "app.chain.interaction -> app.schemas.notification", + "app.chain.interaction -> app.schemas.system", + "app.chain.interaction -> app.schemas.types", + "app.chain.listenbrainz -> app.chain", + "app.chain.listenbrainz -> app.domain", + "app.chain.listenbrainz -> app.domain.context", + "app.chain.listenbrainz -> app.schemas", + "app.chain.listenbrainz -> app.schemas.types", + "app.chain.lrclib -> app.chain", + "app.chain.lrclib -> app.domain", + "app.chain.lrclib -> app.domain.context", + "app.chain.lrclib -> app.domain.meta", + "app.chain.lrclib -> app.domain.meta.metamusic", + "app.chain.media -> app.application", + "app.chain.media -> app.application.audio", + "app.chain.media -> app.application.music", + "app.chain.media -> app.application.music.catalog", + "app.chain.media -> app.chain", + "app.chain.media -> app.chain.acoustid", + "app.chain.media -> app.chain.douban", + "app.chain.media -> app.chain.musicbrainz", + "app.chain.media -> app.chain.theaudiodb", + "app.chain.media -> app.domain", + "app.chain.media -> app.domain.context", + "app.chain.media -> app.domain.media", + "app.chain.media -> app.domain.meta", + "app.chain.media -> app.domain.meta.metabase", + "app.chain.media -> app.domain.meta.metamusic", + "app.chain.media -> app.domain.metainfo", + "app.chain.media -> app.domain.title", + "app.chain.media -> app.foundation", + "app.chain.media -> app.foundation.singleton", + "app.chain.media -> app.foundation.text", + "app.chain.media -> app.runtime", + "app.chain.media -> app.runtime.cache", + "app.chain.media -> app.runtime.config", + "app.chain.media -> app.runtime.events", + "app.chain.media -> app.runtime.log", + "app.chain.media -> app.schemas", + "app.chain.media -> app.schemas.event", + "app.chain.media -> app.schemas.media", + "app.chain.media -> app.schemas.types", + "app.chain.mediaserver -> app.application", + "app.chain.mediaserver -> app.application.security", + "app.chain.mediaserver -> app.application.security.url", + "app.chain.mediaserver -> app.chain", + "app.chain.mediaserver -> app.db", + "app.chain.mediaserver -> app.db.oper", + "app.chain.mediaserver -> app.db.oper.mediaserver", + "app.chain.mediaserver -> app.runtime", + "app.chain.mediaserver -> app.runtime.config", + "app.chain.mediaserver -> app.runtime.extensions", + "app.chain.mediaserver -> app.runtime.extensions.service_registry", + "app.chain.mediaserver -> app.runtime.log", + "app.chain.mediaserver -> app.schemas", + "app.chain.mediaserver -> app.schemas.mediaserver", + "app.chain.mediaserver -> app.schemas.types", + "app.chain.message -> app.adapters", + "app.chain.message -> app.adapters.network", + "app.chain.message -> app.adapters.network.http", + "app.chain.message -> app.application", + "app.chain.message -> app.application.agent", + "app.chain.message -> app.application.messaging", + "app.chain.message -> app.application.messaging.agent", + "app.chain.message -> app.application.messaging.interaction", + "app.chain.message -> app.application.messaging.media", + "app.chain.message -> app.application.messaging.plugin", + "app.chain.message -> app.application.messaging.router", + "app.chain.message -> app.application.messaging.session", + "app.chain.message -> app.application.messaging.site", + "app.chain.message -> app.application.messaging.skill", + "app.chain.message -> app.application.messaging.subscribe", + "app.chain.message -> app.chain", + "app.chain.message -> app.chain.interaction", + "app.chain.message -> app.chain.site", + "app.chain.message -> app.chain.subscribe", + "app.chain.message -> app.chain.transfer", + "app.chain.message -> app.runtime", + "app.chain.message -> app.runtime.config", + "app.chain.message -> app.runtime.log", + "app.chain.message -> app.schemas", + "app.chain.message -> app.schemas.message", + "app.chain.message -> app.schemas.notification", + "app.chain.message -> app.schemas.types", + "app.chain.musicbrainz -> app.chain", + "app.chain.musicbrainz -> app.domain", + "app.chain.musicbrainz -> app.domain.context", + "app.chain.musicbrainz -> app.domain.meta", + "app.chain.musicbrainz -> app.domain.meta.metamusic", + "app.chain.musicbrainz -> app.schemas", + "app.chain.musicbrainz -> app.schemas.types", + "app.chain.notification -> app.chain", + "app.chain.recommend -> app.application", + "app.chain.recommend -> app.application.image", + "app.chain.recommend -> app.chain", + "app.chain.recommend -> app.chain.bangumi", + "app.chain.recommend -> app.chain.douban", + "app.chain.recommend -> app.chain.listenbrainz", + "app.chain.recommend -> app.chain.tmdb", + "app.chain.recommend -> app.domain", + "app.chain.recommend -> app.domain.context", + "app.chain.recommend -> app.foundation", + "app.chain.recommend -> app.foundation.singleton", + "app.chain.recommend -> app.runtime", + "app.chain.recommend -> app.runtime.cache", + "app.chain.recommend -> app.runtime.config", + "app.chain.recommend -> app.runtime.execution", + "app.chain.recommend -> app.runtime.log", + "app.chain.recommend -> app.schemas", + "app.chain.recommend -> app.schemas.media", + "app.chain.recommend -> app.schemas.types", + "app.chain.scraping -> app.adapters", + "app.chain.scraping -> app.adapters.network", + "app.chain.scraping -> app.adapters.network.http", + "app.chain.scraping -> app.application", + "app.chain.scraping -> app.application.audio", + "app.chain.scraping -> app.chain", + "app.chain.scraping -> app.chain.lrclib", + "app.chain.scraping -> app.chain.media", + "app.chain.scraping -> app.chain.storage", + "app.chain.scraping -> app.db", + "app.chain.scraping -> app.db.oper", + "app.chain.scraping -> app.db.oper.systemconfig", + "app.chain.scraping -> app.domain", + "app.chain.scraping -> app.domain.context", + "app.chain.scraping -> app.domain.meta", + "app.chain.scraping -> app.domain.meta.metabase", + "app.chain.scraping -> app.domain.meta.metamusic", + "app.chain.scraping -> app.domain.metainfo", + "app.chain.scraping -> app.foundation", + "app.chain.scraping -> app.foundation.singleton", + "app.chain.scraping -> app.runtime", + "app.chain.scraping -> app.runtime.cache", + "app.chain.scraping -> app.runtime.config", + "app.chain.scraping -> app.runtime.events", + "app.chain.scraping -> app.runtime.log", + "app.chain.scraping -> app.runtime.reload", + "app.chain.scraping -> app.schemas", + "app.chain.scraping -> app.schemas.media", + "app.chain.scraping -> app.schemas.types", + "app.chain.scraping -> app.schemas.workflow", + "app.chain.search -> app.application", + "app.chain.search -> app.application.agent", + "app.chain.search -> app.application.search", + "app.chain.search -> app.application.search.state", + "app.chain.search -> app.application.site", + "app.chain.search -> app.application.torrent", + "app.chain.search -> app.chain", + "app.chain.search -> app.chain.media", + "app.chain.search -> app.db", + "app.chain.search -> app.db.oper", + "app.chain.search -> app.db.oper.systemconfig", + "app.chain.search -> app.domain", + "app.chain.search -> app.domain.context", + "app.chain.search -> app.domain.meta", + "app.chain.search -> app.domain.meta.metamusic", + "app.chain.search -> app.domain.metainfo", + "app.chain.search -> app.foundation", + "app.chain.search -> app.foundation.size", + "app.chain.search -> app.foundation.text", + "app.chain.search -> app.runtime", + "app.chain.search -> app.runtime.config", + "app.chain.search -> app.runtime.events", + "app.chain.search -> app.runtime.log", + "app.chain.search -> app.runtime.progress", + "app.chain.search -> app.schemas", + "app.chain.search -> app.schemas.media", + "app.chain.search -> app.schemas.mediaserver", + "app.chain.search -> app.schemas.types", + "app.chain.site -> app.adapters", + "app.chain.site -> app.adapters.external", + "app.chain.site -> app.adapters.external.cookiecloud", + "app.chain.site -> app.adapters.network", + "app.chain.site -> app.adapters.network.browser", + "app.chain.site -> app.adapters.network.cloudflare", + "app.chain.site -> app.adapters.network.http", + "app.chain.site -> app.application", + "app.chain.site -> app.application.messaging", + "app.chain.site -> app.application.messaging.site", + "app.chain.site -> app.application.rss", + "app.chain.site -> app.application.security", + "app.chain.site -> app.application.security.cookie", + "app.chain.site -> app.application.site", + "app.chain.site -> app.chain", + "app.chain.site -> app.chain._interaction", + "app.chain.site -> app.db", + "app.chain.site -> app.db.models", + "app.chain.site -> app.db.models.site", + "app.chain.site -> app.db.oper", + "app.chain.site -> app.db.oper.site", + "app.chain.site -> app.db.oper.systemconfig", + "app.chain.site -> app.domain", + "app.chain.site -> app.domain.site", + "app.chain.site -> app.foundation", + "app.chain.site -> app.foundation.dom", + "app.chain.site -> app.foundation.size", + "app.chain.site -> app.foundation.url", + "app.chain.site -> app.runtime", + "app.chain.site -> app.runtime.config", + "app.chain.site -> app.runtime.events", + "app.chain.site -> app.runtime.log", + "app.chain.site -> app.schemas", + "app.chain.site -> app.schemas.message", + "app.chain.site -> app.schemas.notification", + "app.chain.site -> app.schemas.site", + "app.chain.site -> app.schemas.types", + "app.chain.storage -> app.application", + "app.chain.storage -> app.application.directory", + "app.chain.storage -> app.chain", + "app.chain.storage -> app.runtime", + "app.chain.storage -> app.runtime.config", + "app.chain.storage -> app.runtime.log", + "app.chain.storage -> app.schemas", + "app.chain.storage -> app.schemas.workflow", + "app.chain.subscribe -> app.adapters", + "app.chain.subscribe -> app.adapters.external", + "app.chain.subscribe -> app.adapters.external.server", + "app.chain.subscribe -> app.application", + "app.chain.subscribe -> app.application.mediaserver", + "app.chain.subscribe -> app.application.messaging", + "app.chain.subscribe -> app.application.messaging.subscribe", + "app.chain.subscribe -> app.application.subscribe", + "app.chain.subscribe -> app.application.subscription", + "app.chain.subscribe -> app.application.subscription.contract", + "app.chain.subscribe -> app.application.subscription.query", + "app.chain.subscribe -> app.application.torrent", + "app.chain.subscribe -> app.chain", + "app.chain.subscribe -> app.chain._interaction", + "app.chain.subscribe -> app.chain._music", + "app.chain.subscribe -> app.chain.download", + "app.chain.subscribe -> app.chain.media", + "app.chain.subscribe -> app.chain.mediaserver", + "app.chain.subscribe -> app.chain.search", + "app.chain.subscribe -> app.chain.tmdb", + "app.chain.subscribe -> app.chain.torrents", + "app.chain.subscribe -> app.db", + "app.chain.subscribe -> app.db.models", + "app.chain.subscribe -> app.db.models.subscribe", + "app.chain.subscribe -> app.db.oper", + "app.chain.subscribe -> app.db.oper.downloadhistory", + "app.chain.subscribe -> app.db.oper.site", + "app.chain.subscribe -> app.db.oper.subscribe", + "app.chain.subscribe -> app.db.oper.systemconfig", + "app.chain.subscribe -> app.domain", + "app.chain.subscribe -> app.domain.context", + "app.chain.subscribe -> app.domain.meta", + "app.chain.subscribe -> app.domain.meta.metabase", + "app.chain.subscribe -> app.domain.meta.metamusic", + "app.chain.subscribe -> app.domain.meta.words", + "app.chain.subscribe -> app.domain.metainfo", + "app.chain.subscribe -> app.runtime", + "app.chain.subscribe -> app.runtime.config", + "app.chain.subscribe -> app.runtime.events", + "app.chain.subscribe -> app.runtime.log", + "app.chain.subscribe -> app.schemas", + "app.chain.subscribe -> app.schemas.event", + "app.chain.subscribe -> app.schemas.media", + "app.chain.subscribe -> app.schemas.mediaserver", + "app.chain.subscribe -> app.schemas.message", + "app.chain.subscribe -> app.schemas.subscribe", + "app.chain.subscribe -> app.schemas.types", + "app.chain.subscribe -> app.schemas.workflow", + "app.chain.system -> app.adapters", + "app.chain.system -> app.adapters.network", + "app.chain.system -> app.adapters.network.http", + "app.chain.system -> app.adapters.system", + "app.chain.system -> app.adapters.system.host", + "app.chain.system -> app.chain", + "app.chain.system -> app.runtime", + "app.chain.system -> app.runtime.config", + "app.chain.system -> app.runtime.extensions", + "app.chain.system -> app.runtime.extensions.plugin_manager", + "app.chain.system -> app.runtime.log", + "app.chain.system -> app.runtime.state", + "app.chain.system -> app.schemas", + "app.chain.system -> app.schemas.message", + "app.chain.system -> app.schemas.notification", + "app.chain.theaudiodb -> app.chain", + "app.chain.theaudiodb -> app.chain.musicbrainz", + "app.chain.theaudiodb -> app.schemas", + "app.chain.theaudiodb -> app.schemas.types", + "app.chain.tmdb -> app.chain", + "app.chain.tmdb -> app.domain", + "app.chain.tmdb -> app.domain.context", + "app.chain.tmdb -> app.schemas", + "app.chain.tmdb -> app.schemas.context", + "app.chain.tmdb -> app.schemas.tmdb", + "app.chain.tmdb -> app.schemas.types", + "app.chain.torrents -> app.application", + "app.chain.torrents -> app.application.rss", + "app.chain.torrents -> app.application.site", + "app.chain.torrents -> app.application.torrent", + "app.chain.torrents -> app.chain", + "app.chain.torrents -> app.chain.media", + "app.chain.torrents -> app.db", + "app.chain.torrents -> app.db.oper", + "app.chain.torrents -> app.db.oper.site", + "app.chain.torrents -> app.db.oper.systemconfig", + "app.chain.torrents -> app.domain", + "app.chain.torrents -> app.domain.context", + "app.chain.torrents -> app.domain.meta", + "app.chain.torrents -> app.domain.meta.metamusic", + "app.chain.torrents -> app.domain.metainfo", + "app.chain.torrents -> app.domain.site", + "app.chain.torrents -> app.foundation", + "app.chain.torrents -> app.foundation.text", + "app.chain.torrents -> app.runtime", + "app.chain.torrents -> app.runtime.config", + "app.chain.torrents -> app.runtime.log", + "app.chain.torrents -> app.schemas", + "app.chain.torrents -> app.schemas.media", + "app.chain.torrents -> app.schemas.message", + "app.chain.torrents -> app.schemas.types", + "app.chain.transfer -> app.application", + "app.chain.transfer -> app.application.directory", + "app.chain.transfer -> app.application.formatting", + "app.chain.transfer -> app.application.history", + "app.chain.transfer -> app.application.transfer", + "app.chain.transfer -> app.chain", + "app.chain.transfer -> app.chain._transfer", + "app.chain.transfer -> app.chain.media", + "app.chain.transfer -> app.chain.storage", + "app.chain.transfer -> app.chain.tmdb", + "app.chain.transfer -> app.db", + "app.chain.transfer -> app.db.models", + "app.chain.transfer -> app.db.models.downloadhistory", + "app.chain.transfer -> app.db.oper", + "app.chain.transfer -> app.db.oper.downloadhistory", + "app.chain.transfer -> app.db.oper.systemconfig", + "app.chain.transfer -> app.db.oper.transferhistory", + "app.chain.transfer -> app.db.oper.transferpending", + "app.chain.transfer -> app.domain", + "app.chain.transfer -> app.domain.context", + "app.chain.transfer -> app.domain.episode", + "app.chain.transfer -> app.domain.meta", + "app.chain.transfer -> app.domain.meta.metabase", + "app.chain.transfer -> app.domain.meta.metamusic", + "app.chain.transfer -> app.domain.metainfo", + "app.chain.transfer -> app.foundation", + "app.chain.transfer -> app.foundation.singleton", + "app.chain.transfer -> app.runtime", + "app.chain.transfer -> app.runtime.config", + "app.chain.transfer -> app.runtime.events", + "app.chain.transfer -> app.runtime.log", + "app.chain.transfer -> app.runtime.progress", + "app.chain.transfer -> app.runtime.reload", + "app.chain.transfer -> app.schemas", + "app.chain.transfer -> app.schemas.event", + "app.chain.transfer -> app.schemas.exception", + "app.chain.transfer -> app.schemas.media", + "app.chain.transfer -> app.schemas.message", + "app.chain.transfer -> app.schemas.system", + "app.chain.transfer -> app.schemas.tmdb", + "app.chain.transfer -> app.schemas.transfer", + "app.chain.transfer -> app.schemas.types", + "app.chain.transfer -> app.schemas.workflow", + "app.chain.tvdb -> app.chain", + "app.chain.user -> app.application", + "app.chain.user -> app.application.security", + "app.chain.user -> app.application.security.access", + "app.chain.user -> app.application.security.otp", + "app.chain.user -> app.chain", + "app.chain.user -> app.db", + "app.chain.user -> app.db.models", + "app.chain.user -> app.db.models.user", + "app.chain.user -> app.db.oper", + "app.chain.user -> app.db.oper.user", + "app.chain.user -> app.runtime", + "app.chain.user -> app.runtime.config", + "app.chain.user -> app.runtime.log", + "app.chain.user -> app.schemas", + "app.chain.user -> app.schemas.event", + "app.chain.user -> app.schemas.types", + "app.chain.webhook -> app.chain", + "app.chain.webhook -> app.schemas", + "app.chain.webhook -> app.schemas.types", + "app.chain.workflow -> app.chain", + "app.chain.workflow -> app.db", + "app.chain.workflow -> app.db.models", + "app.chain.workflow -> app.db.oper", + "app.chain.workflow -> app.db.oper.workflow", + "app.chain.workflow -> app.runtime", + "app.chain.workflow -> app.runtime.config", + "app.chain.workflow -> app.runtime.events", + "app.chain.workflow -> app.runtime.log", + "app.chain.workflow -> app.schemas", + "app.chain.workflow -> app.schemas.types", + "app.chain.workflow -> app.schemas.workflow", + "app.chain.workflow -> app.workflow", + "app.cli -> app.doctor", + "app.cli -> app.doctor.formatters", + "app.cli -> app.runtime", + "app.cli -> app.runtime.config", + "app.cli -> app.runtime.state", + "app.command -> app.application", + "app.command -> app.application.messaging", + "app.command -> app.application.messaging.message", + "app.command -> app.application.messaging.skill", + "app.command -> app.chain", + "app.command -> app.chain.download", + "app.command -> app.chain.message", + "app.command -> app.chain.site", + "app.command -> app.chain.subscribe", + "app.command -> app.chain.system", + "app.command -> app.chain.transfer", + "app.command -> app.foundation", + "app.command -> app.foundation.collections", + "app.command -> app.foundation.reflection", + "app.command -> app.foundation.singleton", + "app.command -> app.runtime", + "app.command -> app.runtime.events", + "app.command -> app.runtime.extensions", + "app.command -> app.runtime.extensions.plugin_manager", + "app.command -> app.runtime.log", + "app.command -> app.runtime.thread", + "app.command -> app.scheduler", + "app.command -> app.schemas", + "app.command -> app.schemas.event", + "app.command -> app.schemas.message", + "app.command -> app.schemas.types", + "app.db.base -> app.db", + "app.db.base -> app.db.decorators", + "app.db.base -> app.runtime", + "app.db.base -> app.runtime.config", + "app.db.decorators -> app.db", + "app.db.decorators -> app.db.session", + "app.db.decorators -> app.runtime", + "app.db.decorators -> app.runtime.log", + "app.db.diagnostics -> app.runtime", + "app.db.diagnostics -> app.runtime.log", + "app.db.engine -> app.db", + "app.db.engine -> app.db.diagnostics", + "app.db.engine -> app.runtime", + "app.db.engine -> app.runtime.config", + "app.db.engine -> app.runtime.log", + "app.db.maintenance -> app.db", + "app.db.maintenance -> app.db.models", + "app.db.maintenance -> app.db.models.downloadfailure", + "app.db.maintenance -> app.db.models.downloadhistory", + "app.db.maintenance -> app.db.models.message", + "app.db.maintenance -> app.db.models.siteuserdata", + "app.db.maintenance -> app.db.models.transferhistory", + "app.db.models -> app.db", + "app.db.models -> app.db.models._identity", + "app.db.models._identity -> app.runtime", + "app.db.models._identity -> app.runtime.log", + "app.db.models._identity -> app.schemas", + "app.db.models._identity -> app.schemas.media", + "app.db.models.agentchat -> app.db", + "app.db.models.agentchat -> app.db.base", + "app.db.models.agentchat -> app.db.decorators", + "app.db.models.agenttask -> app.db", + "app.db.models.agenttask -> app.db.base", + "app.db.models.agenttask -> app.db.decorators", + "app.db.models.agenttaskrun -> app.db", + "app.db.models.agenttaskrun -> app.db.base", + "app.db.models.agenttaskrun -> app.db.decorators", + "app.db.models.agenttaskrun -> app.db.models", + "app.db.models.agenttaskrun -> app.db.models.agenttask", + "app.db.models.downloadfailure -> app.db", + "app.db.models.downloadfailure -> app.db.base", + "app.db.models.downloadfailure -> app.db.decorators", + "app.db.models.downloadfailure -> app.db.models", + "app.db.models.downloadfailure -> app.db.models._constraints", + "app.db.models.downloadhistory -> app.db", + "app.db.models.downloadhistory -> app.db.base", + "app.db.models.downloadhistory -> app.db.decorators", + "app.db.models.downloadhistory -> app.db.models", + "app.db.models.downloadhistory -> app.db.models._constraints", + "app.db.models.downloadhistory -> app.schemas", + "app.db.models.downloadhistory -> app.schemas.types", + "app.db.models.mediaserver -> app.db", + "app.db.models.mediaserver -> app.db.base", + "app.db.models.mediaserver -> app.db.decorators", + "app.db.models.mediaserver -> app.db.models", + "app.db.models.mediaserver -> app.db.models._constraints", + "app.db.models.mediaserver -> app.schemas", + "app.db.models.mediaserver -> app.schemas.types", + "app.db.models.message -> app.db", + "app.db.models.message -> app.db.base", + "app.db.models.message -> app.db.decorators", + "app.db.models.passkey -> app.db", + "app.db.models.passkey -> app.db.base", + "app.db.models.passkey -> app.db.decorators", + "app.db.models.plugindata -> app.db", + "app.db.models.plugindata -> app.db.base", + "app.db.models.plugindata -> app.db.decorators", + "app.db.models.site -> app.db", + "app.db.models.site -> app.db.base", + "app.db.models.site -> app.db.decorators", + "app.db.models.siteicon -> app.db", + "app.db.models.siteicon -> app.db.base", + "app.db.models.siteicon -> app.db.decorators", + "app.db.models.sitestatistic -> app.db", + "app.db.models.sitestatistic -> app.db.base", + "app.db.models.sitestatistic -> app.db.decorators", + "app.db.models.siteuserdata -> app.db", + "app.db.models.siteuserdata -> app.db.base", + "app.db.models.siteuserdata -> app.db.decorators", + "app.db.models.subscribe -> app.db", + "app.db.models.subscribe -> app.db.base", + "app.db.models.subscribe -> app.db.decorators", + "app.db.models.subscribe -> app.db.models", + "app.db.models.subscribe -> app.db.models._constraints", + "app.db.models.subscribe -> app.schemas", + "app.db.models.subscribe -> app.schemas.types", + "app.db.models.subscribehistory -> app.db", + "app.db.models.subscribehistory -> app.db.base", + "app.db.models.subscribehistory -> app.db.decorators", + "app.db.models.subscribehistory -> app.db.models", + "app.db.models.subscribehistory -> app.db.models._constraints", + "app.db.models.subscribehistory -> app.schemas", + "app.db.models.subscribehistory -> app.schemas.types", + "app.db.models.systemconfig -> app.db", + "app.db.models.systemconfig -> app.db.base", + "app.db.models.systemconfig -> app.db.decorators", + "app.db.models.transferhistory -> app.db", + "app.db.models.transferhistory -> app.db.base", + "app.db.models.transferhistory -> app.db.decorators", + "app.db.models.transferhistory -> app.db.models", + "app.db.models.transferhistory -> app.db.models._constraints", + "app.db.models.transferhistory -> app.schemas", + "app.db.models.transferhistory -> app.schemas.types", + "app.db.models.transferpending -> app.db", + "app.db.models.transferpending -> app.db.base", + "app.db.models.transferpending -> app.db.decorators", + "app.db.models.user -> app.db", + "app.db.models.user -> app.db.base", + "app.db.models.user -> app.db.decorators", + "app.db.models.userconfig -> app.db", + "app.db.models.userconfig -> app.db.base", + "app.db.models.userconfig -> app.db.decorators", + "app.db.models.workflow -> app.db", + "app.db.models.workflow -> app.db.base", + "app.db.models.workflow -> app.db.decorators", + "app.db.oper -> app.db", + "app.db.oper -> app.db.oper.agentchat", + "app.db.oper -> app.db.oper.agenttask", + "app.db.oper -> app.db.oper.downloadfailure", + "app.db.oper -> app.db.oper.downloadhistory", + "app.db.oper -> app.db.oper.mediaserver", + "app.db.oper -> app.db.oper.message", + "app.db.oper -> app.db.oper.plugindata", + "app.db.oper -> app.db.oper.site", + "app.db.oper -> app.db.oper.subscribe", + "app.db.oper -> app.db.oper.subscribehistory", + "app.db.oper -> app.db.oper.systemconfig", + "app.db.oper -> app.db.oper.transferhistory", + "app.db.oper -> app.db.oper.transferpending", + "app.db.oper -> app.db.oper.user", + "app.db.oper -> app.db.oper.userconfig", + "app.db.oper -> app.db.oper.workflow", + "app.db.oper.agentchat -> app.db", + "app.db.oper.agentchat -> app.db.base", + "app.db.oper.agentchat -> app.db.models", + "app.db.oper.agentchat -> app.db.models.agentchat", + "app.db.oper.agentchat -> app.schemas", + "app.db.oper.agentchat -> app.schemas.types", + "app.db.oper.agenttask -> app.db", + "app.db.oper.agenttask -> app.db.base", + "app.db.oper.agenttask -> app.db.models", + "app.db.oper.agenttask -> app.db.models.agenttask", + "app.db.oper.agenttask -> app.db.models.agenttaskrun", + "app.db.oper.downloadfailure -> app.db", + "app.db.oper.downloadfailure -> app.db.base", + "app.db.oper.downloadfailure -> app.db.models", + "app.db.oper.downloadfailure -> app.db.models.downloadfailure", + "app.db.oper.downloadhistory -> app.db", + "app.db.oper.downloadhistory -> app.db.base", + "app.db.oper.downloadhistory -> app.db.models", + "app.db.oper.downloadhistory -> app.db.models.downloadhistory", + "app.db.oper.downloadhistory -> app.schemas", + "app.db.oper.downloadhistory -> app.schemas.types", + "app.db.oper.mediaserver -> app.db", + "app.db.oper.mediaserver -> app.db.base", + "app.db.oper.mediaserver -> app.db.models", + "app.db.oper.mediaserver -> app.db.models.mediaserver", + "app.db.oper.message -> app.db", + "app.db.oper.message -> app.db.base", + "app.db.oper.message -> app.db.models", + "app.db.oper.message -> app.db.models.message", + "app.db.oper.message -> app.schemas", + "app.db.oper.message -> app.schemas.message", + "app.db.oper.message -> app.schemas.notification", + "app.db.oper.plugindata -> app.db", + "app.db.oper.plugindata -> app.db.base", + "app.db.oper.plugindata -> app.db.models", + "app.db.oper.plugindata -> app.db.models.plugindata", + "app.db.oper.site -> app.db", + "app.db.oper.site -> app.db.base", + "app.db.oper.site -> app.db.models", + "app.db.oper.site -> app.db.models.site", + "app.db.oper.site -> app.db.models.siteicon", + "app.db.oper.site -> app.db.models.sitestatistic", + "app.db.oper.site -> app.db.models.siteuserdata", + "app.db.oper.subscribe -> app.application", + "app.db.oper.subscribe -> app.application.subscription", + "app.db.oper.subscribe -> app.application.subscription.delete", + "app.db.oper.subscribe -> app.db", + "app.db.oper.subscribe -> app.db.base", + "app.db.oper.subscribe -> app.db.models", + "app.db.oper.subscribe -> app.db.models.subscribe", + "app.db.oper.subscribe -> app.db.models.subscribehistory", + "app.db.oper.subscribe -> app.schemas", + "app.db.oper.subscribe -> app.schemas.types", + "app.db.oper.subscribehistory -> app.db", + "app.db.oper.subscribehistory -> app.db.base", + "app.db.oper.subscribehistory -> app.db.models", + "app.db.oper.subscribehistory -> app.db.models.subscribehistory", + "app.db.oper.systemconfig -> app.db", + "app.db.oper.systemconfig -> app.db.base", + "app.db.oper.systemconfig -> app.db.models", + "app.db.oper.systemconfig -> app.db.models.systemconfig", + "app.db.oper.systemconfig -> app.foundation", + "app.db.oper.systemconfig -> app.foundation.singleton", + "app.db.oper.systemconfig -> app.schemas", + "app.db.oper.systemconfig -> app.schemas.types", + "app.db.oper.transferhistory -> app.db", + "app.db.oper.transferhistory -> app.db.base", + "app.db.oper.transferhistory -> app.db.models", + "app.db.oper.transferhistory -> app.db.models.transferhistory", + "app.db.oper.transferhistory -> app.schemas", + "app.db.oper.transferhistory -> app.schemas.types", + "app.db.oper.transferpending -> app.db", + "app.db.oper.transferpending -> app.db.base", + "app.db.oper.transferpending -> app.db.models", + "app.db.oper.transferpending -> app.db.models.transferpending", + "app.db.oper.user -> app.db", + "app.db.oper.user -> app.db.base", + "app.db.oper.user -> app.db.models", + "app.db.oper.user -> app.db.models.user", + "app.db.oper.userconfig -> app.db", + "app.db.oper.userconfig -> app.db.base", + "app.db.oper.userconfig -> app.db.models", + "app.db.oper.userconfig -> app.db.models.userconfig", + "app.db.oper.userconfig -> app.foundation", + "app.db.oper.userconfig -> app.foundation.singleton", + "app.db.oper.userconfig -> app.schemas", + "app.db.oper.userconfig -> app.schemas.types", + "app.db.oper.workflow -> app.db", + "app.db.oper.workflow -> app.db.base", + "app.db.oper.workflow -> app.db.models", + "app.db.oper.workflow -> app.db.models.workflow", + "app.db.session -> app.db", + "app.db.session -> app.db.engine", + "app.db.session -> app.runtime", + "app.db.session -> app.runtime.config", + "app.db.session -> app.runtime.log", + "app.doctor -> app.doctor.models", + "app.doctor -> app.doctor.runner", + "app.doctor.checks -> app.adapters", + "app.doctor.checks -> app.adapters.system", + "app.doctor.checks -> app.adapters.system.host", + "app.doctor.checks -> app.doctor", + "app.doctor.checks -> app.doctor.models", + "app.doctor.checks -> app.runtime", + "app.doctor.checks -> app.runtime.config", + "app.doctor.formatters -> app.doctor", + "app.doctor.formatters -> app.doctor.models", + "app.doctor.runner -> app.adapters", + "app.doctor.runner -> app.adapters.system", + "app.doctor.runner -> app.adapters.system.host", + "app.doctor.runner -> app.doctor", + "app.doctor.runner -> app.doctor.checks", + "app.doctor.runner -> app.doctor.models", + "app.doctor.runner -> app.runtime", + "app.doctor.runner -> app.runtime.config", + "app.domain.context -> app.domain", + "app.domain.context -> app.domain.meta", + "app.domain.context -> app.domain.meta.metabase", + "app.domain.context -> app.domain.meta.metamusic", + "app.domain.context -> app.domain.metainfo", + "app.domain.context -> app.foundation", + "app.domain.context -> app.foundation.temporal", + "app.domain.context -> app.schemas", + "app.domain.context -> app.schemas.media", + "app.domain.context -> app.schemas.types", + "app.domain.media -> app.schemas", + "app.domain.media -> app.schemas.media", + "app.domain.media -> app.schemas.types", + "app.domain.meta.customization -> app.foundation", + "app.domain.meta.customization -> app.foundation.singleton", + "app.domain.meta.infopath -> app.domain", + "app.domain.meta.infopath -> app.domain.meta", + "app.domain.meta.infopath -> app.domain.meta.metabase", + "app.domain.meta.infopath -> app.foundation", + "app.domain.meta.infopath -> app.foundation.text", + "app.domain.meta.metaanime -> app.domain", + "app.domain.meta.metaanime -> app.domain.meta", + "app.domain.meta.metaanime -> app.domain.meta.customization", + "app.domain.meta.metaanime -> app.domain.meta.metabase", + "app.domain.meta.metaanime -> app.domain.meta.releasegroup", + "app.domain.meta.metaanime -> app.domain.title", + "app.domain.meta.metaanime -> app.foundation", + "app.domain.meta.metaanime -> app.foundation.text", + "app.domain.meta.metaanime -> app.schemas", + "app.domain.meta.metaanime -> app.schemas.types", + "app.domain.meta.metabase -> app.foundation", + "app.domain.meta.metabase -> app.foundation.text", + "app.domain.meta.metabase -> app.schemas", + "app.domain.meta.metabase -> app.schemas.media", + "app.domain.meta.metabase -> app.schemas.types", + "app.domain.meta.metamusic -> app.domain", + "app.domain.meta.metamusic -> app.domain.meta", + "app.domain.meta.metamusic -> app.domain.meta.metabase", + "app.domain.meta.metamusic -> app.domain.meta.runtime", + "app.domain.meta.metamusic -> app.schemas", + "app.domain.meta.metamusic -> app.schemas.media", + "app.domain.meta.metamusic -> app.schemas.types", + "app.domain.meta.metavideo -> app.domain", + "app.domain.meta.metavideo -> app.domain.meta", + "app.domain.meta.metavideo -> app.domain.meta.customization", + "app.domain.meta.metavideo -> app.domain.meta.metabase", + "app.domain.meta.metavideo -> app.domain.meta.releasegroup", + "app.domain.meta.metavideo -> app.domain.meta.runtime", + "app.domain.meta.metavideo -> app.domain.meta.streamingplatform", + "app.domain.meta.metavideo -> app.domain.tokens", + "app.domain.meta.metavideo -> app.foundation", + "app.domain.meta.metavideo -> app.foundation.text", + "app.domain.meta.metavideo -> app.schemas", + "app.domain.meta.metavideo -> app.schemas.types", + "app.domain.meta.releasegroup -> app.foundation", + "app.domain.meta.releasegroup -> app.foundation.singleton", + "app.domain.meta.streamingplatform -> app.foundation", + "app.domain.meta.streamingplatform -> app.foundation.singleton", + "app.domain.meta.words -> app.foundation", + "app.domain.meta.words -> app.foundation.singleton", + "app.domain.metainfo -> app.domain", + "app.domain.metainfo -> app.domain.meta", + "app.domain.metainfo -> app.domain.meta.customization", + "app.domain.metainfo -> app.domain.meta.infopath", + "app.domain.metainfo -> app.domain.meta.metaanime", + "app.domain.metainfo -> app.domain.meta.metabase", + "app.domain.metainfo -> app.domain.meta.metamusic", + "app.domain.metainfo -> app.domain.meta.metavideo", + "app.domain.metainfo -> app.domain.meta.releasegroup", + "app.domain.metainfo -> app.domain.meta.runtime", + "app.domain.metainfo -> app.domain.meta.streamingplatform", + "app.domain.metainfo -> app.domain.meta.words", + "app.domain.metainfo -> app.schemas", + "app.domain.metainfo -> app.schemas.media", + "app.domain.metainfo -> app.schemas.types", + "app.domain.scraper -> app.domain", + "app.domain.scraper -> app.domain.context", + "app.domain.scraper -> app.foundation", + "app.domain.scraper -> app.foundation.dom", + "app.domain.scraper -> app.schemas", + "app.domain.scraper -> app.schemas.types", + "app.domain.site -> app.foundation", + "app.domain.site -> app.foundation.dom", + "app.domain.site -> app.foundation.url", + "app.domain.title -> app.foundation", + "app.domain.title -> app.foundation.text", + "app.domain.title -> app.schemas", + "app.domain.title -> app.schemas.types", + "app.factory -> app.api", + "app.factory -> app.api.response", + "app.factory -> app.application", + "app.factory -> app.application.plugins", + "app.factory -> app.runtime", + "app.factory -> app.runtime.config", + "app.factory -> app.runtime.localization", + "app.factory -> app.runtime.log", + "app.factory -> app.schemas", + "app.factory -> app.schemas.mcp", + "app.factory -> app.schemas.openai", + "app.factory -> app.schemas.response", + "app.factory -> app.startup", + "app.factory -> app.startup.lifecycle", + "app.main -> app.adapters", + "app.main -> app.adapters.system", + "app.main -> app.adapters.system.host", + "app.main -> app.adapters.system.stdio", + "app.main -> app.factory", + "app.main -> app.runtime", + "app.main -> app.runtime.config", + "app.main -> app.startup", + "app.main -> app.startup.database_initializer", + "app.modules -> app.runtime", + "app.modules -> app.runtime.extensions", + "app.modules -> app.runtime.extensions.service_config", + "app.modules -> app.runtime.log", + "app.modules -> app.runtime.reload", + "app.modules -> app.schemas", + "app.modules -> app.schemas.message", + "app.modules -> app.schemas.system", + "app.modules -> app.schemas.types", + "app.modules._base -> app.modules", + "app.modules._base -> app.modules._base.downloader", + "app.modules._base -> app.modules._base.mediaserver", + "app.modules._base -> app.modules._base.notification", + "app.modules._base.downloader -> app.domain", + "app.modules._base.downloader -> app.domain.torrent", + "app.modules._base.downloader -> app.modules", + "app.modules._base.downloader -> app.runtime", + "app.modules._base.downloader -> app.runtime.cache", + "app.modules._base.downloader -> app.runtime.log", + "app.modules._base.downloader -> app.schemas", + "app.modules._base.downloader -> app.schemas.types", + "app.modules._base.mediaserver -> app.application", + "app.modules._base.mediaserver -> app.application.mediaserver", + "app.modules._base.mediaserver -> app.domain", + "app.modules._base.mediaserver -> app.domain.context", + "app.modules._base.mediaserver -> app.modules", + "app.modules._base.mediaserver -> app.runtime", + "app.modules._base.mediaserver -> app.runtime.events", + "app.modules._base.mediaserver -> app.runtime.log", + "app.modules._base.mediaserver -> app.schemas", + "app.modules._base.mediaserver -> app.schemas.event", + "app.modules._base.mediaserver -> app.schemas.mediaserver", + "app.modules._base.mediaserver -> app.schemas.types", + "app.modules._base.notification -> app.application", + "app.modules._base.notification -> app.application.messaging", + "app.modules._base.notification -> app.application.messaging.agent", + "app.modules._base.notification -> app.foundation", + "app.modules._base.notification -> app.foundation.collections", + "app.modules._base.notification -> app.modules", + "app.modules._base.notification -> app.runtime", + "app.modules._base.notification -> app.runtime.events", + "app.modules._base.notification -> app.runtime.log", + "app.modules._base.notification -> app.schemas", + "app.modules._base.notification -> app.schemas.event", + "app.modules._base.notification -> app.schemas.types", + "app.modules.acoustid -> app.adapters", + "app.modules.acoustid -> app.adapters.network", + "app.modules.acoustid -> app.adapters.network.http", + "app.modules.acoustid -> app.modules", + "app.modules.acoustid -> app.runtime", + "app.modules.acoustid -> app.runtime.config", + "app.modules.acoustid -> app.runtime.log", + "app.modules.acoustid -> app.schemas", + "app.modules.acoustid -> app.schemas.types", + "app.modules.anilist -> app.domain", + "app.modules.anilist -> app.domain.context", + "app.modules.anilist -> app.domain.media", + "app.modules.anilist -> app.domain.meta", + "app.modules.anilist -> app.domain.meta.metabase", + "app.modules.anilist -> app.domain.scraper", + "app.modules.anilist -> app.modules", + "app.modules.anilist -> app.modules.anilist.anilist", + "app.modules.anilist -> app.runtime", + "app.modules.anilist -> app.runtime.config", + "app.modules.anilist -> app.runtime.log", + "app.modules.anilist -> app.schemas", + "app.modules.anilist -> app.schemas.context", + "app.modules.anilist -> app.schemas.types", + "app.modules.anilist.anilist -> app.adapters", + "app.modules.anilist.anilist -> app.adapters.network", + "app.modules.anilist.anilist -> app.adapters.network.http", + "app.modules.anilist.anilist -> app.runtime", + "app.modules.anilist.anilist -> app.runtime.cache", + "app.modules.anilist.anilist -> app.runtime.config", + "app.modules.anilist.anilist -> app.runtime.log", + "app.modules.bangumi -> app.adapters", + "app.modules.bangumi -> app.adapters.network", + "app.modules.bangumi -> app.adapters.network.http", + "app.modules.bangumi -> app.domain", + "app.modules.bangumi -> app.domain.context", + "app.modules.bangumi -> app.domain.media", + "app.modules.bangumi -> app.domain.meta", + "app.modules.bangumi -> app.domain.meta.metabase", + "app.modules.bangumi -> app.domain.scraper", + "app.modules.bangumi -> app.modules", + "app.modules.bangumi -> app.modules.bangumi.bangumi", + "app.modules.bangumi -> app.runtime", + "app.modules.bangumi -> app.runtime.config", + "app.modules.bangumi -> app.runtime.log", + "app.modules.bangumi -> app.schemas", + "app.modules.bangumi -> app.schemas.context", + "app.modules.bangumi -> app.schemas.types", + "app.modules.bangumi.bangumi -> app.adapters", + "app.modules.bangumi.bangumi -> app.adapters.network", + "app.modules.bangumi.bangumi -> app.adapters.network.http", + "app.modules.bangumi.bangumi -> app.runtime", + "app.modules.bangumi.bangumi -> app.runtime.cache", + "app.modules.bangumi.bangumi -> app.runtime.config", + "app.modules.discord -> app.adapters", + "app.modules.discord -> app.adapters.network", + "app.modules.discord -> app.adapters.network.http", + "app.modules.discord -> app.application", + "app.modules.discord -> app.application.messaging", + "app.modules.discord -> app.application.messaging.agent", + "app.modules.discord -> app.domain", + "app.modules.discord -> app.domain.context", + "app.modules.discord -> app.modules", + "app.modules.discord -> app.modules._base", + "app.modules.discord -> app.modules.discord.discord", + "app.modules.discord -> app.runtime", + "app.modules.discord -> app.runtime.log", + "app.modules.discord -> app.schemas", + "app.modules.discord -> app.schemas.event", + "app.modules.discord -> app.schemas.message", + "app.modules.discord -> app.schemas.notification", + "app.modules.discord -> app.schemas.types", + "app.modules.discord.discord -> app.domain", + "app.modules.discord.discord -> app.domain.context", + "app.modules.discord.discord -> app.domain.metainfo", + "app.modules.discord.discord -> app.foundation", + "app.modules.discord.discord -> app.foundation.size", + "app.modules.discord.discord -> app.runtime", + "app.modules.discord.discord -> app.runtime.config", + "app.modules.discord.discord -> app.runtime.log", + "app.modules.discord.discord -> app.schemas", + "app.modules.discord.discord -> app.schemas.types", + "app.modules.douban -> app.adapters", + "app.modules.douban -> app.adapters.network", + "app.modules.douban -> app.adapters.network.http", + "app.modules.douban -> app.domain", + "app.modules.douban -> app.domain.context", + "app.modules.douban -> app.domain.media", + "app.modules.douban -> app.domain.meta", + "app.modules.douban -> app.domain.meta.metabase", + "app.modules.douban -> app.domain.meta.metamusic", + "app.modules.douban -> app.domain.metainfo", + "app.modules.douban -> app.foundation", + "app.modules.douban -> app.foundation.text", + "app.modules.douban -> app.modules", + "app.modules.douban -> app.modules.douban.apiv2", + "app.modules.douban -> app.modules.douban.scraper", + "app.modules.douban -> app.runtime", + "app.modules.douban -> app.runtime.config", + "app.modules.douban -> app.runtime.execution", + "app.modules.douban -> app.runtime.log", + "app.modules.douban -> app.runtime.rate", + "app.modules.douban -> app.schemas", + "app.modules.douban -> app.schemas.context", + "app.modules.douban -> app.schemas.exception", + "app.modules.douban -> app.schemas.types", + "app.modules.douban.apiv2 -> app.adapters", + "app.modules.douban.apiv2 -> app.adapters.network", + "app.modules.douban.apiv2 -> app.adapters.network.http", + "app.modules.douban.apiv2 -> app.foundation", + "app.modules.douban.apiv2 -> app.foundation.singleton", + "app.modules.douban.apiv2 -> app.runtime", + "app.modules.douban.apiv2 -> app.runtime.cache", + "app.modules.douban.apiv2 -> app.runtime.config", + "app.modules.douban.scraper -> app.domain", + "app.modules.douban.scraper -> app.domain.context", + "app.modules.douban.scraper -> app.foundation", + "app.modules.douban.scraper -> app.foundation.dom", + "app.modules.douban.scraper -> app.schemas", + "app.modules.douban.scraper -> app.schemas.types", + "app.modules.emby -> app.modules", + "app.modules.emby -> app.modules._base", + "app.modules.emby -> app.modules.emby.emby", + "app.modules.emby -> app.runtime", + "app.modules.emby -> app.runtime.log", + "app.modules.emby -> app.schemas", + "app.modules.emby -> app.schemas.dashboard", + "app.modules.emby -> app.schemas.mediaserver", + "app.modules.emby -> app.schemas.types", + "app.modules.emby.emby -> app.adapters", + "app.modules.emby.emby -> app.adapters.network", + "app.modules.emby.emby -> app.adapters.network.http", + "app.modules.emby.emby -> app.application", + "app.modules.emby.emby -> app.application.mediaserver", + "app.modules.emby.emby -> app.foundation", + "app.modules.emby.emby -> app.foundation.url", + "app.modules.emby.emby -> app.runtime", + "app.modules.emby.emby -> app.runtime.config", + "app.modules.emby.emby -> app.runtime.log", + "app.modules.emby.emby -> app.schemas", + "app.modules.emby.emby -> app.schemas.dashboard", + "app.modules.emby.emby -> app.schemas.mediaserver", + "app.modules.emby.emby -> app.schemas.types", + "app.modules.fanart -> app.adapters", + "app.modules.fanart -> app.adapters.network", + "app.modules.fanart -> app.adapters.network.http", + "app.modules.fanart -> app.domain", + "app.modules.fanart -> app.domain.context", + "app.modules.fanart -> app.modules", + "app.modules.fanart -> app.runtime", + "app.modules.fanart -> app.runtime.cache", + "app.modules.fanart -> app.runtime.config", + "app.modules.fanart -> app.runtime.log", + "app.modules.fanart -> app.schemas", + "app.modules.fanart -> app.schemas.types", + "app.modules.feishu -> app.application", + "app.modules.feishu -> app.application.messaging", + "app.modules.feishu -> app.application.messaging.agent", + "app.modules.feishu -> app.domain", + "app.modules.feishu -> app.domain.context", + "app.modules.feishu -> app.modules", + "app.modules.feishu -> app.modules._base", + "app.modules.feishu -> app.modules.feishu.feishu", + "app.modules.feishu -> app.runtime", + "app.modules.feishu -> app.runtime.log", + "app.modules.feishu -> app.schemas", + "app.modules.feishu -> app.schemas.message", + "app.modules.feishu -> app.schemas.notification", + "app.modules.feishu -> app.schemas.types", + "app.modules.feishu.feishu -> app.adapters", + "app.modules.feishu.feishu -> app.adapters.network", + "app.modules.feishu.feishu -> app.adapters.network.http", + "app.modules.feishu.feishu -> app.application", + "app.modules.feishu.feishu -> app.application.messaging", + "app.modules.feishu.feishu -> app.application.messaging.agent", + "app.modules.feishu.feishu -> app.db", + "app.modules.feishu.feishu -> app.db.oper", + "app.modules.feishu.feishu -> app.db.oper.user", + "app.modules.feishu.feishu -> app.domain", + "app.modules.feishu.feishu -> app.domain.context", + "app.modules.feishu.feishu -> app.runtime", + "app.modules.feishu.feishu -> app.runtime.config", + "app.modules.feishu.feishu -> app.runtime.log", + "app.modules.feishu.feishu -> app.schemas", + "app.modules.feishu.feishu -> app.schemas.message", + "app.modules.feishu.feishu -> app.schemas.types", + "app.modules.filemanager.module -> app.adapters", + "app.modules.filemanager.module -> app.adapters.system", + "app.modules.filemanager.module -> app.adapters.system.host", + "app.modules.filemanager.module -> app.application", + "app.modules.filemanager.module -> app.application.directory", + "app.modules.filemanager.module -> app.application.messaging", + "app.modules.filemanager.module -> app.application.messaging.message", + "app.modules.filemanager.module -> app.domain", + "app.modules.filemanager.module -> app.domain.context", + "app.modules.filemanager.module -> app.domain.meta", + "app.modules.filemanager.module -> app.domain.meta.metabase", + "app.modules.filemanager.module -> app.domain.meta.metamusic", + "app.modules.filemanager.module -> app.domain.metainfo", + "app.modules.filemanager.module -> app.foundation", + "app.modules.filemanager.module -> app.foundation.reflection", + "app.modules.filemanager.module -> app.foundation.text", + "app.modules.filemanager.module -> app.modules", + "app.modules.filemanager.module -> app.modules.filemanager", + "app.modules.filemanager.module -> app.modules.filemanager.storages", + "app.modules.filemanager.module -> app.modules.filemanager.transhandler", + "app.modules.filemanager.module -> app.runtime", + "app.modules.filemanager.module -> app.runtime.config", + "app.modules.filemanager.module -> app.runtime.log", + "app.modules.filemanager.module -> app.schemas", + "app.modules.filemanager.module -> app.schemas.file", + "app.modules.filemanager.module -> app.schemas.mediaserver", + "app.modules.filemanager.module -> app.schemas.system", + "app.modules.filemanager.module -> app.schemas.tmdb", + "app.modules.filemanager.module -> app.schemas.transfer", + "app.modules.filemanager.module -> app.schemas.types", + "app.modules.filemanager.module -> app.schemas.workflow", + "app.modules.filemanager.storages -> app.application", + "app.modules.filemanager.storages -> app.application.storage", + "app.modules.filemanager.storages -> app.foundation", + "app.modules.filemanager.storages -> app.foundation.crypto", + "app.modules.filemanager.storages -> app.runtime", + "app.modules.filemanager.storages -> app.runtime.log", + "app.modules.filemanager.storages -> app.runtime.progress", + "app.modules.filemanager.storages -> app.schemas", + "app.modules.filemanager.storages -> app.schemas.exception", + "app.modules.filemanager.storages -> app.schemas.file", + "app.modules.filemanager.storages -> app.schemas.system", + "app.modules.filemanager.storages -> app.schemas.workflow", + "app.modules.filemanager.storages.alipan -> app.adapters", + "app.modules.filemanager.storages.alipan -> app.adapters.network", + "app.modules.filemanager.storages.alipan -> app.adapters.network.http", + "app.modules.filemanager.storages.alipan -> app.foundation", + "app.modules.filemanager.storages.alipan -> app.foundation.singleton", + "app.modules.filemanager.storages.alipan -> app.foundation.temporal", + "app.modules.filemanager.storages.alipan -> app.modules", + "app.modules.filemanager.storages.alipan -> app.modules.filemanager", + "app.modules.filemanager.storages.alipan -> app.modules.filemanager.storages", + "app.modules.filemanager.storages.alipan -> app.runtime", + "app.modules.filemanager.storages.alipan -> app.runtime.config", + "app.modules.filemanager.storages.alipan -> app.runtime.log", + "app.modules.filemanager.storages.alipan -> app.schemas", + "app.modules.filemanager.storages.alipan -> app.schemas.exception", + "app.modules.filemanager.storages.alipan -> app.schemas.file", + "app.modules.filemanager.storages.alipan -> app.schemas.types", + "app.modules.filemanager.storages.alipan -> app.schemas.workflow", + "app.modules.filemanager.storages.alist -> app.adapters", + "app.modules.filemanager.storages.alist -> app.adapters.network", + "app.modules.filemanager.storages.alist -> app.adapters.network.http", + "app.modules.filemanager.storages.alist -> app.foundation", + "app.modules.filemanager.storages.alist -> app.foundation.singleton", + "app.modules.filemanager.storages.alist -> app.foundation.url", + "app.modules.filemanager.storages.alist -> app.modules", + "app.modules.filemanager.storages.alist -> app.modules.filemanager", + "app.modules.filemanager.storages.alist -> app.modules.filemanager.storages", + "app.modules.filemanager.storages.alist -> app.runtime", + "app.modules.filemanager.storages.alist -> app.runtime.cache", + "app.modules.filemanager.storages.alist -> app.runtime.config", + "app.modules.filemanager.storages.alist -> app.runtime.log", + "app.modules.filemanager.storages.alist -> app.schemas", + "app.modules.filemanager.storages.alist -> app.schemas.exception", + "app.modules.filemanager.storages.alist -> app.schemas.file", + "app.modules.filemanager.storages.alist -> app.schemas.types", + "app.modules.filemanager.storages.alist -> app.schemas.workflow", + "app.modules.filemanager.storages.alistgo -> app.modules", + "app.modules.filemanager.storages.alistgo -> app.modules.filemanager", + "app.modules.filemanager.storages.alistgo -> app.modules.filemanager.storages", + "app.modules.filemanager.storages.alistgo -> app.modules.filemanager.storages.alist", + "app.modules.filemanager.storages.alistgo -> app.schemas", + "app.modules.filemanager.storages.alistgo -> app.schemas.types", + "app.modules.filemanager.storages.local -> app.adapters", + "app.modules.filemanager.storages.local -> app.adapters.system", + "app.modules.filemanager.storages.local -> app.adapters.system.fsproxy", + "app.modules.filemanager.storages.local -> app.adapters.system.host", + "app.modules.filemanager.storages.local -> app.application", + "app.modules.filemanager.storages.local -> app.application.directory", + "app.modules.filemanager.storages.local -> app.modules", + "app.modules.filemanager.storages.local -> app.modules.filemanager", + "app.modules.filemanager.storages.local -> app.modules.filemanager.storages", + "app.modules.filemanager.storages.local -> app.runtime", + "app.modules.filemanager.storages.local -> app.runtime.config", + "app.modules.filemanager.storages.local -> app.runtime.log", + "app.modules.filemanager.storages.local -> app.schemas", + "app.modules.filemanager.storages.local -> app.schemas.exception", + "app.modules.filemanager.storages.local -> app.schemas.file", + "app.modules.filemanager.storages.local -> app.schemas.types", + "app.modules.filemanager.storages.local -> app.schemas.workflow", + "app.modules.filemanager.storages.rclone -> app.adapters", + "app.modules.filemanager.storages.rclone -> app.adapters.system", + "app.modules.filemanager.storages.rclone -> app.adapters.system.host", + "app.modules.filemanager.storages.rclone -> app.foundation", + "app.modules.filemanager.storages.rclone -> app.foundation.temporal", + "app.modules.filemanager.storages.rclone -> app.modules", + "app.modules.filemanager.storages.rclone -> app.modules.filemanager", + "app.modules.filemanager.storages.rclone -> app.modules.filemanager.storages", + "app.modules.filemanager.storages.rclone -> app.runtime", + "app.modules.filemanager.storages.rclone -> app.runtime.config", + "app.modules.filemanager.storages.rclone -> app.runtime.log", + "app.modules.filemanager.storages.rclone -> app.schemas", + "app.modules.filemanager.storages.rclone -> app.schemas.exception", + "app.modules.filemanager.storages.rclone -> app.schemas.file", + "app.modules.filemanager.storages.rclone -> app.schemas.types", + "app.modules.filemanager.storages.rclone -> app.schemas.workflow", + "app.modules.filemanager.storages.smb -> app.foundation", + "app.modules.filemanager.storages.smb -> app.foundation.singleton", + "app.modules.filemanager.storages.smb -> app.modules", + "app.modules.filemanager.storages.smb -> app.modules.filemanager", + "app.modules.filemanager.storages.smb -> app.modules.filemanager.storages", + "app.modules.filemanager.storages.smb -> app.runtime", + "app.modules.filemanager.storages.smb -> app.runtime.config", + "app.modules.filemanager.storages.smb -> app.runtime.log", + "app.modules.filemanager.storages.smb -> app.schemas", + "app.modules.filemanager.storages.smb -> app.schemas.exception", + "app.modules.filemanager.storages.smb -> app.schemas.file", + "app.modules.filemanager.storages.smb -> app.schemas.types", + "app.modules.filemanager.storages.smb -> app.schemas.workflow", + "app.modules.filemanager.storages.u115 -> app.foundation", + "app.modules.filemanager.storages.u115 -> app.foundation.singleton", + "app.modules.filemanager.storages.u115 -> app.foundation.size", + "app.modules.filemanager.storages.u115 -> app.modules", + "app.modules.filemanager.storages.u115 -> app.modules.filemanager", + "app.modules.filemanager.storages.u115 -> app.modules.filemanager.storages", + "app.modules.filemanager.storages.u115 -> app.runtime", + "app.modules.filemanager.storages.u115 -> app.runtime.config", + "app.modules.filemanager.storages.u115 -> app.runtime.log", + "app.modules.filemanager.storages.u115 -> app.runtime.rate", + "app.modules.filemanager.storages.u115 -> app.schemas", + "app.modules.filemanager.storages.u115 -> app.schemas.exception", + "app.modules.filemanager.storages.u115 -> app.schemas.file", + "app.modules.filemanager.storages.u115 -> app.schemas.types", + "app.modules.filemanager.storages.u115 -> app.schemas.workflow", + "app.modules.filemanager.transhandler -> app.adapters", + "app.modules.filemanager.transhandler -> app.adapters.system", + "app.modules.filemanager.transhandler -> app.adapters.system.host", + "app.modules.filemanager.transhandler -> app.application", + "app.modules.filemanager.transhandler -> app.application.audio", + "app.modules.filemanager.transhandler -> app.application.directory", + "app.modules.filemanager.transhandler -> app.application.messaging", + "app.modules.filemanager.transhandler -> app.application.messaging.message", + "app.modules.filemanager.transhandler -> app.domain", + "app.modules.filemanager.transhandler -> app.domain.context", + "app.modules.filemanager.transhandler -> app.domain.meta", + "app.modules.filemanager.transhandler -> app.domain.meta.metabase", + "app.modules.filemanager.transhandler -> app.domain.meta.metamusic", + "app.modules.filemanager.transhandler -> app.domain.metainfo", + "app.modules.filemanager.transhandler -> app.modules", + "app.modules.filemanager.transhandler -> app.modules.filemanager", + "app.modules.filemanager.transhandler -> app.modules.filemanager.storages", + "app.modules.filemanager.transhandler -> app.runtime", + "app.modules.filemanager.transhandler -> app.runtime.config", + "app.modules.filemanager.transhandler -> app.runtime.events", + "app.modules.filemanager.transhandler -> app.runtime.log", + "app.modules.filemanager.transhandler -> app.schemas", + "app.modules.filemanager.transhandler -> app.schemas.event", + "app.modules.filemanager.transhandler -> app.schemas.exception", + "app.modules.filemanager.transhandler -> app.schemas.system", + "app.modules.filemanager.transhandler -> app.schemas.tmdb", + "app.modules.filemanager.transhandler -> app.schemas.transfer", + "app.modules.filemanager.transhandler -> app.schemas.types", + "app.modules.filemanager.transhandler -> app.schemas.workflow", + "app.modules.filter -> app.adapters", + "app.modules.filter -> app.adapters.system", + "app.modules.filter -> app.adapters.system.rust", + "app.modules.filter -> app.application", + "app.modules.filter -> app.application.rules", + "app.modules.filter -> app.domain", + "app.modules.filter -> app.domain.context", + "app.modules.filter -> app.domain.metainfo", + "app.modules.filter -> app.foundation", + "app.modules.filter -> app.foundation.size", + "app.modules.filter -> app.modules", + "app.modules.filter -> app.runtime", + "app.modules.filter -> app.runtime.log", + "app.modules.filter -> app.schemas", + "app.modules.filter -> app.schemas.types", + "app.modules.indexer -> app.application", + "app.modules.indexer -> app.application.site", + "app.modules.indexer -> app.db", + "app.modules.indexer -> app.db.oper", + "app.modules.indexer -> app.db.oper.site", + "app.modules.indexer -> app.domain", + "app.modules.indexer -> app.domain.context", + "app.modules.indexer -> app.domain.site", + "app.modules.indexer -> app.foundation", + "app.modules.indexer -> app.foundation.reflection", + "app.modules.indexer -> app.foundation.text", + "app.modules.indexer -> app.modules", + "app.modules.indexer -> app.modules.indexer.parser", + "app.modules.indexer -> app.modules.indexer.spider", + "app.modules.indexer -> app.modules.indexer.spider.haidan", + "app.modules.indexer -> app.modules.indexer.spider.hddolby", + "app.modules.indexer -> app.modules.indexer.spider.mtorrent", + "app.modules.indexer -> app.modules.indexer.spider.rousi", + "app.modules.indexer -> app.modules.indexer.spider.sunnypt", + "app.modules.indexer -> app.modules.indexer.spider.tnode", + "app.modules.indexer -> app.modules.indexer.spider.torrentleech", + "app.modules.indexer -> app.modules.indexer.spider.yema", + "app.modules.indexer -> app.runtime", + "app.modules.indexer -> app.runtime.log", + "app.modules.indexer -> app.schemas", + "app.modules.indexer -> app.schemas.media", + "app.modules.indexer -> app.schemas.site", + "app.modules.indexer -> app.schemas.types", + "app.modules.indexer.parser -> app.adapters", + "app.modules.indexer.parser -> app.adapters.network", + "app.modules.indexer.parser -> app.adapters.network.cloudflare", + "app.modules.indexer.parser -> app.adapters.network.http", + "app.modules.indexer.parser -> app.domain", + "app.modules.indexer.parser -> app.domain.site", + "app.modules.indexer.parser -> app.foundation", + "app.modules.indexer.parser -> app.foundation.size", + "app.modules.indexer.parser -> app.runtime", + "app.modules.indexer.parser -> app.runtime.config", + "app.modules.indexer.parser -> app.runtime.log", + "app.modules.indexer.parser.bitpt -> app.foundation", + "app.modules.indexer.parser.bitpt -> app.foundation.size", + "app.modules.indexer.parser.bitpt -> app.foundation.temporal", + "app.modules.indexer.parser.bitpt -> app.modules", + "app.modules.indexer.parser.bitpt -> app.modules.indexer", + "app.modules.indexer.parser.bitpt -> app.modules.indexer.parser", + "app.modules.indexer.parser.discuz -> app.foundation", + "app.modules.indexer.parser.discuz -> app.foundation.dom", + "app.modules.indexer.parser.discuz -> app.foundation.size", + "app.modules.indexer.parser.discuz -> app.foundation.temporal", + "app.modules.indexer.parser.discuz -> app.foundation.text", + "app.modules.indexer.parser.discuz -> app.modules", + "app.modules.indexer.parser.discuz -> app.modules.indexer", + "app.modules.indexer.parser.discuz -> app.modules.indexer.parser", + "app.modules.indexer.parser.file_list -> app.foundation", + "app.modules.indexer.parser.file_list -> app.foundation.dom", + "app.modules.indexer.parser.file_list -> app.foundation.size", + "app.modules.indexer.parser.file_list -> app.foundation.temporal", + "app.modules.indexer.parser.file_list -> app.foundation.text", + "app.modules.indexer.parser.file_list -> app.modules", + "app.modules.indexer.parser.file_list -> app.modules.indexer", + "app.modules.indexer.parser.file_list -> app.modules.indexer.parser", + "app.modules.indexer.parser.gazelle -> app.foundation", + "app.modules.indexer.parser.gazelle -> app.foundation.dom", + "app.modules.indexer.parser.gazelle -> app.foundation.size", + "app.modules.indexer.parser.gazelle -> app.foundation.temporal", + "app.modules.indexer.parser.gazelle -> app.foundation.text", + "app.modules.indexer.parser.gazelle -> app.modules", + "app.modules.indexer.parser.gazelle -> app.modules.indexer", + "app.modules.indexer.parser.gazelle -> app.modules.indexer.parser", + "app.modules.indexer.parser.hddolby -> app.domain", + "app.modules.indexer.parser.hddolby -> app.domain.site", + "app.modules.indexer.parser.hddolby -> app.modules", + "app.modules.indexer.parser.hddolby -> app.modules.indexer", + "app.modules.indexer.parser.hddolby -> app.modules.indexer.parser", + "app.modules.indexer.parser.hddolby -> app.modules.indexer.parser.nexus_php", + "app.modules.indexer.parser.hddolby -> app.runtime", + "app.modules.indexer.parser.hddolby -> app.runtime.log", + "app.modules.indexer.parser.ipt_project -> app.foundation", + "app.modules.indexer.parser.ipt_project -> app.foundation.dom", + "app.modules.indexer.parser.ipt_project -> app.foundation.size", + "app.modules.indexer.parser.ipt_project -> app.foundation.temporal", + "app.modules.indexer.parser.ipt_project -> app.foundation.text", + "app.modules.indexer.parser.ipt_project -> app.modules", + "app.modules.indexer.parser.ipt_project -> app.modules.indexer", + "app.modules.indexer.parser.ipt_project -> app.modules.indexer.parser", + "app.modules.indexer.parser.mtorrent -> app.domain", + "app.modules.indexer.parser.mtorrent -> app.domain.site", + "app.modules.indexer.parser.mtorrent -> app.modules", + "app.modules.indexer.parser.mtorrent -> app.modules.indexer", + "app.modules.indexer.parser.mtorrent -> app.modules.indexer.parser", + "app.modules.indexer.parser.mtorrent -> app.runtime", + "app.modules.indexer.parser.mtorrent -> app.runtime.log", + "app.modules.indexer.parser.nexus_audiences -> app.foundation", + "app.modules.indexer.parser.nexus_audiences -> app.foundation.dom", + "app.modules.indexer.parser.nexus_audiences -> app.foundation.size", + "app.modules.indexer.parser.nexus_audiences -> app.foundation.text", + "app.modules.indexer.parser.nexus_audiences -> app.modules", + "app.modules.indexer.parser.nexus_audiences -> app.modules.indexer", + "app.modules.indexer.parser.nexus_audiences -> app.modules.indexer.parser", + "app.modules.indexer.parser.nexus_audiences -> app.modules.indexer.parser.nexus_php", + "app.modules.indexer.parser.nexus_audiences -> app.runtime", + "app.modules.indexer.parser.nexus_audiences -> app.runtime.log", + "app.modules.indexer.parser.nexus_hhanclub -> app.foundation", + "app.modules.indexer.parser.nexus_hhanclub -> app.foundation.dom", + "app.modules.indexer.parser.nexus_hhanclub -> app.foundation.size", + "app.modules.indexer.parser.nexus_hhanclub -> app.foundation.temporal", + "app.modules.indexer.parser.nexus_hhanclub -> app.foundation.text", + "app.modules.indexer.parser.nexus_hhanclub -> app.modules", + "app.modules.indexer.parser.nexus_hhanclub -> app.modules.indexer", + "app.modules.indexer.parser.nexus_hhanclub -> app.modules.indexer.parser", + "app.modules.indexer.parser.nexus_hhanclub -> app.modules.indexer.parser.nexus_php", + "app.modules.indexer.parser.nexus_php -> app.foundation", + "app.modules.indexer.parser.nexus_php -> app.foundation.dom", + "app.modules.indexer.parser.nexus_php -> app.foundation.temporal", + "app.modules.indexer.parser.nexus_php -> app.foundation.text", + "app.modules.indexer.parser.nexus_php -> app.modules", + "app.modules.indexer.parser.nexus_php -> app.modules.indexer", + "app.modules.indexer.parser.nexus_php -> app.modules.indexer.parser", + "app.modules.indexer.parser.nexus_php -> app.runtime", + "app.modules.indexer.parser.nexus_php -> app.runtime.log", + "app.modules.indexer.parser.nexus_project -> app.modules", + "app.modules.indexer.parser.nexus_project -> app.modules.indexer", + "app.modules.indexer.parser.nexus_project -> app.modules.indexer.parser", + "app.modules.indexer.parser.nexus_project -> app.modules.indexer.parser.nexus_php", + "app.modules.indexer.parser.nexus_rabbit -> app.foundation", + "app.modules.indexer.parser.nexus_rabbit -> app.foundation.dom", + "app.modules.indexer.parser.nexus_rabbit -> app.foundation.size", + "app.modules.indexer.parser.nexus_rabbit -> app.foundation.temporal", + "app.modules.indexer.parser.nexus_rabbit -> app.foundation.text", + "app.modules.indexer.parser.nexus_rabbit -> app.modules", + "app.modules.indexer.parser.nexus_rabbit -> app.modules.indexer", + "app.modules.indexer.parser.nexus_rabbit -> app.modules.indexer.parser", + "app.modules.indexer.parser.nexus_rabbit -> app.runtime", + "app.modules.indexer.parser.nexus_rabbit -> app.runtime.log", + "app.modules.indexer.parser.rousi -> app.adapters", + "app.modules.indexer.parser.rousi -> app.adapters.network", + "app.modules.indexer.parser.rousi -> app.adapters.network.http", + "app.modules.indexer.parser.rousi -> app.domain", + "app.modules.indexer.parser.rousi -> app.domain.site", + "app.modules.indexer.parser.rousi -> app.foundation", + "app.modules.indexer.parser.rousi -> app.foundation.temporal", + "app.modules.indexer.parser.rousi -> app.modules", + "app.modules.indexer.parser.rousi -> app.modules.indexer", + "app.modules.indexer.parser.rousi -> app.modules.indexer.parser", + "app.modules.indexer.parser.rousi -> app.runtime", + "app.modules.indexer.parser.rousi -> app.runtime.config", + "app.modules.indexer.parser.rousi -> app.runtime.log", + "app.modules.indexer.parser.small_horse -> app.foundation", + "app.modules.indexer.parser.small_horse -> app.foundation.dom", + "app.modules.indexer.parser.small_horse -> app.foundation.size", + "app.modules.indexer.parser.small_horse -> app.foundation.temporal", + "app.modules.indexer.parser.small_horse -> app.foundation.text", + "app.modules.indexer.parser.small_horse -> app.modules", + "app.modules.indexer.parser.small_horse -> app.modules.indexer", + "app.modules.indexer.parser.small_horse -> app.modules.indexer.parser", + "app.modules.indexer.parser.sunnypt -> app.foundation", + "app.modules.indexer.parser.sunnypt -> app.foundation.temporal", + "app.modules.indexer.parser.sunnypt -> app.modules", + "app.modules.indexer.parser.sunnypt -> app.modules.indexer", + "app.modules.indexer.parser.sunnypt -> app.modules.indexer.parser", + "app.modules.indexer.parser.sunnypt -> app.runtime", + "app.modules.indexer.parser.sunnypt -> app.runtime.log", + "app.modules.indexer.parser.tnode -> app.foundation", + "app.modules.indexer.parser.tnode -> app.foundation.temporal", + "app.modules.indexer.parser.tnode -> app.modules", + "app.modules.indexer.parser.tnode -> app.modules.indexer", + "app.modules.indexer.parser.tnode -> app.modules.indexer.parser", + "app.modules.indexer.parser.tnode -> app.runtime", + "app.modules.indexer.parser.tnode -> app.runtime.log", + "app.modules.indexer.parser.torrent_leech -> app.foundation", + "app.modules.indexer.parser.torrent_leech -> app.foundation.dom", + "app.modules.indexer.parser.torrent_leech -> app.foundation.size", + "app.modules.indexer.parser.torrent_leech -> app.foundation.temporal", + "app.modules.indexer.parser.torrent_leech -> app.foundation.text", + "app.modules.indexer.parser.torrent_leech -> app.modules", + "app.modules.indexer.parser.torrent_leech -> app.modules.indexer", + "app.modules.indexer.parser.torrent_leech -> app.modules.indexer.parser", + "app.modules.indexer.parser.unit3d -> app.foundation", + "app.modules.indexer.parser.unit3d -> app.foundation.dom", + "app.modules.indexer.parser.unit3d -> app.foundation.size", + "app.modules.indexer.parser.unit3d -> app.foundation.temporal", + "app.modules.indexer.parser.unit3d -> app.foundation.text", + "app.modules.indexer.parser.unit3d -> app.modules", + "app.modules.indexer.parser.unit3d -> app.modules.indexer", + "app.modules.indexer.parser.unit3d -> app.modules.indexer.parser", + "app.modules.indexer.parser.yema -> app.foundation", + "app.modules.indexer.parser.yema -> app.foundation.temporal", + "app.modules.indexer.parser.yema -> app.modules", + "app.modules.indexer.parser.yema -> app.modules.indexer", + "app.modules.indexer.parser.yema -> app.modules.indexer.parser", + "app.modules.indexer.parser.yema -> app.runtime", + "app.modules.indexer.parser.yema -> app.runtime.log", + "app.modules.indexer.parser.zhixing -> app.foundation", + "app.modules.indexer.parser.zhixing -> app.foundation.temporal", + "app.modules.indexer.parser.zhixing -> app.modules", + "app.modules.indexer.parser.zhixing -> app.modules.indexer", + "app.modules.indexer.parser.zhixing -> app.modules.indexer.parser", + "app.modules.indexer.spider -> app.adapters", + "app.modules.indexer.spider -> app.adapters.network", + "app.modules.indexer.spider -> app.adapters.network.http", + "app.modules.indexer.spider -> app.adapters.system", + "app.modules.indexer.spider -> app.adapters.system.rust", + "app.modules.indexer.spider -> app.foundation", + "app.modules.indexer.spider -> app.foundation.size", + "app.modules.indexer.spider -> app.foundation.temporal", + "app.modules.indexer.spider -> app.foundation.url", + "app.modules.indexer.spider -> app.runtime", + "app.modules.indexer.spider -> app.runtime.config", + "app.modules.indexer.spider -> app.runtime.log", + "app.modules.indexer.spider -> app.schemas", + "app.modules.indexer.spider -> app.schemas.types", + "app.modules.indexer.spider.haidan -> app.adapters", + "app.modules.indexer.spider.haidan -> app.adapters.network", + "app.modules.indexer.spider.haidan -> app.adapters.network.http", + "app.modules.indexer.spider.haidan -> app.db", + "app.modules.indexer.spider.haidan -> app.db.oper", + "app.modules.indexer.spider.haidan -> app.db.oper.systemconfig", + "app.modules.indexer.spider.haidan -> app.domain", + "app.modules.indexer.spider.haidan -> app.domain.site", + "app.modules.indexer.spider.haidan -> app.foundation", + "app.modules.indexer.spider.haidan -> app.foundation.temporal", + "app.modules.indexer.spider.haidan -> app.runtime", + "app.modules.indexer.spider.haidan -> app.runtime.config", + "app.modules.indexer.spider.haidan -> app.runtime.log", + "app.modules.indexer.spider.haidan -> app.schemas", + "app.modules.indexer.spider.haidan -> app.schemas.types", + "app.modules.indexer.spider.hddolby -> app.adapters", + "app.modules.indexer.spider.hddolby -> app.adapters.network", + "app.modules.indexer.spider.hddolby -> app.adapters.network.http", + "app.modules.indexer.spider.hddolby -> app.db", + "app.modules.indexer.spider.hddolby -> app.db.oper", + "app.modules.indexer.spider.hddolby -> app.db.oper.systemconfig", + "app.modules.indexer.spider.hddolby -> app.domain", + "app.modules.indexer.spider.hddolby -> app.domain.site", + "app.modules.indexer.spider.hddolby -> app.runtime", + "app.modules.indexer.spider.hddolby -> app.runtime.config", + "app.modules.indexer.spider.hddolby -> app.runtime.log", + "app.modules.indexer.spider.hddolby -> app.schemas", + "app.modules.indexer.spider.hddolby -> app.schemas.types", + "app.modules.indexer.spider.mtorrent -> app.adapters", + "app.modules.indexer.spider.mtorrent -> app.adapters.network", + "app.modules.indexer.spider.mtorrent -> app.adapters.network.http", + "app.modules.indexer.spider.mtorrent -> app.db", + "app.modules.indexer.spider.mtorrent -> app.db.oper", + "app.modules.indexer.spider.mtorrent -> app.db.oper.systemconfig", + "app.modules.indexer.spider.mtorrent -> app.domain", + "app.modules.indexer.spider.mtorrent -> app.domain.site", + "app.modules.indexer.spider.mtorrent -> app.foundation", + "app.modules.indexer.spider.mtorrent -> app.foundation.temporal", + "app.modules.indexer.spider.mtorrent -> app.runtime", + "app.modules.indexer.spider.mtorrent -> app.runtime.config", + "app.modules.indexer.spider.mtorrent -> app.runtime.log", + "app.modules.indexer.spider.mtorrent -> app.schemas", + "app.modules.indexer.spider.mtorrent -> app.schemas.types", + "app.modules.indexer.spider.rousi -> app.adapters", + "app.modules.indexer.spider.rousi -> app.adapters.network", + "app.modules.indexer.spider.rousi -> app.adapters.network.http", + "app.modules.indexer.spider.rousi -> app.db", + "app.modules.indexer.spider.rousi -> app.db.oper", + "app.modules.indexer.spider.rousi -> app.db.oper.systemconfig", + "app.modules.indexer.spider.rousi -> app.domain", + "app.modules.indexer.spider.rousi -> app.domain.site", + "app.modules.indexer.spider.rousi -> app.foundation", + "app.modules.indexer.spider.rousi -> app.foundation.temporal", + "app.modules.indexer.spider.rousi -> app.runtime", + "app.modules.indexer.spider.rousi -> app.runtime.config", + "app.modules.indexer.spider.rousi -> app.runtime.log", + "app.modules.indexer.spider.rousi -> app.schemas", + "app.modules.indexer.spider.rousi -> app.schemas.types", + "app.modules.indexer.spider.sunnypt -> app.adapters", + "app.modules.indexer.spider.sunnypt -> app.adapters.network", + "app.modules.indexer.spider.sunnypt -> app.adapters.network.http", + "app.modules.indexer.spider.sunnypt -> app.foundation", + "app.modules.indexer.spider.sunnypt -> app.foundation.temporal", + "app.modules.indexer.spider.sunnypt -> app.runtime", + "app.modules.indexer.spider.sunnypt -> app.runtime.config", + "app.modules.indexer.spider.sunnypt -> app.runtime.log", + "app.modules.indexer.spider.sunnypt -> app.schemas", + "app.modules.indexer.spider.sunnypt -> app.schemas.types", + "app.modules.indexer.spider.tnode -> app.adapters", + "app.modules.indexer.spider.tnode -> app.adapters.network", + "app.modules.indexer.spider.tnode -> app.adapters.network.http", + "app.modules.indexer.spider.tnode -> app.foundation", + "app.modules.indexer.spider.tnode -> app.foundation.singleton", + "app.modules.indexer.spider.tnode -> app.foundation.temporal", + "app.modules.indexer.spider.tnode -> app.runtime", + "app.modules.indexer.spider.tnode -> app.runtime.cache", + "app.modules.indexer.spider.tnode -> app.runtime.config", + "app.modules.indexer.spider.tnode -> app.runtime.log", + "app.modules.indexer.spider.torrentleech -> app.adapters", + "app.modules.indexer.spider.torrentleech -> app.adapters.network", + "app.modules.indexer.spider.torrentleech -> app.adapters.network.http", + "app.modules.indexer.spider.torrentleech -> app.foundation", + "app.modules.indexer.spider.torrentleech -> app.foundation.temporal", + "app.modules.indexer.spider.torrentleech -> app.foundation.text", + "app.modules.indexer.spider.torrentleech -> app.runtime", + "app.modules.indexer.spider.torrentleech -> app.runtime.config", + "app.modules.indexer.spider.torrentleech -> app.runtime.log", + "app.modules.indexer.spider.torrentleech -> app.schemas", + "app.modules.indexer.spider.torrentleech -> app.schemas.types", + "app.modules.indexer.spider.yema -> app.adapters", + "app.modules.indexer.spider.yema -> app.adapters.network", + "app.modules.indexer.spider.yema -> app.adapters.network.http", + "app.modules.indexer.spider.yema -> app.foundation", + "app.modules.indexer.spider.yema -> app.foundation.temporal", + "app.modules.indexer.spider.yema -> app.runtime", + "app.modules.indexer.spider.yema -> app.runtime.config", + "app.modules.indexer.spider.yema -> app.runtime.log", + "app.modules.indexer.spider.yema -> app.schemas", + "app.modules.indexer.spider.yema -> app.schemas.types", + "app.modules.jellyfin -> app.modules", + "app.modules.jellyfin -> app.modules._base", + "app.modules.jellyfin -> app.modules.jellyfin.jellyfin", + "app.modules.jellyfin -> app.runtime", + "app.modules.jellyfin -> app.runtime.log", + "app.modules.jellyfin -> app.schemas", + "app.modules.jellyfin -> app.schemas.dashboard", + "app.modules.jellyfin -> app.schemas.mediaserver", + "app.modules.jellyfin -> app.schemas.types", + "app.modules.jellyfin.jellyfin -> app.adapters", + "app.modules.jellyfin.jellyfin -> app.adapters.network", + "app.modules.jellyfin.jellyfin -> app.adapters.network.http", + "app.modules.jellyfin.jellyfin -> app.application", + "app.modules.jellyfin.jellyfin -> app.application.mediaserver", + "app.modules.jellyfin.jellyfin -> app.foundation", + "app.modules.jellyfin.jellyfin -> app.foundation.url", + "app.modules.jellyfin.jellyfin -> app.runtime", + "app.modules.jellyfin.jellyfin -> app.runtime.config", + "app.modules.jellyfin.jellyfin -> app.runtime.log", + "app.modules.jellyfin.jellyfin -> app.schemas", + "app.modules.jellyfin.jellyfin -> app.schemas.dashboard", + "app.modules.jellyfin.jellyfin -> app.schemas.mediaserver", + "app.modules.jellyfin.jellyfin -> app.schemas.types", + "app.modules.listenbrainz -> app.adapters", + "app.modules.listenbrainz -> app.adapters.network", + "app.modules.listenbrainz -> app.adapters.network.http", + "app.modules.listenbrainz -> app.domain", + "app.modules.listenbrainz -> app.domain.context", + "app.modules.listenbrainz -> app.modules", + "app.modules.listenbrainz -> app.runtime", + "app.modules.listenbrainz -> app.runtime.cache", + "app.modules.listenbrainz -> app.runtime.config", + "app.modules.listenbrainz -> app.runtime.log", + "app.modules.listenbrainz -> app.schemas", + "app.modules.listenbrainz -> app.schemas.types", + "app.modules.lrclib -> app.adapters", + "app.modules.lrclib -> app.adapters.network", + "app.modules.lrclib -> app.adapters.network.http", + "app.modules.lrclib -> app.domain", + "app.modules.lrclib -> app.domain.context", + "app.modules.lrclib -> app.domain.meta", + "app.modules.lrclib -> app.domain.meta.metamusic", + "app.modules.lrclib -> app.modules", + "app.modules.lrclib -> app.runtime", + "app.modules.lrclib -> app.runtime.cache", + "app.modules.lrclib -> app.runtime.config", + "app.modules.lrclib -> app.runtime.log", + "app.modules.lrclib -> app.schemas", + "app.modules.lrclib -> app.schemas.types", + "app.modules.musicbrainz -> app.adapters", + "app.modules.musicbrainz -> app.adapters.network", + "app.modules.musicbrainz -> app.adapters.network.http", + "app.modules.musicbrainz -> app.domain", + "app.modules.musicbrainz -> app.domain.context", + "app.modules.musicbrainz -> app.domain.media", + "app.modules.musicbrainz -> app.domain.meta", + "app.modules.musicbrainz -> app.domain.meta.metabase", + "app.modules.musicbrainz -> app.domain.meta.metamusic", + "app.modules.musicbrainz -> app.foundation", + "app.modules.musicbrainz -> app.foundation.text", + "app.modules.musicbrainz -> app.modules", + "app.modules.musicbrainz -> app.modules.musicbrainz.music_cache", + "app.modules.musicbrainz -> app.runtime", + "app.modules.musicbrainz -> app.runtime.cache", + "app.modules.musicbrainz -> app.runtime.config", + "app.modules.musicbrainz -> app.runtime.log", + "app.modules.musicbrainz -> app.schemas", + "app.modules.musicbrainz -> app.schemas.types", + "app.modules.musicbrainz.music_cache -> app.domain", + "app.modules.musicbrainz.music_cache -> app.domain.context", + "app.modules.musicbrainz.music_cache -> app.domain.meta", + "app.modules.musicbrainz.music_cache -> app.domain.meta.metamusic", + "app.modules.musicbrainz.music_cache -> app.foundation", + "app.modules.musicbrainz.music_cache -> app.foundation.singleton", + "app.modules.musicbrainz.music_cache -> app.runtime", + "app.modules.musicbrainz.music_cache -> app.runtime.cache", + "app.modules.musicbrainz.music_cache -> app.runtime.config", + "app.modules.musicbrainz.music_cache -> app.runtime.log", + "app.modules.musicbrainz.music_cache -> app.schemas", + "app.modules.musicbrainz.music_cache -> app.schemas.types", + "app.modules.navidrome -> app.application", + "app.modules.navidrome -> app.application.mediaserver", + "app.modules.navidrome -> app.domain", + "app.modules.navidrome -> app.domain.context", + "app.modules.navidrome -> app.modules", + "app.modules.navidrome -> app.modules.navidrome.navidrome", + "app.modules.navidrome -> app.runtime", + "app.modules.navidrome -> app.runtime.events", + "app.modules.navidrome -> app.runtime.log", + "app.modules.navidrome -> app.schemas", + "app.modules.navidrome -> app.schemas.dashboard", + "app.modules.navidrome -> app.schemas.event", + "app.modules.navidrome -> app.schemas.mediaserver", + "app.modules.navidrome -> app.schemas.types", + "app.modules.navidrome.navidrome -> app.adapters", + "app.modules.navidrome.navidrome -> app.adapters.network", + "app.modules.navidrome.navidrome -> app.adapters.network.http", + "app.modules.navidrome.navidrome -> app.foundation", + "app.modules.navidrome.navidrome -> app.foundation.url", + "app.modules.navidrome.navidrome -> app.runtime", + "app.modules.navidrome.navidrome -> app.runtime.log", + "app.modules.navidrome.navidrome -> app.schemas", + "app.modules.navidrome.navidrome -> app.schemas.dashboard", + "app.modules.navidrome.navidrome -> app.schemas.mediaserver", + "app.modules.navidrome.navidrome -> app.schemas.types", + "app.modules.plex -> app.application", + "app.modules.plex -> app.application.mediaserver", + "app.modules.plex -> app.domain", + "app.modules.plex -> app.domain.context", + "app.modules.plex -> app.modules", + "app.modules.plex -> app.modules._base", + "app.modules.plex -> app.modules.plex.plex", + "app.modules.plex -> app.runtime", + "app.modules.plex -> app.runtime.events", + "app.modules.plex -> app.runtime.log", + "app.modules.plex -> app.schemas", + "app.modules.plex -> app.schemas.dashboard", + "app.modules.plex -> app.schemas.event", + "app.modules.plex -> app.schemas.mediaserver", + "app.modules.plex -> app.schemas.types", + "app.modules.plex.plex -> app.adapters", + "app.modules.plex.plex -> app.adapters.network", + "app.modules.plex.plex -> app.adapters.network.http", + "app.modules.plex.plex -> app.application", + "app.modules.plex.plex -> app.application.mediaserver", + "app.modules.plex.plex -> app.foundation", + "app.modules.plex.plex -> app.foundation.url", + "app.modules.plex.plex -> app.runtime", + "app.modules.plex.plex -> app.runtime.cache", + "app.modules.plex.plex -> app.runtime.log", + "app.modules.plex.plex -> app.schemas", + "app.modules.plex.plex -> app.schemas.dashboard", + "app.modules.plex.plex -> app.schemas.mediaserver", + "app.modules.plex.plex -> app.schemas.types", + "app.modules.postgresql -> app.db", + "app.modules.postgresql -> app.modules", + "app.modules.postgresql -> app.runtime", + "app.modules.postgresql -> app.runtime.config", + "app.modules.postgresql -> app.schemas", + "app.modules.postgresql -> app.schemas.types", + "app.modules.qbittorrent -> app.domain", + "app.modules.qbittorrent -> app.domain.metainfo", + "app.modules.qbittorrent -> app.foundation", + "app.modules.qbittorrent -> app.foundation.size", + "app.modules.qbittorrent -> app.foundation.temporal", + "app.modules.qbittorrent -> app.foundation.text", + "app.modules.qbittorrent -> app.modules", + "app.modules.qbittorrent -> app.modules._base", + "app.modules.qbittorrent -> app.modules.qbittorrent.qbittorrent", + "app.modules.qbittorrent -> app.runtime", + "app.modules.qbittorrent -> app.runtime.config", + "app.modules.qbittorrent -> app.runtime.log", + "app.modules.qbittorrent -> app.schemas", + "app.modules.qbittorrent -> app.schemas.dashboard", + "app.modules.qbittorrent -> app.schemas.transfer", + "app.modules.qbittorrent -> app.schemas.types", + "app.modules.qbittorrent.qbittorrent -> app.domain", + "app.modules.qbittorrent.qbittorrent -> app.domain.torrent", + "app.modules.qbittorrent.qbittorrent -> app.foundation", + "app.modules.qbittorrent.qbittorrent -> app.foundation.url", + "app.modules.qbittorrent.qbittorrent -> app.runtime", + "app.modules.qbittorrent.qbittorrent -> app.runtime.log", + "app.modules.qqbot -> app.adapters", + "app.modules.qqbot -> app.adapters.network", + "app.modules.qqbot -> app.adapters.network.http", + "app.modules.qqbot -> app.application", + "app.modules.qqbot -> app.application.messaging", + "app.modules.qqbot -> app.application.messaging.agent", + "app.modules.qqbot -> app.domain", + "app.modules.qqbot -> app.domain.context", + "app.modules.qqbot -> app.modules", + "app.modules.qqbot -> app.modules._base", + "app.modules.qqbot -> app.modules.qqbot.qqbot", + "app.modules.qqbot -> app.runtime", + "app.modules.qqbot -> app.runtime.log", + "app.modules.qqbot -> app.schemas", + "app.modules.qqbot -> app.schemas.message", + "app.modules.qqbot -> app.schemas.notification", + "app.modules.qqbot -> app.schemas.types", + "app.modules.qqbot.api -> app.adapters", + "app.modules.qqbot.api -> app.adapters.network", + "app.modules.qqbot.api -> app.adapters.network.http", + "app.modules.qqbot.api -> app.runtime", + "app.modules.qqbot.api -> app.runtime.log", + "app.modules.qqbot.gateway -> app.runtime", + "app.modules.qqbot.gateway -> app.runtime.log", + "app.modules.qqbot.qqbot -> app.adapters", + "app.modules.qqbot.qqbot -> app.adapters.network", + "app.modules.qqbot.qqbot -> app.adapters.network.http", + "app.modules.qqbot.qqbot -> app.domain", + "app.modules.qqbot.qqbot -> app.domain.context", + "app.modules.qqbot.qqbot -> app.domain.metainfo", + "app.modules.qqbot.qqbot -> app.foundation", + "app.modules.qqbot.qqbot -> app.foundation.size", + "app.modules.qqbot.qqbot -> app.modules", + "app.modules.qqbot.qqbot -> app.modules.qqbot", + "app.modules.qqbot.qqbot -> app.modules.qqbot.api", + "app.modules.qqbot.qqbot -> app.modules.qqbot.gateway", + "app.modules.qqbot.qqbot -> app.runtime", + "app.modules.qqbot.qqbot -> app.runtime.cache", + "app.modules.qqbot.qqbot -> app.runtime.config", + "app.modules.qqbot.qqbot -> app.runtime.log", + "app.modules.redis -> app.adapters", + "app.modules.redis -> app.adapters.cache", + "app.modules.redis -> app.adapters.cache.redis", + "app.modules.redis -> app.modules", + "app.modules.redis -> app.runtime", + "app.modules.redis -> app.runtime.config", + "app.modules.redis -> app.schemas", + "app.modules.redis -> app.schemas.types", + "app.modules.rtorrent -> app.domain", + "app.modules.rtorrent -> app.domain.metainfo", + "app.modules.rtorrent -> app.foundation", + "app.modules.rtorrent -> app.foundation.size", + "app.modules.rtorrent -> app.foundation.temporal", + "app.modules.rtorrent -> app.foundation.text", + "app.modules.rtorrent -> app.modules", + "app.modules.rtorrent -> app.modules._base", + "app.modules.rtorrent -> app.modules.rtorrent.rtorrent", + "app.modules.rtorrent -> app.runtime", + "app.modules.rtorrent -> app.runtime.config", + "app.modules.rtorrent -> app.runtime.log", + "app.modules.rtorrent -> app.schemas", + "app.modules.rtorrent -> app.schemas.dashboard", + "app.modules.rtorrent -> app.schemas.transfer", + "app.modules.rtorrent -> app.schemas.types", + "app.modules.rtorrent.rtorrent -> app.runtime", + "app.modules.rtorrent.rtorrent -> app.runtime.log", + "app.modules.slack -> app.application", + "app.modules.slack -> app.application.messaging", + "app.modules.slack -> app.application.messaging.agent", + "app.modules.slack -> app.domain", + "app.modules.slack -> app.domain.context", + "app.modules.slack -> app.modules", + "app.modules.slack -> app.modules._base", + "app.modules.slack -> app.modules.slack.slack", + "app.modules.slack -> app.runtime", + "app.modules.slack -> app.runtime.log", + "app.modules.slack -> app.schemas", + "app.modules.slack -> app.schemas.event", + "app.modules.slack -> app.schemas.message", + "app.modules.slack -> app.schemas.notification", + "app.modules.slack -> app.schemas.types", + "app.modules.slack.slack -> app.adapters", + "app.modules.slack.slack -> app.adapters.network", + "app.modules.slack.slack -> app.adapters.network.http", + "app.modules.slack.slack -> app.domain", + "app.modules.slack.slack -> app.domain.context", + "app.modules.slack.slack -> app.domain.metainfo", + "app.modules.slack.slack -> app.foundation", + "app.modules.slack.slack -> app.foundation.size", + "app.modules.slack.slack -> app.runtime", + "app.modules.slack.slack -> app.runtime.config", + "app.modules.slack.slack -> app.runtime.log", + "app.modules.subtitle -> app.adapters", + "app.modules.subtitle -> app.adapters.network", + "app.modules.subtitle -> app.adapters.network.http", + "app.modules.subtitle -> app.application", + "app.modules.subtitle -> app.application.site", + "app.modules.subtitle -> app.db", + "app.modules.subtitle -> app.db.oper", + "app.modules.subtitle -> app.db.oper.site", + "app.modules.subtitle -> app.domain", + "app.modules.subtitle -> app.domain.context", + "app.modules.subtitle -> app.modules", + "app.modules.subtitle -> app.runtime", + "app.modules.subtitle -> app.runtime.config", + "app.modules.subtitle -> app.runtime.log", + "app.modules.subtitle -> app.schemas", + "app.modules.subtitle -> app.schemas.types", + "app.modules.synologychat -> app.adapters", + "app.modules.synologychat -> app.adapters.network", + "app.modules.synologychat -> app.adapters.network.http", + "app.modules.synologychat -> app.application", + "app.modules.synologychat -> app.application.messaging", + "app.modules.synologychat -> app.application.messaging.agent", + "app.modules.synologychat -> app.domain", + "app.modules.synologychat -> app.domain.context", + "app.modules.synologychat -> app.modules", + "app.modules.synologychat -> app.modules._base", + "app.modules.synologychat -> app.modules.synologychat.synologychat", + "app.modules.synologychat -> app.runtime", + "app.modules.synologychat -> app.runtime.log", + "app.modules.synologychat -> app.schemas", + "app.modules.synologychat -> app.schemas.message", + "app.modules.synologychat -> app.schemas.notification", + "app.modules.synologychat -> app.schemas.types", + "app.modules.synologychat.synologychat -> app.adapters", + "app.modules.synologychat.synologychat -> app.adapters.network", + "app.modules.synologychat.synologychat -> app.adapters.network.http", + "app.modules.synologychat.synologychat -> app.domain", + "app.modules.synologychat.synologychat -> app.domain.context", + "app.modules.synologychat.synologychat -> app.domain.metainfo", + "app.modules.synologychat.synologychat -> app.foundation", + "app.modules.synologychat.synologychat -> app.foundation.size", + "app.modules.synologychat.synologychat -> app.foundation.url", + "app.modules.synologychat.synologychat -> app.runtime", + "app.modules.synologychat.synologychat -> app.runtime.log", + "app.modules.telegram -> app.application", + "app.modules.telegram -> app.application.messaging", + "app.modules.telegram -> app.application.messaging.agent", + "app.modules.telegram -> app.domain", + "app.modules.telegram -> app.domain.context", + "app.modules.telegram -> app.modules", + "app.modules.telegram -> app.modules._base", + "app.modules.telegram -> app.modules.telegram.telegram", + "app.modules.telegram -> app.runtime", + "app.modules.telegram -> app.runtime.log", + "app.modules.telegram -> app.schemas", + "app.modules.telegram -> app.schemas.message", + "app.modules.telegram -> app.schemas.notification", + "app.modules.telegram -> app.schemas.system", + "app.modules.telegram -> app.schemas.types", + "app.modules.telegram.telegram -> app.adapters", + "app.modules.telegram.telegram -> app.adapters.network", + "app.modules.telegram.telegram -> app.adapters.network.http", + "app.modules.telegram.telegram -> app.application", + "app.modules.telegram.telegram -> app.application.image", + "app.modules.telegram.telegram -> app.domain", + "app.modules.telegram.telegram -> app.domain.context", + "app.modules.telegram.telegram -> app.domain.metainfo", + "app.modules.telegram.telegram -> app.foundation", + "app.modules.telegram.telegram -> app.foundation.size", + "app.modules.telegram.telegram -> app.modules", + "app.modules.telegram.telegram -> app.modules.telegram", + "app.modules.telegram.telegram -> app.modules.telegram.compat", + "app.modules.telegram.telegram -> app.runtime", + "app.modules.telegram.telegram -> app.runtime.config", + "app.modules.telegram.telegram -> app.runtime.execution", + "app.modules.telegram.telegram -> app.runtime.log", + "app.modules.telegram.telegram -> app.runtime.thread", + "app.modules.theaudiodb -> app.adapters", + "app.modules.theaudiodb -> app.adapters.network", + "app.modules.theaudiodb -> app.adapters.network.http", + "app.modules.theaudiodb -> app.domain", + "app.modules.theaudiodb -> app.domain.context", + "app.modules.theaudiodb -> app.domain.media", + "app.modules.theaudiodb -> app.domain.meta", + "app.modules.theaudiodb -> app.domain.meta.metabase", + "app.modules.theaudiodb -> app.domain.meta.metamusic", + "app.modules.theaudiodb -> app.modules", + "app.modules.theaudiodb -> app.runtime", + "app.modules.theaudiodb -> app.runtime.cache", + "app.modules.theaudiodb -> app.runtime.config", + "app.modules.theaudiodb -> app.runtime.log", + "app.modules.theaudiodb -> app.schemas", + "app.modules.theaudiodb -> app.schemas.types", + "app.modules.themoviedb -> app.adapters", + "app.modules.themoviedb -> app.adapters.network", + "app.modules.themoviedb -> app.adapters.network.http", + "app.modules.themoviedb -> app.domain", + "app.modules.themoviedb -> app.domain.context", + "app.modules.themoviedb -> app.domain.media", + "app.modules.themoviedb -> app.domain.meta", + "app.modules.themoviedb -> app.domain.meta.metabase", + "app.modules.themoviedb -> app.foundation", + "app.modules.themoviedb -> app.foundation.text", + "app.modules.themoviedb -> app.modules", + "app.modules.themoviedb -> app.modules.themoviedb.category", + "app.modules.themoviedb -> app.modules.themoviedb.scraper", + "app.modules.themoviedb -> app.modules.themoviedb.tmdb_cache", + "app.modules.themoviedb -> app.modules.themoviedb.tmdbapi", + "app.modules.themoviedb -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb -> app.modules.themoviedb.tmdbv3api.exceptions", + "app.modules.themoviedb -> app.runtime", + "app.modules.themoviedb -> app.runtime.config", + "app.modules.themoviedb -> app.runtime.log", + "app.modules.themoviedb -> app.schemas", + "app.modules.themoviedb -> app.schemas.category", + "app.modules.themoviedb -> app.schemas.context", + "app.modules.themoviedb -> app.schemas.media", + "app.modules.themoviedb -> app.schemas.tmdb", + "app.modules.themoviedb -> app.schemas.types", + "app.modules.themoviedb.category -> app.foundation", + "app.modules.themoviedb.category -> app.foundation.singleton", + "app.modules.themoviedb.category -> app.runtime", + "app.modules.themoviedb.category -> app.runtime.config", + "app.modules.themoviedb.category -> app.runtime.log", + "app.modules.themoviedb.category -> app.schemas", + "app.modules.themoviedb.category -> app.schemas.category", + "app.modules.themoviedb.scraper -> app.domain", + "app.modules.themoviedb.scraper -> app.domain.context", + "app.modules.themoviedb.scraper -> app.domain.meta", + "app.modules.themoviedb.scraper -> app.domain.meta.metabase", + "app.modules.themoviedb.scraper -> app.foundation", + "app.modules.themoviedb.scraper -> app.foundation.dom", + "app.modules.themoviedb.scraper -> app.modules", + "app.modules.themoviedb.scraper -> app.modules.themoviedb", + "app.modules.themoviedb.scraper -> app.modules.themoviedb.tmdbapi", + "app.modules.themoviedb.scraper -> app.runtime", + "app.modules.themoviedb.scraper -> app.runtime.config", + "app.modules.themoviedb.scraper -> app.schemas", + "app.modules.themoviedb.scraper -> app.schemas.types", + "app.modules.themoviedb.tmdb_cache -> app.domain", + "app.modules.themoviedb.tmdb_cache -> app.domain.meta", + "app.modules.themoviedb.tmdb_cache -> app.domain.meta.metabase", + "app.modules.themoviedb.tmdb_cache -> app.foundation", + "app.modules.themoviedb.tmdb_cache -> app.foundation.singleton", + "app.modules.themoviedb.tmdb_cache -> app.runtime", + "app.modules.themoviedb.tmdb_cache -> app.runtime.cache", + "app.modules.themoviedb.tmdb_cache -> app.runtime.config", + "app.modules.themoviedb.tmdb_cache -> app.runtime.log", + "app.modules.themoviedb.tmdb_cache -> app.schemas", + "app.modules.themoviedb.tmdb_cache -> app.schemas.types", + "app.modules.themoviedb.tmdbapi -> app.foundation", + "app.modules.themoviedb.tmdbapi -> app.foundation.text", + "app.modules.themoviedb.tmdbapi -> app.modules", + "app.modules.themoviedb.tmdbapi -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbapi -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbapi -> app.modules.themoviedb.tmdbv3api.exceptions", + "app.modules.themoviedb.tmdbapi -> app.runtime", + "app.modules.themoviedb.tmdbapi -> app.runtime.config", + "app.modules.themoviedb.tmdbapi -> app.runtime.log", + "app.modules.themoviedb.tmdbapi -> app.schemas", + "app.modules.themoviedb.tmdbapi -> app.schemas.types", + "app.modules.themoviedb.tmdbv3api -> app.modules", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.account", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.auth", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.certification", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.change", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.collection", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.company", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.configuration", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.credit", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.discover", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.episode", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.find", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.genre", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.group", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.keyword", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.list", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.movie", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.network", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.person", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.provider", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.review", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.search", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.season", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.trending", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.objs.tv", + "app.modules.themoviedb.tmdbv3api -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.exceptions -> app.schemas", + "app.modules.themoviedb.tmdbv3api.exceptions -> app.schemas.exception", + "app.modules.themoviedb.tmdbv3api.objs.account -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.account -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.account -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.account -> app.modules.themoviedb.tmdbv3api.exceptions", + "app.modules.themoviedb.tmdbv3api.objs.account -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.auth -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.auth -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.auth -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.auth -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.certification -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.certification -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.certification -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.certification -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.change -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.change -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.change -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.change -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.collection -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.collection -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.collection -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.collection -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.company -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.company -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.company -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.company -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.configuration -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.configuration -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.configuration -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.configuration -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.credit -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.credit -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.credit -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.credit -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.discover -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.discover -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.discover -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.discover -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.discover -> app.runtime", + "app.modules.themoviedb.tmdbv3api.objs.discover -> app.runtime.cache", + "app.modules.themoviedb.tmdbv3api.objs.discover -> app.runtime.config", + "app.modules.themoviedb.tmdbv3api.objs.episode -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.episode -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.episode -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.episode -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.find -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.find -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.find -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.find -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.genre -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.genre -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.genre -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.genre -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.group -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.group -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.group -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.group -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.keyword -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.keyword -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.keyword -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.keyword -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.list -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.list -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.list -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.list -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.movie -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.movie -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.movie -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.movie -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.network -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.network -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.network -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.network -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.person -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.person -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.person -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.person -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.provider -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.provider -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.provider -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.provider -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.review -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.review -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.review -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.review -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.search -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.search -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.search -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.search -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.season -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.season -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.season -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.season -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.trending -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.trending -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.trending -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.trending -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.objs.tv -> app.modules", + "app.modules.themoviedb.tmdbv3api.objs.tv -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.objs.tv -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.tv -> app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.themoviedb.tmdbv3api.tmdb -> app.adapters", + "app.modules.themoviedb.tmdbv3api.tmdb -> app.adapters.network", + "app.modules.themoviedb.tmdbv3api.tmdb -> app.adapters.network.http", + "app.modules.themoviedb.tmdbv3api.tmdb -> app.modules", + "app.modules.themoviedb.tmdbv3api.tmdb -> app.modules.themoviedb", + "app.modules.themoviedb.tmdbv3api.tmdb -> app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.tmdb -> app.modules.themoviedb.tmdbv3api.exceptions", + "app.modules.themoviedb.tmdbv3api.tmdb -> app.runtime", + "app.modules.themoviedb.tmdbv3api.tmdb -> app.runtime.cache", + "app.modules.themoviedb.tmdbv3api.tmdb -> app.runtime.config", + "app.modules.thetvdb -> app.modules", + "app.modules.thetvdb -> app.modules.thetvdb.tvdb_v4_official", + "app.modules.thetvdb -> app.runtime", + "app.modules.thetvdb -> app.runtime.config", + "app.modules.thetvdb -> app.runtime.log", + "app.modules.thetvdb -> app.schemas", + "app.modules.thetvdb -> app.schemas.types", + "app.modules.thetvdb.tvdb_v4_official -> app.adapters", + "app.modules.thetvdb.tvdb_v4_official -> app.adapters.network", + "app.modules.thetvdb.tvdb_v4_official -> app.adapters.network.http", + "app.modules.thetvdb.tvdb_v4_official -> app.runtime", + "app.modules.thetvdb.tvdb_v4_official -> app.runtime.cache", + "app.modules.thetvdb.tvdb_v4_official -> app.runtime.config", + "app.modules.transmission -> app.domain", + "app.modules.transmission -> app.domain.metainfo", + "app.modules.transmission -> app.foundation", + "app.modules.transmission -> app.foundation.size", + "app.modules.transmission -> app.foundation.temporal", + "app.modules.transmission -> app.modules", + "app.modules.transmission -> app.modules._base", + "app.modules.transmission -> app.modules.transmission.transmission", + "app.modules.transmission -> app.runtime", + "app.modules.transmission -> app.runtime.config", + "app.modules.transmission -> app.runtime.log", + "app.modules.transmission -> app.schemas", + "app.modules.transmission -> app.schemas.dashboard", + "app.modules.transmission -> app.schemas.transfer", + "app.modules.transmission -> app.schemas.types", + "app.modules.transmission.transmission -> app.foundation", + "app.modules.transmission.transmission -> app.foundation.url", + "app.modules.transmission.transmission -> app.runtime", + "app.modules.transmission.transmission -> app.runtime.log", + "app.modules.trimemedia -> app.modules", + "app.modules.trimemedia -> app.modules._base", + "app.modules.trimemedia -> app.modules.trimemedia.trimemedia", + "app.modules.trimemedia -> app.runtime", + "app.modules.trimemedia -> app.runtime.log", + "app.modules.trimemedia -> app.schemas", + "app.modules.trimemedia -> app.schemas.dashboard", + "app.modules.trimemedia -> app.schemas.mediaserver", + "app.modules.trimemedia -> app.schemas.types", + "app.modules.trimemedia.api -> app.adapters", + "app.modules.trimemedia.api -> app.adapters.network", + "app.modules.trimemedia.api -> app.adapters.network.http", + "app.modules.trimemedia.api -> app.runtime", + "app.modules.trimemedia.api -> app.runtime.config", + "app.modules.trimemedia.api -> app.runtime.log", + "app.modules.trimemedia.trimemedia -> app.application", + "app.modules.trimemedia.trimemedia -> app.application.mediaserver", + "app.modules.trimemedia.trimemedia -> app.application.security", + "app.modules.trimemedia.trimemedia -> app.application.security.url", + "app.modules.trimemedia.trimemedia -> app.foundation", + "app.modules.trimemedia.trimemedia -> app.foundation.url", + "app.modules.trimemedia.trimemedia -> app.modules", + "app.modules.trimemedia.trimemedia -> app.modules.trimemedia", + "app.modules.trimemedia.trimemedia -> app.modules.trimemedia.api", + "app.modules.trimemedia.trimemedia -> app.runtime", + "app.modules.trimemedia.trimemedia -> app.runtime.log", + "app.modules.trimemedia.trimemedia -> app.schemas", + "app.modules.trimemedia.trimemedia -> app.schemas.dashboard", + "app.modules.trimemedia.trimemedia -> app.schemas.mediaserver", + "app.modules.trimemedia.trimemedia -> app.schemas.types", + "app.modules.ugreen -> app.modules", + "app.modules.ugreen -> app.modules._base", + "app.modules.ugreen -> app.modules.ugreen.ugreen", + "app.modules.ugreen -> app.runtime", + "app.modules.ugreen -> app.runtime.log", + "app.modules.ugreen -> app.schemas", + "app.modules.ugreen -> app.schemas.dashboard", + "app.modules.ugreen -> app.schemas.mediaserver", + "app.modules.ugreen -> app.schemas.types", + "app.modules.ugreen.api -> app.foundation", + "app.modules.ugreen.api -> app.foundation.url", + "app.modules.ugreen.api -> app.modules", + "app.modules.ugreen.api -> app.modules.ugreen", + "app.modules.ugreen.api -> app.modules.ugreen.crypto", + "app.modules.ugreen.api -> app.runtime", + "app.modules.ugreen.api -> app.runtime.log", + "app.modules.ugreen.ugreen -> app.application", + "app.modules.ugreen.ugreen -> app.application.mediaserver", + "app.modules.ugreen.ugreen -> app.db", + "app.modules.ugreen.ugreen -> app.db.oper", + "app.modules.ugreen.ugreen -> app.db.oper.systemconfig", + "app.modules.ugreen.ugreen -> app.foundation", + "app.modules.ugreen.ugreen -> app.foundation.url", + "app.modules.ugreen.ugreen -> app.modules", + "app.modules.ugreen.ugreen -> app.modules.ugreen", + "app.modules.ugreen.ugreen -> app.modules.ugreen.api", + "app.modules.ugreen.ugreen -> app.runtime", + "app.modules.ugreen.ugreen -> app.runtime.log", + "app.modules.ugreen.ugreen -> app.schemas", + "app.modules.ugreen.ugreen -> app.schemas.dashboard", + "app.modules.ugreen.ugreen -> app.schemas.mediaserver", + "app.modules.ugreen.ugreen -> app.schemas.types", + "app.modules.vocechat -> app.application", + "app.modules.vocechat -> app.application.messaging", + "app.modules.vocechat -> app.application.messaging.agent", + "app.modules.vocechat -> app.domain", + "app.modules.vocechat -> app.domain.context", + "app.modules.vocechat -> app.modules", + "app.modules.vocechat -> app.modules._base", + "app.modules.vocechat -> app.modules.vocechat.vocechat", + "app.modules.vocechat -> app.runtime", + "app.modules.vocechat -> app.runtime.log", + "app.modules.vocechat -> app.schemas", + "app.modules.vocechat -> app.schemas.message", + "app.modules.vocechat -> app.schemas.notification", + "app.modules.vocechat -> app.schemas.types", + "app.modules.vocechat.vocechat -> app.adapters", + "app.modules.vocechat.vocechat -> app.adapters.network", + "app.modules.vocechat.vocechat -> app.adapters.network.http", + "app.modules.vocechat.vocechat -> app.domain", + "app.modules.vocechat.vocechat -> app.domain.context", + "app.modules.vocechat.vocechat -> app.domain.metainfo", + "app.modules.vocechat.vocechat -> app.foundation", + "app.modules.vocechat.vocechat -> app.foundation.size", + "app.modules.vocechat.vocechat -> app.runtime", + "app.modules.vocechat.vocechat -> app.runtime.execution", + "app.modules.vocechat.vocechat -> app.runtime.log", + "app.modules.webpush -> app.modules", + "app.modules.webpush -> app.runtime", + "app.modules.webpush -> app.runtime.config", + "app.modules.webpush -> app.runtime.log", + "app.modules.webpush -> app.schemas", + "app.modules.webpush -> app.schemas.message", + "app.modules.webpush -> app.schemas.types", + "app.modules.wechat -> app.adapters", + "app.modules.wechat -> app.adapters.external", + "app.modules.wechat -> app.adapters.external.wechat_crypt", + "app.modules.wechat -> app.application", + "app.modules.wechat -> app.application.messaging", + "app.modules.wechat -> app.application.messaging.agent", + "app.modules.wechat -> app.domain", + "app.modules.wechat -> app.domain.context", + "app.modules.wechat -> app.foundation", + "app.modules.wechat -> app.foundation.dom", + "app.modules.wechat -> app.modules", + "app.modules.wechat -> app.modules._base", + "app.modules.wechat -> app.modules.wechat.wechat", + "app.modules.wechat -> app.modules.wechat.wechatbot", + "app.modules.wechat -> app.runtime", + "app.modules.wechat -> app.runtime.log", + "app.modules.wechat -> app.schemas", + "app.modules.wechat -> app.schemas.message", + "app.modules.wechat -> app.schemas.notification", + "app.modules.wechat -> app.schemas.types", + "app.modules.wechat.wechat -> app.adapters", + "app.modules.wechat.wechat -> app.adapters.network", + "app.modules.wechat.wechat -> app.adapters.network.http", + "app.modules.wechat.wechat -> app.domain", + "app.modules.wechat.wechat -> app.domain.context", + "app.modules.wechat.wechat -> app.domain.metainfo", + "app.modules.wechat.wechat -> app.foundation", + "app.modules.wechat.wechat -> app.foundation.size", + "app.modules.wechat.wechat -> app.foundation.url", + "app.modules.wechat.wechat -> app.runtime", + "app.modules.wechat.wechat -> app.runtime.execution", + "app.modules.wechat.wechat -> app.runtime.log", + "app.modules.wechat.wechatbot -> app.adapters", + "app.modules.wechat.wechatbot -> app.adapters.network", + "app.modules.wechat.wechatbot -> app.adapters.network.http", + "app.modules.wechat.wechatbot -> app.application", + "app.modules.wechat.wechatbot -> app.application.messaging", + "app.modules.wechat.wechatbot -> app.application.messaging.agent", + "app.modules.wechat.wechatbot -> app.domain", + "app.modules.wechat.wechatbot -> app.domain.context", + "app.modules.wechat.wechatbot -> app.domain.metainfo", + "app.modules.wechat.wechatbot -> app.foundation", + "app.modules.wechat.wechatbot -> app.foundation.size", + "app.modules.wechat.wechatbot -> app.runtime", + "app.modules.wechat.wechatbot -> app.runtime.cache", + "app.modules.wechat.wechatbot -> app.runtime.config", + "app.modules.wechat.wechatbot -> app.runtime.log", + "app.modules.wechat.wechatbot -> app.schemas", + "app.modules.wechat.wechatbot -> app.schemas.message", + "app.modules.wechat.wechatbot -> app.schemas.types", + "app.modules.wechatclawbot -> app.application", + "app.modules.wechatclawbot -> app.application.messaging", + "app.modules.wechatclawbot -> app.application.messaging.agent", + "app.modules.wechatclawbot -> app.domain", + "app.modules.wechatclawbot -> app.domain.context", + "app.modules.wechatclawbot -> app.modules", + "app.modules.wechatclawbot -> app.modules._base", + "app.modules.wechatclawbot -> app.modules.wechatclawbot.wechatclawbot", + "app.modules.wechatclawbot -> app.runtime", + "app.modules.wechatclawbot -> app.runtime.cache", + "app.modules.wechatclawbot -> app.runtime.log", + "app.modules.wechatclawbot -> app.schemas", + "app.modules.wechatclawbot -> app.schemas.message", + "app.modules.wechatclawbot -> app.schemas.types", + "app.modules.wechatclawbot.wechatclawbot -> app.adapters", + "app.modules.wechatclawbot.wechatclawbot -> app.adapters.network", + "app.modules.wechatclawbot.wechatclawbot -> app.adapters.network.http", + "app.modules.wechatclawbot.wechatclawbot -> app.domain", + "app.modules.wechatclawbot.wechatclawbot -> app.domain.context", + "app.modules.wechatclawbot.wechatclawbot -> app.domain.metainfo", + "app.modules.wechatclawbot.wechatclawbot -> app.foundation", + "app.modules.wechatclawbot.wechatclawbot -> app.foundation.size", + "app.modules.wechatclawbot.wechatclawbot -> app.runtime", + "app.modules.wechatclawbot.wechatclawbot -> app.runtime.cache", + "app.modules.wechatclawbot.wechatclawbot -> app.runtime.config", + "app.modules.wechatclawbot.wechatclawbot -> app.runtime.log", + "app.modules.zspace -> app.modules", + "app.modules.zspace -> app.modules._base", + "app.modules.zspace -> app.modules.zspace.zspace", + "app.modules.zspace -> app.runtime", + "app.modules.zspace -> app.runtime.log", + "app.modules.zspace -> app.schemas", + "app.modules.zspace -> app.schemas.dashboard", + "app.modules.zspace -> app.schemas.event", + "app.modules.zspace -> app.schemas.mediaserver", + "app.modules.zspace -> app.schemas.types", + "app.modules.zspace.zspace -> app.adapters", + "app.modules.zspace.zspace -> app.adapters.network", + "app.modules.zspace.zspace -> app.adapters.network.http", + "app.modules.zspace.zspace -> app.application", + "app.modules.zspace.zspace -> app.application.mediaserver", + "app.modules.zspace.zspace -> app.foundation", + "app.modules.zspace.zspace -> app.foundation.url", + "app.modules.zspace.zspace -> app.runtime", + "app.modules.zspace.zspace -> app.runtime.log", + "app.modules.zspace.zspace -> app.schemas", + "app.modules.zspace.zspace -> app.schemas.dashboard", + "app.modules.zspace.zspace -> app.schemas.mediaserver", + "app.modules.zspace.zspace -> app.schemas.types", + "app.monitor -> app.monitor.monitor", + "app.monitor -> app.monitor.watcher", + "app.monitor.dispatcher -> app.adapters", + "app.monitor.dispatcher -> app.adapters.system", + "app.monitor.dispatcher -> app.adapters.system.fsproxy", + "app.monitor.dispatcher -> app.application", + "app.monitor.dispatcher -> app.application.directory", + "app.monitor.dispatcher -> app.application.history", + "app.monitor.dispatcher -> app.chain", + "app.monitor.dispatcher -> app.chain.transfer", + "app.monitor.dispatcher -> app.db", + "app.monitor.dispatcher -> app.db.oper", + "app.monitor.dispatcher -> app.db.oper.transferhistory", + "app.monitor.dispatcher -> app.runtime", + "app.monitor.dispatcher -> app.runtime.cache", + "app.monitor.dispatcher -> app.runtime.config", + "app.monitor.dispatcher -> app.runtime.log", + "app.monitor.dispatcher -> app.schemas", + "app.monitor.dispatcher -> app.schemas.types", + "app.monitor.dispatcher -> app.schemas.workflow", + "app.monitor.monitor -> app.adapters", + "app.monitor.monitor -> app.adapters.system", + "app.monitor.monitor -> app.adapters.system.host", + "app.monitor.monitor -> app.application", + "app.monitor.monitor -> app.application.directory", + "app.monitor.monitor -> app.application.messaging", + "app.monitor.monitor -> app.application.messaging.message", + "app.monitor.monitor -> app.foundation", + "app.monitor.monitor -> app.foundation.singleton", + "app.monitor.monitor -> app.monitor", + "app.monitor.monitor -> app.monitor.dispatcher", + "app.monitor.monitor -> app.monitor.poller", + "app.monitor.monitor -> app.monitor.recovery", + "app.monitor.monitor -> app.monitor.snapshot", + "app.monitor.monitor -> app.monitor.syslimits", + "app.monitor.monitor -> app.monitor.watcher", + "app.monitor.monitor -> app.runtime", + "app.monitor.monitor -> app.runtime.config", + "app.monitor.monitor -> app.runtime.log", + "app.monitor.monitor -> app.runtime.reload", + "app.monitor.monitor -> app.schemas", + "app.monitor.monitor -> app.schemas.types", + "app.monitor.poller -> app.chain", + "app.monitor.poller -> app.chain.storage", + "app.monitor.poller -> app.monitor", + "app.monitor.poller -> app.monitor.dispatcher", + "app.monitor.poller -> app.monitor.snapshot", + "app.monitor.poller -> app.runtime", + "app.monitor.poller -> app.runtime.log", + "app.monitor.recovery -> app.runtime", + "app.monitor.recovery -> app.runtime.log", + "app.monitor.snapshot -> app.runtime", + "app.monitor.snapshot -> app.runtime.cache", + "app.monitor.snapshot -> app.runtime.config", + "app.monitor.snapshot -> app.runtime.log", + "app.monitor.syslimits -> app.adapters", + "app.monitor.syslimits -> app.adapters.system", + "app.monitor.syslimits -> app.adapters.system.fsproxy", + "app.monitor.syslimits -> app.adapters.system.host", + "app.monitor.syslimits -> app.runtime", + "app.monitor.syslimits -> app.runtime.config", + "app.monitor.syslimits -> app.runtime.log", + "app.monitor.watcher -> app.adapters", + "app.monitor.watcher -> app.adapters.system", + "app.monitor.watcher -> app.adapters.system.fsproxy", + "app.monitor.watcher -> app.runtime", + "app.monitor.watcher -> app.runtime.config", + "app.monitor.watcher -> app.runtime.log", + "app.runtime.capabilities.registry -> app.runtime", + "app.runtime.capabilities.registry -> app.runtime.capabilities", + "app.runtime.capabilities.registry -> app.runtime.capabilities.errors", + "app.runtime.capabilities.registry -> app.runtime.capabilities.model", + "app.runtime.capabilities.runtime -> app.runtime", + "app.runtime.capabilities.runtime -> app.runtime.capabilities", + "app.runtime.capabilities.runtime -> app.runtime.capabilities.errors", + "app.runtime.capabilities.runtime -> app.runtime.capabilities.model", + "app.runtime.capabilities.runtime -> app.runtime.capabilities.registry", + "app.runtime.coalesce -> app.runtime", + "app.runtime.coalesce -> app.runtime.log", + "app.runtime.compat.diagnostics -> app.runtime", + "app.runtime.compat.diagnostics -> app.runtime.compat", + "app.runtime.compat.diagnostics -> app.runtime.compat.manifest", + "app.runtime.compat.imports -> app.runtime", + "app.runtime.compat.imports -> app.runtime.compat", + "app.runtime.compat.imports -> app.runtime.compat.diagnostics", + "app.runtime.compat.imports -> app.runtime.compat.manifest", + "app.runtime.config -> app.adapters", + "app.runtime.config -> app.adapters.system", + "app.runtime.config -> app.adapters.system.host", + "app.runtime.config -> app.foundation", + "app.runtime.config -> app.foundation.url", + "app.runtime.config -> app.runtime", + "app.runtime.config -> app.runtime.log", + "app.runtime.config -> app.schemas", + "app.runtime.config -> app.schemas.types", + "app.runtime.debounce -> app.runtime", + "app.runtime.debounce -> app.runtime.log", + "app.runtime.event.binding -> app.runtime", + "app.runtime.event.binding -> app.runtime.event", + "app.runtime.event.binding -> app.runtime.event.registry", + "app.runtime.event.binding -> app.runtime.log", + "app.runtime.event.dispatch -> app.runtime", + "app.runtime.event.dispatch -> app.runtime.event", + "app.runtime.event.dispatch -> app.runtime.event.binding", + "app.runtime.event.dispatch -> app.runtime.event.registry", + "app.runtime.event.dispatch -> app.runtime.log", + "app.runtime.event.dispatch -> app.schemas", + "app.runtime.event.dispatch -> app.schemas.types", + "app.runtime.event.errors -> app.runtime", + "app.runtime.event.errors -> app.runtime.log", + "app.runtime.event.errors -> app.schemas", + "app.runtime.event.errors -> app.schemas.types", + "app.runtime.event.registry -> app.runtime", + "app.runtime.event.registry -> app.runtime.log", + "app.runtime.event.registry -> app.schemas", + "app.runtime.event.registry -> app.schemas.types", + "app.runtime.events -> app.foundation", + "app.runtime.events -> app.foundation.singleton", + "app.runtime.events -> app.runtime", + "app.runtime.events -> app.runtime.config", + "app.runtime.events -> app.runtime.event", + "app.runtime.events -> app.runtime.event.binding", + "app.runtime.events -> app.runtime.event.dispatch", + "app.runtime.events -> app.runtime.event.errors", + "app.runtime.events -> app.runtime.event.registry", + "app.runtime.events -> app.runtime.log", + "app.runtime.events -> app.runtime.rate", + "app.runtime.events -> app.runtime.thread", + "app.runtime.events -> app.schemas", + "app.runtime.events -> app.schemas.event", + "app.runtime.events -> app.schemas.types", + "app.runtime.execution -> app.schemas", + "app.runtime.execution -> app.schemas.exception", + "app.runtime.extensions.host_module_adapter -> app.runtime", + "app.runtime.extensions.host_module_adapter -> app.runtime.capabilities", + "app.runtime.extensions.host_module_adapter -> app.runtime.capabilities.model", + "app.runtime.extensions.host_module_adapter -> app.runtime.capabilities.registry", + "app.runtime.extensions.host_module_adapter -> app.runtime.config", + "app.runtime.extensions.host_module_adapter -> app.runtime.extensions", + "app.runtime.extensions.host_module_adapter -> app.runtime.extensions.service_config", + "app.runtime.extensions.host_module_adapter -> app.schemas", + "app.runtime.extensions.host_module_adapter -> app.schemas.types", + "app.runtime.extensions.managed_resource_adapter -> app.runtime", + "app.runtime.extensions.managed_resource_adapter -> app.runtime.capabilities", + "app.runtime.extensions.managed_resource_adapter -> app.runtime.capabilities.errors", + "app.runtime.extensions.managed_resource_adapter -> app.runtime.capabilities.model", + "app.runtime.extensions.managed_resource_adapter -> app.runtime.capabilities.registry", + "app.runtime.extensions.managed_resource_adapter -> app.runtime.managed_resources", + "app.runtime.extensions.module.dispatcher -> app.foundation", + "app.runtime.extensions.module.dispatcher -> app.foundation.reflection", + "app.runtime.extensions.module.dispatcher -> app.runtime", + "app.runtime.extensions.module.dispatcher -> app.runtime.extensions", + "app.runtime.extensions.module.dispatcher -> app.runtime.extensions.module", + "app.runtime.extensions.module.dispatcher -> app.runtime.extensions.module.contracts", + "app.runtime.extensions.module.dispatcher -> app.runtime.log", + "app.runtime.extensions.module.dispatcher -> app.schemas", + "app.runtime.extensions.module.dispatcher -> app.schemas.exception", + "app.runtime.extensions.module_manager -> app.foundation", + "app.runtime.extensions.module_manager -> app.foundation.reflection", + "app.runtime.extensions.module_manager -> app.foundation.singleton", + "app.runtime.extensions.module_manager -> app.runtime", + "app.runtime.extensions.module_manager -> app.runtime.capabilities", + "app.runtime.extensions.module_manager -> app.runtime.capabilities.model", + "app.runtime.extensions.module_manager -> app.runtime.capabilities.runtime", + "app.runtime.extensions.module_manager -> app.runtime.config", + "app.runtime.extensions.module_manager -> app.runtime.events", + "app.runtime.extensions.module_manager -> app.runtime.extensions", + "app.runtime.extensions.module_manager -> app.runtime.extensions.host_module_adapter", + "app.runtime.extensions.module_manager -> app.runtime.log", + "app.runtime.extensions.module_manager -> app.schemas", + "app.runtime.extensions.module_manager -> app.schemas.types", + "app.runtime.extensions.plugin.contracts -> app.foundation", + "app.runtime.extensions.plugin.contracts -> app.foundation.reflection", + "app.runtime.extensions.plugin.projection -> app.runtime", + "app.runtime.extensions.plugin.projection -> app.runtime.extensions", + "app.runtime.extensions.plugin.projection -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.projection -> app.runtime.extensions.plugin.contracts", + "app.runtime.extensions.plugin.projection -> app.runtime.log", + "app.runtime.extensions.plugin_manager -> app.foundation", + "app.runtime.extensions.plugin_manager -> app.foundation.crypto", + "app.runtime.extensions.plugin_manager -> app.foundation.singleton", + "app.runtime.extensions.plugin_manager -> app.foundation.version", + "app.runtime.extensions.plugin_manager -> app.runtime", + "app.runtime.extensions.plugin_manager -> app.runtime.config", + "app.runtime.extensions.plugin_manager -> app.runtime.events", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.contracts", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.projection", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.registry", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.storage", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin_manager -> app.runtime.log", + "app.runtime.extensions.plugin_manager -> app.runtime.reload", + "app.runtime.extensions.plugin_manager -> app.schemas", + "app.runtime.extensions.plugin_manager -> app.schemas.plugin", + "app.runtime.extensions.plugin_manager -> app.schemas.types", + "app.runtime.extensions.service_config -> app.runtime", + "app.runtime.extensions.service_config -> app.runtime.log", + "app.runtime.extensions.service_config -> app.schemas", + "app.runtime.extensions.service_config -> app.schemas.system", + "app.runtime.extensions.service_config -> app.schemas.types", + "app.runtime.extensions.service_registry -> app.runtime", + "app.runtime.extensions.service_registry -> app.runtime.extensions", + "app.runtime.extensions.service_registry -> app.runtime.extensions.module_manager", + "app.runtime.extensions.service_registry -> app.runtime.extensions.service_config", + "app.runtime.extensions.service_registry -> app.schemas", + "app.runtime.extensions.service_registry -> app.schemas.system", + "app.runtime.extensions.service_registry -> app.schemas.types", + "app.runtime.progress -> app.runtime", + "app.runtime.progress -> app.runtime.cache", + "app.runtime.progress -> app.runtime.localization", + "app.runtime.progress -> app.schemas", + "app.runtime.progress -> app.schemas.types", + "app.runtime.rate -> app.runtime", + "app.runtime.rate -> app.runtime.log", + "app.runtime.rate -> app.schemas", + "app.runtime.rate -> app.schemas.exception", + "app.runtime.reload -> app.runtime", + "app.runtime.reload -> app.runtime.events", + "app.runtime.reload -> app.runtime.log", + "app.runtime.reload -> app.schemas", + "app.runtime.reload -> app.schemas.types", + "app.runtime.state -> app.adapters", + "app.runtime.state -> app.adapters.system", + "app.runtime.state -> app.adapters.system.host", + "app.runtime.state -> app.runtime", + "app.runtime.state -> app.runtime.config", + "app.runtime.state -> app.runtime.log", + "app.runtime.state -> app.runtime.reload", + "app.runtime.thread -> app.foundation", + "app.runtime.thread -> app.foundation.singleton", + "app.runtime.thread -> app.runtime", + "app.runtime.thread -> app.runtime.config", + "app.scheduler -> app.adapters", + "app.scheduler -> app.adapters.external", + "app.scheduler -> app.adapters.external.server", + "app.scheduler -> app.agent", + "app.scheduler -> app.agent.runtime_loader", + "app.scheduler -> app.application", + "app.scheduler -> app.application.image", + "app.scheduler -> app.application.maintenance", + "app.scheduler -> app.application.messaging", + "app.scheduler -> app.application.messaging.message", + "app.scheduler -> app.application.scheduling", + "app.scheduler -> app.application.site", + "app.scheduler -> app.chain", + "app.scheduler -> app.chain.mediaserver", + "app.scheduler -> app.chain.recommend", + "app.scheduler -> app.chain.site", + "app.scheduler -> app.chain.subscribe", + "app.scheduler -> app.chain.transfer", + "app.scheduler -> app.chain.workflow", + "app.scheduler -> app.db", + "app.scheduler -> app.db.oper", + "app.scheduler -> app.db.oper.agenttask", + "app.scheduler -> app.db.oper.systemconfig", + "app.scheduler -> app.foundation", + "app.scheduler -> app.foundation.singleton", + "app.scheduler -> app.runtime", + "app.scheduler -> app.runtime.config", + "app.scheduler -> app.runtime.events", + "app.scheduler -> app.runtime.extensions", + "app.scheduler -> app.runtime.extensions.plugin_manager", + "app.scheduler -> app.runtime.extensions.service_registry", + "app.scheduler -> app.runtime.gc", + "app.scheduler -> app.runtime.log", + "app.scheduler -> app.runtime.progress", + "app.scheduler -> app.runtime.reload", + "app.scheduler -> app.runtime.scheduling", + "app.scheduler -> app.schemas", + "app.scheduler -> app.schemas.dashboard", + "app.scheduler -> app.schemas.message", + "app.scheduler -> app.schemas.system", + "app.scheduler -> app.schemas.types", + "app.scheduler -> app.schemas.workflow", + "app.schemas -> app.schemas.exports", + "app.schemas.agent -> app.schemas", + "app.schemas.agent -> app.schemas.common", + "app.schemas.cache -> app.schemas", + "app.schemas.cache -> app.schemas.types", + "app.schemas.context -> app.schemas", + "app.schemas.context -> app.schemas.common", + "app.schemas.context -> app.schemas.media", + "app.schemas.context -> app.schemas.music", + "app.schemas.context -> app.schemas.types", + "app.schemas.dashboard -> app.runtime", + "app.schemas.dashboard -> app.runtime.localization", + "app.schemas.dashboard -> app.schemas", + "app.schemas.dashboard -> app.schemas.common", + "app.schemas.event -> app.schemas", + "app.schemas.event -> app.schemas.common", + "app.schemas.event -> app.schemas.file", + "app.schemas.event -> app.schemas.media", + "app.schemas.event -> app.schemas.types", + "app.schemas.file -> app.schemas", + "app.schemas.file -> app.schemas.types", + "app.schemas.history -> app.schemas", + "app.schemas.history -> app.schemas.common", + "app.schemas.history -> app.schemas.media", + "app.schemas.history -> app.schemas.types", + "app.schemas.mcp -> app.schemas", + "app.schemas.mcp -> app.schemas.common", + "app.schemas.media -> app.schemas", + "app.schemas.media -> app.schemas.types", + "app.schemas.mediaserver -> app.schemas", + "app.schemas.mediaserver -> app.schemas.common", + "app.schemas.mediaserver -> app.schemas.media", + "app.schemas.mediaserver -> app.schemas.types", + "app.schemas.message -> app.schemas", + "app.schemas.message -> app.schemas.common", + "app.schemas.message -> app.schemas.types", + "app.schemas.mfa -> app.schemas", + "app.schemas.mfa -> app.schemas.common", + "app.schemas.music -> app.schemas", + "app.schemas.music -> app.schemas.common", + "app.schemas.music -> app.schemas.media", + "app.schemas.music -> app.schemas.types", + "app.schemas.notification -> app.schemas", + "app.schemas.notification -> app.schemas.types", + "app.schemas.openai -> app.schemas", + "app.schemas.openai -> app.schemas.common", + "app.schemas.plugin -> app.schemas", + "app.schemas.plugin -> app.schemas.common", + "app.schemas.response -> app.runtime", + "app.schemas.response -> app.runtime.localization", + "app.schemas.search -> app.schemas", + "app.schemas.search -> app.schemas.common", + "app.schemas.search -> app.schemas.context", + "app.schemas.servcookie -> app.schemas", + "app.schemas.servcookie -> app.schemas.common", + "app.schemas.site -> app.schemas", + "app.schemas.site -> app.schemas.common", + "app.schemas.subscribe -> app.schemas", + "app.schemas.subscribe -> app.schemas.media", + "app.schemas.subscribe -> app.schemas.types", + "app.schemas.system -> app.schemas", + "app.schemas.system -> app.schemas.context", + "app.schemas.system -> app.schemas.rule", + "app.schemas.token -> app.schemas", + "app.schemas.token -> app.schemas.user", + "app.schemas.transfer -> app.schemas", + "app.schemas.transfer -> app.schemas.context", + "app.schemas.transfer -> app.schemas.file", + "app.schemas.transfer -> app.schemas.media", + "app.schemas.transfer -> app.schemas.music", + "app.schemas.transfer -> app.schemas.types", + "app.schemas.user -> app.schemas", + "app.schemas.user -> app.schemas.common", + "app.schemas.workflow -> app.schemas", + "app.schemas.workflow -> app.schemas.common", + "app.schemas.workflow -> app.schemas.context", + "app.schemas.workflow -> app.schemas.download", + "app.schemas.workflow -> app.schemas.file", + "app.schemas.workflow -> app.schemas.site", + "app.schemas.workflow -> app.schemas.subscribe", + "app.sdk._legacy.history -> app.application", + "app.sdk._legacy.history -> app.application.history", + "app.sdk._legacy.history -> app.db", + "app.sdk._legacy.history -> app.db.models", + "app.sdk._legacy.history -> app.db.models.transferhistory", + "app.sdk._legacy.history -> app.db.oper", + "app.sdk._legacy.history -> app.db.oper.transferhistory", + "app.sdk._legacy.history -> app.domain", + "app.sdk._legacy.history -> app.domain.context", + "app.sdk._legacy.history -> app.domain.meta", + "app.sdk._legacy.history -> app.domain.meta.metabase", + "app.sdk._legacy.history -> app.schemas", + "app.sdk._legacy.history -> app.schemas.file", + "app.sdk._legacy.history -> app.schemas.transfer", + "app.sdk._legacy.subscribe -> app.application", + "app.sdk._legacy.subscribe -> app.application.subscribe", + "app.sdk._legacy.subscribe -> app.db", + "app.sdk._legacy.subscribe -> app.db.models", + "app.sdk._legacy.subscribe -> app.db.models.subscribe", + "app.sdk._legacy.subscribe -> app.db.oper", + "app.sdk._legacy.subscribe -> app.db.oper.subscribe", + "app.sdk._legacy.subscribe -> app.domain", + "app.sdk._legacy.subscribe -> app.domain.context", + "app.sdk._legacy.transfer -> app.application", + "app.sdk._legacy.transfer -> app.application.transfer", + "app.sdk._legacy.user -> app.api", + "app.sdk._legacy.user -> app.api.deps", + "app.sdk._legacy.user -> app.db", + "app.sdk._legacy.user -> app.db.models", + "app.sdk._legacy.user -> app.db.models.user", + "app.sdk._legacy.user -> app.db.oper", + "app.sdk._legacy.user -> app.db.oper.user", + "app.sdk.browser -> app.adapters", + "app.sdk.browser -> app.adapters.network", + "app.sdk.browser -> app.adapters.network.browser", + "app.sdk.cache -> app.adapters", + "app.sdk.cache -> app.adapters.cache", + "app.sdk.cache -> app.adapters.cache.backends", + "app.sdk.cache -> app.runtime", + "app.sdk.cache -> app.runtime.cache", + "app.sdk.config -> app.runtime", + "app.sdk.config -> app.runtime.config", + "app.sdk.events -> app.runtime", + "app.sdk.events -> app.runtime.events", + "app.sdk.logging -> app.runtime", + "app.sdk.logging -> app.runtime.log", + "app.sdk.media -> app.domain", + "app.sdk.media -> app.domain.context", + "app.sdk.media -> app.domain.media", + "app.sdk.media -> app.domain.meta", + "app.sdk.media -> app.domain.meta.metaanime", + "app.sdk.media -> app.domain.meta.metabase", + "app.sdk.media -> app.domain.meta.metamusic", + "app.sdk.media -> app.domain.meta.metavideo", + "app.sdk.media -> app.domain.meta.words", + "app.sdk.media -> app.domain.metainfo", + "app.sdk.media -> app.domain.scraper", + "app.sdk.media -> app.domain.tokens", + "app.sdk.media -> app.schemas", + "app.sdk.media -> app.schemas.media", + "app.sdk.network -> app.adapters", + "app.sdk.network -> app.adapters.external", + "app.sdk.network -> app.adapters.external.location", + "app.sdk.network -> app.adapters.network", + "app.sdk.network -> app.adapters.network.http", + "app.sdk.network -> app.adapters.network.ip", + "app.sdk.network -> app.application", + "app.sdk.network -> app.application.rss", + "app.sdk.network -> app.application.security", + "app.sdk.network -> app.application.security.url", + "app.sdk.network -> app.application.site", + "app.sdk.network -> app.domain", + "app.sdk.network -> app.domain.site", + "app.sdk.network -> app.foundation", + "app.sdk.network -> app.foundation.url", + "app.sdk.plugins -> app.runtime", + "app.sdk.plugins -> app.runtime.extensions", + "app.sdk.plugins -> app.runtime.extensions.module_manager", + "app.sdk.plugins -> app.runtime.extensions.plugin_manager", + "app.sdk.services -> app.application", + "app.sdk.services -> app.application.downloader", + "app.sdk.services -> app.application.mediaserver", + "app.sdk.services -> app.application.notification", + "app.sdk.services -> app.application.rules", + "app.sdk.services -> app.application.storage", + "app.sdk.services -> app.runtime", + "app.sdk.services -> app.runtime.extensions", + "app.sdk.services -> app.runtime.extensions.service_registry", + "app.sdk.services -> app.runtime.state", + "app.sdk.string -> app.domain", + "app.sdk.string -> app.domain.episode", + "app.sdk.string -> app.domain.site", + "app.sdk.string -> app.domain.title", + "app.sdk.string -> app.domain.torrent", + "app.sdk.string -> app.foundation", + "app.sdk.string -> app.foundation.crypto", + "app.sdk.string -> app.foundation.dom", + "app.sdk.string -> app.foundation.size", + "app.sdk.string -> app.foundation.temporal", + "app.sdk.string -> app.foundation.text", + "app.sdk.string -> app.foundation.url", + "app.sdk.string -> app.foundation.version", + "app.sdk.utilities -> app.adapters", + "app.sdk.utilities -> app.adapters.system", + "app.sdk.utilities -> app.adapters.system.host", + "app.sdk.utilities -> app.application", + "app.sdk.utilities -> app.application.security", + "app.sdk.utilities -> app.application.security.otp", + "app.sdk.utilities -> app.foundation", + "app.sdk.utilities -> app.foundation.crypto", + "app.sdk.utilities -> app.foundation.dom", + "app.sdk.utilities -> app.foundation.reflection", + "app.sdk.utilities -> app.foundation.singleton", + "app.sdk.utilities -> app.foundation.text", + "app.sdk.utilities -> app.runtime", + "app.sdk.utilities -> app.runtime.execution", + "app.sdk.utilities -> app.runtime.localization", + "app.sdk.utilities -> app.runtime.scheduling", + "app.sdk.utilities -> app.sdk", + "app.sdk.utilities -> app.sdk.string", + "app.startup.agent_initializer -> app.agent", + "app.startup.agent_initializer -> app.agent.llm", + "app.startup.agent_initializer -> app.agent.prompt", + "app.startup.agent_initializer -> app.agent.prompt.transfer_redo", + "app.startup.agent_initializer -> app.agent.runtime_loader", + "app.startup.agent_initializer -> app.agent.tools", + "app.startup.agent_initializer -> app.agent.tools.base", + "app.startup.agent_initializer -> app.application", + "app.startup.agent_initializer -> app.application.agent", + "app.startup.agent_initializer -> app.runtime", + "app.startup.agent_initializer -> app.runtime.config", + "app.startup.agent_initializer -> app.runtime.events", + "app.startup.agent_initializer -> app.runtime.log", + "app.startup.agent_initializer -> app.schemas", + "app.startup.agent_initializer -> app.schemas.types", + "app.startup.cache_initializer -> app.adapters", + "app.startup.cache_initializer -> app.adapters.cache", + "app.startup.cache_initializer -> app.adapters.cache.backends", + "app.startup.command_initializer -> app.application", + "app.startup.command_initializer -> app.application.commands", + "app.startup.command_initializer -> app.command", + "app.startup.database_initializer -> app.db", + "app.startup.database_initializer -> app.db.engine", + "app.startup.database_initializer -> app.db.models", + "app.startup.database_initializer -> app.runtime", + "app.startup.database_initializer -> app.runtime.config", + "app.startup.database_initializer -> app.runtime.log", + "app.startup.domain_initializer -> app.adapters", + "app.startup.domain_initializer -> app.adapters.system", + "app.startup.domain_initializer -> app.adapters.system.rust", + "app.startup.domain_initializer -> app.application", + "app.startup.domain_initializer -> app.application.recognition", + "app.startup.domain_initializer -> app.domain", + "app.startup.domain_initializer -> app.domain.context", + "app.startup.domain_initializer -> app.domain.media", + "app.startup.domain_initializer -> app.domain.meta", + "app.startup.domain_initializer -> app.domain.meta.customization", + "app.startup.domain_initializer -> app.domain.meta.releasegroup", + "app.startup.domain_initializer -> app.domain.meta.runtime", + "app.startup.domain_initializer -> app.domain.meta.words", + "app.startup.domain_initializer -> app.domain.metainfo", + "app.startup.domain_initializer -> app.runtime", + "app.startup.domain_initializer -> app.runtime.config", + "app.startup.lifecycle -> app.adapters", + "app.startup.lifecycle -> app.adapters.external", + "app.startup.lifecycle -> app.adapters.external.server", + "app.startup.lifecycle -> app.adapters.network", + "app.startup.lifecycle -> app.adapters.network.http", + "app.startup.lifecycle -> app.chain", + "app.startup.lifecycle -> app.chain.system", + "app.startup.lifecycle -> app.db", + "app.startup.lifecycle -> app.runtime", + "app.startup.lifecycle -> app.runtime.config", + "app.startup.lifecycle -> app.runtime.log", + "app.startup.lifecycle -> app.runtime.state", + "app.startup.lifecycle -> app.startup", + "app.startup.lifecycle -> app.startup.cache_initializer", + "app.startup.lifecycle -> app.startup.command_initializer", + "app.startup.lifecycle -> app.startup.domain_initializer", + "app.startup.lifecycle -> app.startup.lifecycle.components", + "app.startup.lifecycle -> app.startup.modules_initializer", + "app.startup.lifecycle -> app.startup.monitor_initializer", + "app.startup.lifecycle -> app.startup.plugins_initializer", + "app.startup.lifecycle -> app.startup.routers_initializer", + "app.startup.lifecycle -> app.startup.scheduler_initializer", + "app.startup.lifecycle -> app.startup.transfer_initializer", + "app.startup.lifecycle -> app.startup.workflow_initializer", + "app.startup.managed_resources_initializer -> app.runtime", + "app.startup.managed_resources_initializer -> app.runtime.capabilities", + "app.startup.managed_resources_initializer -> app.runtime.capabilities.runtime", + "app.startup.managed_resources_initializer -> app.runtime.extensions", + "app.startup.managed_resources_initializer -> app.runtime.extensions.managed_resource_adapter", + "app.startup.managed_resources_initializer -> app.runtime.managed_resources", + "app.startup.modules_initializer -> app.adapters", + "app.startup.modules_initializer -> app.adapters.cache", + "app.startup.modules_initializer -> app.adapters.cache.redis", + "app.startup.modules_initializer -> app.adapters.external", + "app.startup.modules_initializer -> app.adapters.external.server", + "app.startup.modules_initializer -> app.adapters.network", + "app.startup.modules_initializer -> app.adapters.network.browser", + "app.startup.modules_initializer -> app.adapters.network.doh", + "app.startup.modules_initializer -> app.adapters.system", + "app.startup.modules_initializer -> app.adapters.system.host", + "app.startup.modules_initializer -> app.adapters.system.resource", + "app.startup.modules_initializer -> app.application", + "app.startup.modules_initializer -> app.application.chain", + "app.startup.modules_initializer -> app.application.chain.context", + "app.startup.modules_initializer -> app.application.image", + "app.startup.modules_initializer -> app.application.messaging", + "app.startup.modules_initializer -> app.application.messaging.message", + "app.startup.modules_initializer -> app.application.security", + "app.startup.modules_initializer -> app.application.security.access", + "app.startup.modules_initializer -> app.application.security.auth", + "app.startup.modules_initializer -> app.application.server", + "app.startup.modules_initializer -> app.application.server.report", + "app.startup.modules_initializer -> app.application.server.share", + "app.startup.modules_initializer -> app.application.site", + "app.startup.modules_initializer -> app.chain", + "app.startup.modules_initializer -> app.chain.download", + "app.startup.modules_initializer -> app.chain.mediaserver", + "app.startup.modules_initializer -> app.chain.scraping", + "app.startup.modules_initializer -> app.chain.search", + "app.startup.modules_initializer -> app.chain.site", + "app.startup.modules_initializer -> app.chain.subscribe", + "app.startup.modules_initializer -> app.chain.tmdb", + "app.startup.modules_initializer -> app.chain.workflow", + "app.startup.modules_initializer -> app.command", + "app.startup.modules_initializer -> app.db", + "app.startup.modules_initializer -> app.db.oper", + "app.startup.modules_initializer -> app.db.oper.subscribe", + "app.startup.modules_initializer -> app.db.oper.systemconfig", + "app.startup.modules_initializer -> app.db.oper.workflow", + "app.startup.modules_initializer -> app.runtime", + "app.startup.modules_initializer -> app.runtime.config", + "app.startup.modules_initializer -> app.runtime.events", + "app.startup.modules_initializer -> app.runtime.extensions", + "app.startup.modules_initializer -> app.runtime.extensions.module_manager", + "app.startup.modules_initializer -> app.runtime.extensions.service_config", + "app.startup.modules_initializer -> app.runtime.log", + "app.startup.modules_initializer -> app.runtime.state", + "app.startup.modules_initializer -> app.runtime.thread", + "app.startup.modules_initializer -> app.scheduler", + "app.startup.modules_initializer -> app.schemas", + "app.startup.modules_initializer -> app.schemas.message", + "app.startup.modules_initializer -> app.schemas.types", + "app.startup.modules_initializer -> app.startup", + "app.startup.modules_initializer -> app.startup.agent_initializer", + "app.startup.modules_initializer -> app.startup.managed_resources_initializer", + "app.startup.monitor_initializer -> app.monitor", + "app.startup.plugins_initializer -> app.adapters", + "app.startup.plugins_initializer -> app.adapters.external", + "app.startup.plugins_initializer -> app.adapters.external.market", + "app.startup.plugins_initializer -> app.adapters.external.plugin", + "app.startup.plugins_initializer -> app.adapters.external.plugin.client", + "app.startup.plugins_initializer -> app.adapters.external.server", + "app.startup.plugins_initializer -> app.adapters.system", + "app.startup.plugins_initializer -> app.adapters.system.host", + "app.startup.plugins_initializer -> app.adapters.system.plugin", + "app.startup.plugins_initializer -> app.adapters.system.plugin.dependency", + "app.startup.plugins_initializer -> app.adapters.system.plugin.package", + "app.startup.plugins_initializer -> app.api", + "app.startup.plugins_initializer -> app.api.endpoints", + "app.startup.plugins_initializer -> app.api.endpoints.plugin", + "app.startup.plugins_initializer -> app.application", + "app.startup.plugins_initializer -> app.application.plugin", + "app.startup.plugins_initializer -> app.application.plugin.catalog", + "app.startup.plugins_initializer -> app.application.site", + "app.startup.plugins_initializer -> app.db", + "app.startup.plugins_initializer -> app.db.oper", + "app.startup.plugins_initializer -> app.db.oper.plugindata", + "app.startup.plugins_initializer -> app.db.oper.systemconfig", + "app.startup.plugins_initializer -> app.foundation", + "app.startup.plugins_initializer -> app.foundation.version", + "app.startup.plugins_initializer -> app.runtime", + "app.startup.plugins_initializer -> app.runtime.compat", + "app.startup.plugins_initializer -> app.runtime.compat.diagnostics", + "app.startup.plugins_initializer -> app.runtime.compat.resource_imports", + "app.startup.plugins_initializer -> app.runtime.config", + "app.startup.plugins_initializer -> app.runtime.extensions", + "app.startup.plugins_initializer -> app.runtime.extensions.plugin", + "app.startup.plugins_initializer -> app.runtime.extensions.plugin.storage", + "app.startup.plugins_initializer -> app.runtime.extensions.plugin.system", + "app.startup.plugins_initializer -> app.runtime.extensions.plugin_manager", + "app.startup.plugins_initializer -> app.runtime.log", + "app.startup.plugins_initializer -> app.runtime.managed_resources", + "app.startup.plugins_initializer -> app.schemas", + "app.startup.plugins_initializer -> app.schemas.types", + "app.startup.routers_initializer -> app.api", + "app.startup.routers_initializer -> app.api.router_specs", + "app.startup.routers_initializer -> app.api.servarr", + "app.startup.routers_initializer -> app.api.servcookie", + "app.startup.routers_initializer -> app.runtime", + "app.startup.routers_initializer -> app.runtime.config", + "app.startup.scheduler_initializer -> app.application", + "app.startup.scheduler_initializer -> app.application.scheduling", + "app.startup.scheduler_initializer -> app.scheduler", + "app.startup.transfer_initializer -> app.chain", + "app.startup.transfer_initializer -> app.chain.transfer", + "app.startup.workflow_initializer -> app.workflow", + "app.testing -> app.testing.stub", + "app.testing.bootstrap -> app.application", + "app.testing.bootstrap -> app.application.site", + "app.testing.bootstrap -> app.startup", + "app.testing.bootstrap -> app.startup.cache_initializer", + "app.testing.bootstrap -> app.startup.database_initializer", + "app.testing.bootstrap -> app.startup.domain_initializer", + "app.workflow -> app.db", + "app.workflow -> app.db.models", + "app.workflow -> app.db.oper", + "app.workflow -> app.db.oper.workflow", + "app.workflow -> app.foundation", + "app.workflow -> app.foundation.reflection", + "app.workflow -> app.foundation.singleton", + "app.workflow -> app.runtime", + "app.workflow -> app.runtime.config", + "app.workflow -> app.runtime.events", + "app.workflow -> app.runtime.log", + "app.workflow -> app.schemas", + "app.workflow -> app.schemas.types", + "app.workflow -> app.schemas.workflow", + "app.workflow.actions -> app.chain", + "app.workflow.actions -> app.db", + "app.workflow.actions -> app.db.oper", + "app.workflow.actions -> app.db.oper.systemconfig", + "app.workflow.actions -> app.schemas", + "app.workflow.actions -> app.schemas.workflow", + "app.workflow.actions.add_download -> app.chain", + "app.workflow.actions.add_download -> app.chain.download", + "app.workflow.actions.add_download -> app.chain.media", + "app.workflow.actions.add_download -> app.domain", + "app.workflow.actions.add_download -> app.domain.metainfo", + "app.workflow.actions.add_download -> app.runtime", + "app.workflow.actions.add_download -> app.runtime.config", + "app.workflow.actions.add_download -> app.runtime.log", + "app.workflow.actions.add_download -> app.schemas", + "app.workflow.actions.add_download -> app.schemas.types", + "app.workflow.actions.add_download -> app.schemas.workflow", + "app.workflow.actions.add_download -> app.workflow", + "app.workflow.actions.add_download -> app.workflow.actions", + "app.workflow.actions.add_subscribe -> app.chain", + "app.workflow.actions.add_subscribe -> app.chain.subscribe", + "app.workflow.actions.add_subscribe -> app.db", + "app.workflow.actions.add_subscribe -> app.db.oper", + "app.workflow.actions.add_subscribe -> app.db.oper.subscribe", + "app.workflow.actions.add_subscribe -> app.domain", + "app.workflow.actions.add_subscribe -> app.domain.context", + "app.workflow.actions.add_subscribe -> app.runtime", + "app.workflow.actions.add_subscribe -> app.runtime.config", + "app.workflow.actions.add_subscribe -> app.runtime.log", + "app.workflow.actions.add_subscribe -> app.schemas", + "app.workflow.actions.add_subscribe -> app.schemas.workflow", + "app.workflow.actions.add_subscribe -> app.workflow", + "app.workflow.actions.add_subscribe -> app.workflow.actions", + "app.workflow.actions.fetch_downloads -> app.runtime", + "app.workflow.actions.fetch_downloads -> app.runtime.config", + "app.workflow.actions.fetch_downloads -> app.runtime.log", + "app.workflow.actions.fetch_downloads -> app.schemas", + "app.workflow.actions.fetch_downloads -> app.schemas.workflow", + "app.workflow.actions.fetch_downloads -> app.workflow", + "app.workflow.actions.fetch_downloads -> app.workflow.actions", + "app.workflow.actions.fetch_medias -> app.adapters", + "app.workflow.actions.fetch_medias -> app.adapters.network", + "app.workflow.actions.fetch_medias -> app.adapters.network.http", + "app.workflow.actions.fetch_medias -> app.chain", + "app.workflow.actions.fetch_medias -> app.chain.recommend", + "app.workflow.actions.fetch_medias -> app.runtime", + "app.workflow.actions.fetch_medias -> app.runtime.config", + "app.workflow.actions.fetch_medias -> app.runtime.events", + "app.workflow.actions.fetch_medias -> app.runtime.log", + "app.workflow.actions.fetch_medias -> app.schemas", + "app.workflow.actions.fetch_medias -> app.schemas.event", + "app.workflow.actions.fetch_medias -> app.schemas.types", + "app.workflow.actions.fetch_medias -> app.schemas.workflow", + "app.workflow.actions.fetch_medias -> app.workflow", + "app.workflow.actions.fetch_medias -> app.workflow.actions", + "app.workflow.actions.fetch_rss -> app.application", + "app.workflow.actions.fetch_rss -> app.application.rss", + "app.workflow.actions.fetch_rss -> app.chain", + "app.workflow.actions.fetch_rss -> app.chain.media", + "app.workflow.actions.fetch_rss -> app.domain", + "app.workflow.actions.fetch_rss -> app.domain.context", + "app.workflow.actions.fetch_rss -> app.domain.metainfo", + "app.workflow.actions.fetch_rss -> app.runtime", + "app.workflow.actions.fetch_rss -> app.runtime.config", + "app.workflow.actions.fetch_rss -> app.runtime.log", + "app.workflow.actions.fetch_rss -> app.schemas", + "app.workflow.actions.fetch_rss -> app.schemas.workflow", + "app.workflow.actions.fetch_rss -> app.workflow", + "app.workflow.actions.fetch_rss -> app.workflow.actions", + "app.workflow.actions.fetch_torrents -> app.chain", + "app.workflow.actions.fetch_torrents -> app.chain.media", + "app.workflow.actions.fetch_torrents -> app.chain.search", + "app.workflow.actions.fetch_torrents -> app.runtime", + "app.workflow.actions.fetch_torrents -> app.runtime.config", + "app.workflow.actions.fetch_torrents -> app.runtime.log", + "app.workflow.actions.fetch_torrents -> app.schemas", + "app.workflow.actions.fetch_torrents -> app.schemas.types", + "app.workflow.actions.fetch_torrents -> app.schemas.workflow", + "app.workflow.actions.fetch_torrents -> app.workflow", + "app.workflow.actions.fetch_torrents -> app.workflow.actions", + "app.workflow.actions.filter_medias -> app.runtime", + "app.workflow.actions.filter_medias -> app.runtime.config", + "app.workflow.actions.filter_medias -> app.runtime.log", + "app.workflow.actions.filter_medias -> app.schemas", + "app.workflow.actions.filter_medias -> app.schemas.workflow", + "app.workflow.actions.filter_medias -> app.workflow", + "app.workflow.actions.filter_medias -> app.workflow.actions", + "app.workflow.actions.filter_torrents -> app.application", + "app.workflow.actions.filter_torrents -> app.application.torrent", + "app.workflow.actions.filter_torrents -> app.runtime", + "app.workflow.actions.filter_torrents -> app.runtime.config", + "app.workflow.actions.filter_torrents -> app.runtime.log", + "app.workflow.actions.filter_torrents -> app.schemas", + "app.workflow.actions.filter_torrents -> app.schemas.workflow", + "app.workflow.actions.filter_torrents -> app.workflow", + "app.workflow.actions.filter_torrents -> app.workflow.actions", + "app.workflow.actions.invoke_plugin -> app.runtime", + "app.workflow.actions.invoke_plugin -> app.runtime.extensions", + "app.workflow.actions.invoke_plugin -> app.runtime.extensions.plugin_manager", + "app.workflow.actions.invoke_plugin -> app.runtime.log", + "app.workflow.actions.invoke_plugin -> app.schemas", + "app.workflow.actions.invoke_plugin -> app.schemas.workflow", + "app.workflow.actions.invoke_plugin -> app.workflow", + "app.workflow.actions.invoke_plugin -> app.workflow.actions", + "app.workflow.actions.note -> app.schemas", + "app.workflow.actions.note -> app.schemas.workflow", + "app.workflow.actions.note -> app.workflow", + "app.workflow.actions.note -> app.workflow.actions", + "app.workflow.actions.scan_file -> app.chain", + "app.workflow.actions.scan_file -> app.chain.storage", + "app.workflow.actions.scan_file -> app.runtime", + "app.workflow.actions.scan_file -> app.runtime.config", + "app.workflow.actions.scan_file -> app.runtime.log", + "app.workflow.actions.scan_file -> app.schemas", + "app.workflow.actions.scan_file -> app.schemas.workflow", + "app.workflow.actions.scan_file -> app.workflow", + "app.workflow.actions.scan_file -> app.workflow.actions", + "app.workflow.actions.scrape_file -> app.chain", + "app.workflow.actions.scrape_file -> app.chain.media", + "app.workflow.actions.scrape_file -> app.chain.scraping", + "app.workflow.actions.scrape_file -> app.chain.storage", + "app.workflow.actions.scrape_file -> app.runtime", + "app.workflow.actions.scrape_file -> app.runtime.config", + "app.workflow.actions.scrape_file -> app.runtime.log", + "app.workflow.actions.scrape_file -> app.schemas", + "app.workflow.actions.scrape_file -> app.schemas.workflow", + "app.workflow.actions.scrape_file -> app.workflow", + "app.workflow.actions.scrape_file -> app.workflow.actions", + "app.workflow.actions.send_event -> app.runtime", + "app.workflow.actions.send_event -> app.runtime.events", + "app.workflow.actions.send_event -> app.schemas", + "app.workflow.actions.send_event -> app.schemas.types", + "app.workflow.actions.send_event -> app.schemas.workflow", + "app.workflow.actions.send_event -> app.workflow", + "app.workflow.actions.send_event -> app.workflow.actions", + "app.workflow.actions.send_message -> app.runtime", + "app.workflow.actions.send_message -> app.runtime.config", + "app.workflow.actions.send_message -> app.schemas", + "app.workflow.actions.send_message -> app.schemas.message", + "app.workflow.actions.send_message -> app.schemas.workflow", + "app.workflow.actions.send_message -> app.workflow", + "app.workflow.actions.send_message -> app.workflow.actions", + "app.workflow.actions.transfer_file -> app.chain", + "app.workflow.actions.transfer_file -> app.chain.storage", + "app.workflow.actions.transfer_file -> app.chain.transfer", + "app.workflow.actions.transfer_file -> app.db", + "app.workflow.actions.transfer_file -> app.db.oper", + "app.workflow.actions.transfer_file -> app.db.oper.transferhistory", + "app.workflow.actions.transfer_file -> app.runtime", + "app.workflow.actions.transfer_file -> app.runtime.config", + "app.workflow.actions.transfer_file -> app.runtime.log", + "app.workflow.actions.transfer_file -> app.schemas", + "app.workflow.actions.transfer_file -> app.schemas.workflow", + "app.workflow.actions.transfer_file -> app.workflow", + "app.workflow.actions.transfer_file -> app.workflow.actions" + ], + "module_count": 707, + "modules": [ + "app", + "app.adapters", + "app.adapters.cache", + "app.adapters.cache.backends", + "app.adapters.cache.redis", + "app.adapters.external", + "app.adapters.external.cookiecloud", + "app.adapters.external.location", + "app.adapters.external.market", + "app.adapters.external.ocr", + "app.adapters.external.plugin", + "app.adapters.external.plugin.client", + "app.adapters.external.server", + "app.adapters.external.wechat_crypt", + "app.adapters.network", + "app.adapters.network.browser", + "app.adapters.network.cloudflare", + "app.adapters.network.doh", + "app.adapters.network.http", + "app.adapters.network.ip", + "app.adapters.system", + "app.adapters.system.display", + "app.adapters.system.display.resource", + "app.adapters.system.fsproxy", + "app.adapters.system.fsworker", + "app.adapters.system.host", + "app.adapters.system.package", + "app.adapters.system.plugin", + "app.adapters.system.plugin.dependency", + "app.adapters.system.plugin.package", + "app.adapters.system.resource", + "app.adapters.system.rust", + "app.adapters.system.stdio", + "app.adapters.web", + "app.adapters.web.plugin", + "app.adapters.web.plugin.routes", + "app.agent", + "app.agent.callback", + "app.agent.capabilities", + "app.agent.capabilities.adapter", + "app.agent.contracts", + "app.agent.llm", + "app.agent.llm.capability", + "app.agent.llm.helper", + "app.agent.llm.provider", + "app.agent.llm.server_tools", + "app.agent.mcp", + "app.agent.memory", + "app.agent.middleware", + "app.agent.middleware.activity_log", + "app.agent.middleware.jobs", + "app.agent.middleware.memory", + "app.agent.middleware.patch_tool_calls", + "app.agent.middleware.policy", + "app.agent.middleware.runtime_config", + "app.agent.middleware.skills", + "app.agent.middleware.subagents", + "app.agent.middleware.summarization", + "app.agent.middleware.tool_selection", + "app.agent.middleware.usage", + "app.agent.middleware.utils", + "app.agent.orchestrator", + "app.agent.policy", + "app.agent.policy.contracts", + "app.agent.policy.orchestrator", + "app.agent.policy.registry", + "app.agent.policy.sanitizer", + "app.agent.policy.secret_fields", + "app.agent.prompt", + "app.agent.prompt.transfer_redo", + "app.agent.runtime", + "app.agent.runtime_loader", + "app.agent.skills", + "app.agent.skills.metadata", + "app.agent.skills.registry", + "app.agent.tools", + "app.agent.tools.base", + "app.agent.tools.catalog", + "app.agent.tools.factory", + "app.agent.tools.impl", + "app.agent.tools.impl._command_safety", + "app.agent.tools.impl._file_write_utils", + "app.agent.tools.impl._filter_rule_utils", + "app.agent.tools.impl._music_utils", + "app.agent.tools.impl._plugin_tool_utils", + "app.agent.tools.impl._system_setting_utils", + "app.agent.tools.impl._terminal_session", + "app.agent.tools.impl._torrent_search_utils", + "app.agent.tools.impl.add_custom_filter_rule", + "app.agent.tools.impl.add_download_tasks", + "app.agent.tools.impl.add_rule_group", + "app.agent.tools.impl.add_subscribe", + "app.agent.tools.impl.apply_patch", + "app.agent.tools.impl.ask_user_choice", + "app.agent.tools.impl.browse_webpage", + "app.agent.tools.impl.create_agent_task", + "app.agent.tools.impl.delete_agent_task", + "app.agent.tools.impl.delete_custom_filter_rule", + "app.agent.tools.impl.delete_download_history", + "app.agent.tools.impl.delete_download_tasks", + "app.agent.tools.impl.delete_rule_group", + "app.agent.tools.impl.delete_subscribe", + "app.agent.tools.impl.delete_transfer_history", + "app.agent.tools.impl.edit_file", + "app.agent.tools.impl.execute_command", + "app.agent.tools.impl.get_recommendations", + "app.agent.tools.impl.get_search_results", + "app.agent.tools.impl.install_plugin", + "app.agent.tools.impl.list_directory", + "app.agent.tools.impl.list_slash_commands", + "app.agent.tools.impl.mcp", + "app.agent.tools.impl.query_agent_tasks", + "app.agent.tools.impl.query_builtin_filter_rules", + "app.agent.tools.impl.query_custom_filter_rules", + "app.agent.tools.impl.query_custom_identifiers", + "app.agent.tools.impl.query_directory_settings", + "app.agent.tools.impl.query_doctor_report", + "app.agent.tools.impl.query_download_tasks", + "app.agent.tools.impl.query_downloaders", + "app.agent.tools.impl.query_episode_schedule", + "app.agent.tools.impl.query_installed_plugins", + "app.agent.tools.impl.query_library_exists", + "app.agent.tools.impl.query_library_latest", + "app.agent.tools.impl.query_market_plugins", + "app.agent.tools.impl.query_media_detail", + "app.agent.tools.impl.query_personas", + "app.agent.tools.impl.query_plugin_capabilities", + "app.agent.tools.impl.query_plugin_config", + "app.agent.tools.impl.query_plugin_data", + "app.agent.tools.impl.query_popular_subscribes", + "app.agent.tools.impl.query_rule_groups", + "app.agent.tools.impl.query_schedulers", + "app.agent.tools.impl.query_site_userdata", + "app.agent.tools.impl.query_sites", + "app.agent.tools.impl.query_subscribe_history", + "app.agent.tools.impl.query_subscribe_shares", + "app.agent.tools.impl.query_subscribes", + "app.agent.tools.impl.query_system_settings", + "app.agent.tools.impl.query_transfer_history", + "app.agent.tools.impl.query_workflows", + "app.agent.tools.impl.read_file", + "app.agent.tools.impl.recognize_captcha", + "app.agent.tools.impl.recognize_media", + "app.agent.tools.impl.reload_plugin", + "app.agent.tools.impl.run_agent_task", + "app.agent.tools.impl.run_scheduler", + "app.agent.tools.impl.run_slash_command", + "app.agent.tools.impl.run_workflow", + "app.agent.tools.impl.scrape_metadata", + "app.agent.tools.impl.search_media", + "app.agent.tools.impl.search_person", + "app.agent.tools.impl.search_person_credits", + "app.agent.tools.impl.search_subscribe", + "app.agent.tools.impl.search_torrents", + "app.agent.tools.impl.search_web", + "app.agent.tools.impl.send_local_file", + "app.agent.tools.impl.send_message", + "app.agent.tools.impl.send_voice_message", + "app.agent.tools.impl.switch_persona", + "app.agent.tools.impl.test_site", + "app.agent.tools.impl.transfer_file", + "app.agent.tools.impl.uninstall_plugin", + "app.agent.tools.impl.update_agent_task", + "app.agent.tools.impl.update_custom_filter_rule", + "app.agent.tools.impl.update_custom_identifiers", + "app.agent.tools.impl.update_download_tasks", + "app.agent.tools.impl.update_persona_definition", + "app.agent.tools.impl.update_plugin_config", + "app.agent.tools.impl.update_rule_group", + "app.agent.tools.impl.update_site", + "app.agent.tools.impl.update_site_cookie", + "app.agent.tools.impl.update_subscribe", + "app.agent.tools.impl.update_system_settings", + "app.agent.tools.impl.write_file", + "app.agent.tools.manager", + "app.agent.tools.tags", + "app.api", + "app.api.apiv1", + "app.api.deps", + "app.api.endpoints", + "app.api.endpoints.agent", + "app.api.endpoints.anilist", + "app.api.endpoints.anthropic", + "app.api.endpoints.auth", + "app.api.endpoints.bangumi", + "app.api.endpoints.dashboard", + "app.api.endpoints.discover", + "app.api.endpoints.douban", + "app.api.endpoints.download", + "app.api.endpoints.history", + "app.api.endpoints.llm", + "app.api.endpoints.login", + "app.api.endpoints.mcp", + "app.api.endpoints.media", + "app.api.endpoints.mediaserver", + "app.api.endpoints.message", + "app.api.endpoints.mfa", + "app.api.endpoints.music", + "app.api.endpoints.notification", + "app.api.endpoints.openai", + "app.api.endpoints.plugin", + "app.api.endpoints.recommend", + "app.api.endpoints.search", + "app.api.endpoints.site", + "app.api.endpoints.storage", + "app.api.endpoints.subscribe", + "app.api.endpoints.system", + "app.api.endpoints.tmdb", + "app.api.endpoints.torrent", + "app.api.endpoints.transfer", + "app.api.endpoints.user", + "app.api.endpoints.webhook", + "app.api.endpoints.workflow", + "app.api.openai_utils", + "app.api.response", + "app.api.router_specs", + "app.api.servarr", + "app.api.servcookie", + "app.application", + "app.application.agent", + "app.application.audio", + "app.application.chain", + "app.application.chain.context", + "app.application.commands", + "app.application.directory", + "app.application.download", + "app.application.download.tasks", + "app.application.downloader", + "app.application.formatting", + "app.application.history", + "app.application.image", + "app.application.maintenance", + "app.application.mediaserver", + "app.application.messaging", + "app.application.messaging.agent", + "app.application.messaging.interaction", + "app.application.messaging.media", + "app.application.messaging.message", + "app.application.messaging.plugin", + "app.application.messaging.router", + "app.application.messaging.session", + "app.application.messaging.site", + "app.application.messaging.skill", + "app.application.messaging.subscribe", + "app.application.music", + "app.application.music.catalog", + "app.application.notification", + "app.application.plugin", + "app.application.plugin.catalog", + "app.application.plugin.config", + "app.application.plugin.install", + "app.application.plugin.routes", + "app.application.plugins", + "app.application.recognition", + "app.application.rss", + "app.application.rules", + "app.application.scheduling", + "app.application.search", + "app.application.search.state", + "app.application.security", + "app.application.security.access", + "app.application.security.auth", + "app.application.security.cookie", + "app.application.security.otp", + "app.application.security.passkey", + "app.application.security.twofactor", + "app.application.security.url", + "app.application.server", + "app.application.server.report", + "app.application.server.share", + "app.application.site", + "app.application.site.mutation", + "app.application.storage", + "app.application.subscribe", + "app.application.subscription", + "app.application.subscription.contract", + "app.application.subscription.delete", + "app.application.subscription.identity", + "app.application.subscription.query", + "app.application.subscription.search", + "app.application.torrent", + "app.application.transfer", + "app.application.workflow", + "app.chain", + "app.chain._interaction", + "app.chain._messaging", + "app.chain._music", + "app.chain._recognition", + "app.chain._transfer", + "app.chain.acoustid", + "app.chain.agent", + "app.chain.anilist", + "app.chain.bangumi", + "app.chain.dashboard", + "app.chain.douban", + "app.chain.download", + "app.chain.interaction", + "app.chain.listenbrainz", + "app.chain.lrclib", + "app.chain.media", + "app.chain.mediaserver", + "app.chain.message", + "app.chain.musicbrainz", + "app.chain.notification", + "app.chain.recommend", + "app.chain.scraping", + "app.chain.search", + "app.chain.site", + "app.chain.storage", + "app.chain.subscribe", + "app.chain.system", + "app.chain.theaudiodb", + "app.chain.tmdb", + "app.chain.torrents", + "app.chain.transfer", + "app.chain.tvdb", + "app.chain.user", + "app.chain.webhook", + "app.chain.workflow", + "app.cli", + "app.command", + "app.db", + "app.db.base", + "app.db.decorators", + "app.db.diagnostics", + "app.db.engine", + "app.db.maintenance", + "app.db.models", + "app.db.models._constraints", + "app.db.models._identity", + "app.db.models.agentchat", + "app.db.models.agenttask", + "app.db.models.agenttaskrun", + "app.db.models.downloadfailure", + "app.db.models.downloadhistory", + "app.db.models.mediaserver", + "app.db.models.message", + "app.db.models.passkey", + "app.db.models.plugindata", + "app.db.models.site", + "app.db.models.siteicon", + "app.db.models.sitestatistic", + "app.db.models.siteuserdata", + "app.db.models.subscribe", + "app.db.models.subscribehistory", + "app.db.models.systemconfig", + "app.db.models.transferhistory", + "app.db.models.transferpending", + "app.db.models.user", + "app.db.models.userconfig", + "app.db.models.workflow", + "app.db.oper", + "app.db.oper.agentchat", + "app.db.oper.agenttask", + "app.db.oper.downloadfailure", + "app.db.oper.downloadhistory", + "app.db.oper.mediaserver", + "app.db.oper.message", + "app.db.oper.plugindata", + "app.db.oper.site", + "app.db.oper.subscribe", + "app.db.oper.subscribehistory", + "app.db.oper.systemconfig", + "app.db.oper.transferhistory", + "app.db.oper.transferpending", + "app.db.oper.user", + "app.db.oper.userconfig", + "app.db.oper.workflow", + "app.db.session", + "app.db.uow", + "app.doctor", + "app.doctor.checks", + "app.doctor.formatters", + "app.doctor.models", + "app.doctor.runner", + "app.domain", + "app.domain.context", + "app.domain.episode", + "app.domain.media", + "app.domain.meta", + "app.domain.meta.customization", + "app.domain.meta.infopath", + "app.domain.meta.metaanime", + "app.domain.meta.metabase", + "app.domain.meta.metamusic", + "app.domain.meta.metavideo", + "app.domain.meta.releasegroup", + "app.domain.meta.runtime", + "app.domain.meta.streamingplatform", + "app.domain.meta.words", + "app.domain.metainfo", + "app.domain.scraper", + "app.domain.site", + "app.domain.title", + "app.domain.tokens", + "app.domain.torrent", + "app.factory", + "app.foundation", + "app.foundation.collections", + "app.foundation.crypto", + "app.foundation.dom", + "app.foundation.identity", + "app.foundation.reflection", + "app.foundation.singleton", + "app.foundation.size", + "app.foundation.temporal", + "app.foundation.text", + "app.foundation.url", + "app.foundation.version", + "app.main", + "app.modules", + "app.modules._base", + "app.modules._base.downloader", + "app.modules._base.mediaserver", + "app.modules._base.notification", + "app.modules.acoustid", + "app.modules.anilist", + "app.modules.anilist.anilist", + "app.modules.bangumi", + "app.modules.bangumi.bangumi", + "app.modules.discord", + "app.modules.discord.discord", + "app.modules.douban", + "app.modules.douban.apiv2", + "app.modules.douban.scraper", + "app.modules.emby", + "app.modules.emby.emby", + "app.modules.fanart", + "app.modules.feishu", + "app.modules.feishu.feishu", + "app.modules.filemanager", + "app.modules.filemanager.module", + "app.modules.filemanager.storages", + "app.modules.filemanager.storages.alipan", + "app.modules.filemanager.storages.alist", + "app.modules.filemanager.storages.alistgo", + "app.modules.filemanager.storages.local", + "app.modules.filemanager.storages.rclone", + "app.modules.filemanager.storages.smb", + "app.modules.filemanager.storages.u115", + "app.modules.filemanager.transhandler", + "app.modules.filter", + "app.modules.indexer", + "app.modules.indexer.parser", + "app.modules.indexer.parser.bitpt", + "app.modules.indexer.parser.discuz", + "app.modules.indexer.parser.file_list", + "app.modules.indexer.parser.gazelle", + "app.modules.indexer.parser.hddolby", + "app.modules.indexer.parser.ipt_project", + "app.modules.indexer.parser.mtorrent", + "app.modules.indexer.parser.nexus_audiences", + "app.modules.indexer.parser.nexus_hhanclub", + "app.modules.indexer.parser.nexus_php", + "app.modules.indexer.parser.nexus_project", + "app.modules.indexer.parser.nexus_rabbit", + "app.modules.indexer.parser.rousi", + "app.modules.indexer.parser.small_horse", + "app.modules.indexer.parser.sunnypt", + "app.modules.indexer.parser.tnode", + "app.modules.indexer.parser.torrent_leech", + "app.modules.indexer.parser.unit3d", + "app.modules.indexer.parser.yema", + "app.modules.indexer.parser.zhixing", + "app.modules.indexer.spider", + "app.modules.indexer.spider.haidan", + "app.modules.indexer.spider.hddolby", + "app.modules.indexer.spider.mtorrent", + "app.modules.indexer.spider.rousi", + "app.modules.indexer.spider.sunnypt", + "app.modules.indexer.spider.tnode", + "app.modules.indexer.spider.torrentleech", + "app.modules.indexer.spider.yema", + "app.modules.jellyfin", + "app.modules.jellyfin.jellyfin", + "app.modules.listenbrainz", + "app.modules.lrclib", + "app.modules.musicbrainz", + "app.modules.musicbrainz.music_cache", + "app.modules.navidrome", + "app.modules.navidrome.navidrome", + "app.modules.plex", + "app.modules.plex.plex", + "app.modules.postgresql", + "app.modules.qbittorrent", + "app.modules.qbittorrent.qbittorrent", + "app.modules.qqbot", + "app.modules.qqbot.api", + "app.modules.qqbot.gateway", + "app.modules.qqbot.qqbot", + "app.modules.redis", + "app.modules.rtorrent", + "app.modules.rtorrent.rtorrent", + "app.modules.slack", + "app.modules.slack.slack", + "app.modules.subtitle", + "app.modules.synologychat", + "app.modules.synologychat.synologychat", + "app.modules.telegram", + "app.modules.telegram.compat", + "app.modules.telegram.telegram", + "app.modules.theaudiodb", + "app.modules.themoviedb", + "app.modules.themoviedb.category", + "app.modules.themoviedb.scraper", + "app.modules.themoviedb.tmdb_cache", + "app.modules.themoviedb.tmdbapi", + "app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.as_obj", + "app.modules.themoviedb.tmdbv3api.exceptions", + "app.modules.themoviedb.tmdbv3api.objs", + "app.modules.themoviedb.tmdbv3api.objs.account", + "app.modules.themoviedb.tmdbv3api.objs.auth", + "app.modules.themoviedb.tmdbv3api.objs.certification", + "app.modules.themoviedb.tmdbv3api.objs.change", + "app.modules.themoviedb.tmdbv3api.objs.collection", + "app.modules.themoviedb.tmdbv3api.objs.company", + "app.modules.themoviedb.tmdbv3api.objs.configuration", + "app.modules.themoviedb.tmdbv3api.objs.credit", + "app.modules.themoviedb.tmdbv3api.objs.discover", + "app.modules.themoviedb.tmdbv3api.objs.episode", + "app.modules.themoviedb.tmdbv3api.objs.find", + "app.modules.themoviedb.tmdbv3api.objs.genre", + "app.modules.themoviedb.tmdbv3api.objs.group", + "app.modules.themoviedb.tmdbv3api.objs.keyword", + "app.modules.themoviedb.tmdbv3api.objs.list", + "app.modules.themoviedb.tmdbv3api.objs.movie", + "app.modules.themoviedb.tmdbv3api.objs.network", + "app.modules.themoviedb.tmdbv3api.objs.person", + "app.modules.themoviedb.tmdbv3api.objs.provider", + "app.modules.themoviedb.tmdbv3api.objs.review", + "app.modules.themoviedb.tmdbv3api.objs.search", + "app.modules.themoviedb.tmdbv3api.objs.season", + "app.modules.themoviedb.tmdbv3api.objs.trending", + "app.modules.themoviedb.tmdbv3api.objs.tv", + "app.modules.themoviedb.tmdbv3api.tmdb", + "app.modules.thetvdb", + "app.modules.thetvdb.tvdb_v4_official", + "app.modules.transmission", + "app.modules.transmission.transmission", + "app.modules.trimemedia", + "app.modules.trimemedia.api", + "app.modules.trimemedia.trimemedia", + "app.modules.ugreen", + "app.modules.ugreen.api", + "app.modules.ugreen.crypto", + "app.modules.ugreen.ugreen", + "app.modules.vocechat", + "app.modules.vocechat.vocechat", + "app.modules.webpush", + "app.modules.wechat", + "app.modules.wechat.wechat", + "app.modules.wechat.wechatbot", + "app.modules.wechatclawbot", + "app.modules.wechatclawbot.wechatclawbot", + "app.modules.zspace", + "app.modules.zspace.zspace", + "app.monitor", + "app.monitor.dispatcher", + "app.monitor.monitor", + "app.monitor.poller", + "app.monitor.recovery", + "app.monitor.snapshot", + "app.monitor.syslimits", + "app.monitor.watcher", + "app.runtime", + "app.runtime.cache", + "app.runtime.capabilities", + "app.runtime.capabilities.errors", + "app.runtime.capabilities.model", + "app.runtime.capabilities.registry", + "app.runtime.capabilities.runtime", + "app.runtime.coalesce", + "app.runtime.compat", + "app.runtime.compat.diagnostics", + "app.runtime.compat.imports", + "app.runtime.compat.manifest", + "app.runtime.compat.resource_imports", + "app.runtime.config", + "app.runtime.debounce", + "app.runtime.event", + "app.runtime.event.binding", + "app.runtime.event.dispatch", + "app.runtime.event.errors", + "app.runtime.event.registry", + "app.runtime.events", + "app.runtime.execution", + "app.runtime.extensions", + "app.runtime.extensions.host_module_adapter", + "app.runtime.extensions.managed_resource_adapter", + "app.runtime.extensions.module", + "app.runtime.extensions.module.contracts", + "app.runtime.extensions.module.dispatcher", + "app.runtime.extensions.module_manager", + "app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.contracts", + "app.runtime.extensions.plugin.projection", + "app.runtime.extensions.plugin.registry", + "app.runtime.extensions.plugin.storage", + "app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin_manager", + "app.runtime.extensions.service_config", + "app.runtime.extensions.service_registry", + "app.runtime.gc", + "app.runtime.localization", + "app.runtime.log", + "app.runtime.managed_resources", + "app.runtime.progress", + "app.runtime.rate", + "app.runtime.reload", + "app.runtime.scheduling", + "app.runtime.state", + "app.runtime.thread", + "app.scheduler", + "app.schemas", + "app.schemas.agent", + "app.schemas.cache", + "app.schemas.category", + "app.schemas.common", + "app.schemas.context", + "app.schemas.dashboard", + "app.schemas.download", + "app.schemas.event", + "app.schemas.exception", + "app.schemas.exports", + "app.schemas.file", + "app.schemas.history", + "app.schemas.llm", + "app.schemas.mcp", + "app.schemas.media", + "app.schemas.mediaserver", + "app.schemas.message", + "app.schemas.mfa", + "app.schemas.monitoring", + "app.schemas.music", + "app.schemas.notification", + "app.schemas.openai", + "app.schemas.plugin", + "app.schemas.response", + "app.schemas.rule", + "app.schemas.search", + "app.schemas.servarr", + "app.schemas.servcookie", + "app.schemas.site", + "app.schemas.storage", + "app.schemas.subscribe", + "app.schemas.system", + "app.schemas.tmdb", + "app.schemas.token", + "app.schemas.transfer", + "app.schemas.types", + "app.schemas.user", + "app.schemas.workflow", + "app.sdk", + "app.sdk._legacy", + "app.sdk._legacy.history", + "app.sdk._legacy.subscribe", + "app.sdk._legacy.transfer", + "app.sdk._legacy.user", + "app.sdk.browser", + "app.sdk.cache", + "app.sdk.config", + "app.sdk.events", + "app.sdk.logging", + "app.sdk.media", + "app.sdk.network", + "app.sdk.plugins", + "app.sdk.services", + "app.sdk.string", + "app.sdk.utilities", + "app.startup", + "app.startup.agent_initializer", + "app.startup.cache_initializer", + "app.startup.command_initializer", + "app.startup.database_initializer", + "app.startup.domain_initializer", + "app.startup.lifecycle", + "app.startup.lifecycle.components", + "app.startup.managed_resources_initializer", + "app.startup.modules_initializer", + "app.startup.monitor_initializer", + "app.startup.plugins_initializer", + "app.startup.routers_initializer", + "app.startup.scheduler_initializer", + "app.startup.transfer_initializer", + "app.startup.workflow_initializer", + "app.testing", + "app.testing.bootstrap", + "app.testing.network_guard", + "app.testing.stub", + "app.workflow", + "app.workflow.actions", + "app.workflow.actions.add_download", + "app.workflow.actions.add_subscribe", + "app.workflow.actions.fetch_downloads", + "app.workflow.actions.fetch_medias", + "app.workflow.actions.fetch_rss", + "app.workflow.actions.fetch_torrents", + "app.workflow.actions.filter_medias", + "app.workflow.actions.filter_torrents", + "app.workflow.actions.invoke_plugin", + "app.workflow.actions.note", + "app.workflow.actions.scan_file", + "app.workflow.actions.scrape_file", + "app.workflow.actions.send_event", + "app.workflow.actions.send_message", + "app.workflow.actions.transfer_file" + ], + "schema_version": 1, + "scope": "MoviePilot host app excluding app/plugins", + "strongly_connected_components": [ + [ + "app.agent.llm", + "app.agent.llm.capability", + "app.agent.llm.helper", + "app.agent.llm.provider" + ], + [ + "app.agent.policy", + "app.agent.policy.orchestrator", + "app.agent.policy.registry", + "app.agent.policy.sanitizer" + ], + [ + "app.doctor", + "app.doctor.checks", + "app.doctor.runner" + ], + [ + "app.modules.qqbot", + "app.modules.qqbot.qqbot" + ], + [ + "app.modules.telegram", + "app.modules.telegram.telegram" + ], + [ + "app.modules.themoviedb", + "app.modules.themoviedb.scraper", + "app.modules.themoviedb.tmdbapi", + "app.modules.themoviedb.tmdbv3api", + "app.modules.themoviedb.tmdbv3api.objs.account", + "app.modules.themoviedb.tmdbv3api.objs.auth", + "app.modules.themoviedb.tmdbv3api.objs.certification", + "app.modules.themoviedb.tmdbv3api.objs.change", + "app.modules.themoviedb.tmdbv3api.objs.collection", + "app.modules.themoviedb.tmdbv3api.objs.company", + "app.modules.themoviedb.tmdbv3api.objs.configuration", + "app.modules.themoviedb.tmdbv3api.objs.credit", + "app.modules.themoviedb.tmdbv3api.objs.discover", + "app.modules.themoviedb.tmdbv3api.objs.episode", + "app.modules.themoviedb.tmdbv3api.objs.find", + "app.modules.themoviedb.tmdbv3api.objs.genre", + "app.modules.themoviedb.tmdbv3api.objs.group", + "app.modules.themoviedb.tmdbv3api.objs.keyword", + "app.modules.themoviedb.tmdbv3api.objs.list", + "app.modules.themoviedb.tmdbv3api.objs.movie", + "app.modules.themoviedb.tmdbv3api.objs.network", + "app.modules.themoviedb.tmdbv3api.objs.person", + "app.modules.themoviedb.tmdbv3api.objs.provider", + "app.modules.themoviedb.tmdbv3api.objs.review", + "app.modules.themoviedb.tmdbv3api.objs.search", + "app.modules.themoviedb.tmdbv3api.objs.season", + "app.modules.themoviedb.tmdbv3api.objs.trending", + "app.modules.themoviedb.tmdbv3api.objs.tv", + "app.modules.themoviedb.tmdbv3api.tmdb" + ], + [ + "app.modules.trimemedia", + "app.modules.trimemedia.trimemedia" + ], + [ + "app.modules.ugreen", + "app.modules.ugreen.api", + "app.modules.ugreen.ugreen" + ], + [ + "app.monitor", + "app.monitor.monitor", + "app.monitor.poller" + ] + ] +} diff --git a/tests/fixtures/architecture/official-plugin-baseline.json b/tests/fixtures/architecture/official-plugin-baseline.json new file mode 100644 index 000000000..37359c022 --- /dev/null +++ b/tests/fixtures/architecture/official-plugin-baseline.json @@ -0,0 +1,4843 @@ +{ + "api_routes": { + "plugins.v2/agentresourceofficer/__init__.py": [ + { + "auth": null, + "endpoint": "api_assistant_action", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/action", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_actions", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/actions", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_capabilities", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/capabilities", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_cookie_update", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/cookie/update", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_history", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/history", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_maintain", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/assistant/maintain", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_pick", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/pick", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_plan_execute", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/plan/execute", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_plans", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/plans", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_plans_clear", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/plans/clear", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_preferences", + "endpoint_return": null, + "methods": [ + "GET", + "POST", + "DELETE" + ], + "path": "/assistant/preferences", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_pulse", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/pulse", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_readiness", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/readiness", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_recover", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/assistant/recover", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_request_templates", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/assistant/request_templates", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_route", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/route", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_selfcheck", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/selfcheck", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_session_state", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/assistant/session", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_session_clear", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/session/clear", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_sessions", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/sessions", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_sessions_clear", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/sessions/clear", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_startup", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/startup", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_toolbox", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/toolbox", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_workflow", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/assistant/workflow", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_config_get", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/config/get", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_config_save", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/config/save", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_feishu_health", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/feishu/health", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_account", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/account", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_checkin", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/hdhive/checkin", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_checkin_history", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/checkin/history", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_health", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/health", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_quota", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/quota", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_search", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/hdhive/search", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_search_by_keyword", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/hdhive/search_by_keyword", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_unlock", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/hdhive/unlock", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_unlock_and_route", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/hdhive/unlock_and_route", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_usage_today", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/usage_today", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_weekly_free_quota", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/weekly_free_quota", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_health", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/health", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_pending", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/p115/pending", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_pending_cancel", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/p115/pending/cancel", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_pending_resume", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/p115/pending/resume", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_qrcode", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/qrcode", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_qrcode_check", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/qrcode/check", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_qrcode_page", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/qrcode/page", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_qrcode_page_check", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/qrcode/page/check", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_qrcode_page_refresh", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/qrcode/page/refresh", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_transfer", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/p115/transfer", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_p115_ui_health", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/ui/health", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_p115_ui_qrcode", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/ui/qrcode", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_p115_ui_qrcode_check", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/ui/qrcode/check", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_quark_health", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/quark/health", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_quark_transfer", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/quark/transfer", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_session_hdhive_pick", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/session/hdhive/pick", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_session_hdhive_search", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/session/hdhive/search", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_share_route", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/share/route", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/agenttokens/__init__.py": [ + { + "auth": "'bear'", + "endpoint": "save_config_api", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/config", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "get_status", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/status", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "reset_usage_api", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/usage/reset", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "reset_all_usage_api", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/usage/reset_all", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/airecognizerenhancer/__init__.py": [ + { + "auth": null, + "endpoint": "api_apply_identifiers", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/apply_identifiers", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_apply_suggested_identifier", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/apply_suggested_identifier", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_apply_suggested_identifiers_for_failed_samples", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/apply_suggested_identifiers_for_failed_samples", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_clear_failed_samples", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/clear_failed_samples", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_clear_llm_errors", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/clear_llm_errors", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_failed_samples", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/failed_samples", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_health", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/health", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_llm_errors", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/llm_errors", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_recognize", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/recognize", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_remove_failed_sample", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/remove_failed_sample", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_replay_failed_sample", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/replay_failed_sample", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_replay_failed_samples", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/replay_failed_samples", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_sample_brief", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/sample_brief", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_sample_insights", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/sample_insights", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_sample_worklist", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/sample_worklist", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_suggest_identifiers", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/suggest_identifiers", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_suggest_identifiers_for_failed_samples", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/suggest_identifiers_for_failed_samples", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_suggest_identifiers_from_sample", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/suggest_identifiers_from_sample", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/animeupscale/__init__.py": [ + { + "auth": "'bear'", + "endpoint": "api_create_jobs", + "endpoint_return": "JSONResponse", + "methods": [ + "POST" + ], + "path": "/jobs", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_jobs", + "endpoint_return": "JSONResponse", + "methods": [ + "GET" + ], + "path": "/jobs", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_cancel_job", + "endpoint_return": "JSONResponse", + "methods": [ + "POST" + ], + "path": "/jobs/cancel", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_retry_job", + "endpoint_return": "JSONResponse", + "methods": [ + "POST" + ], + "path": "/jobs/retry", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_download_model", + "endpoint_return": "JSONResponse", + "methods": [ + "POST" + ], + "path": "/models/download", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_verify_models", + "endpoint_return": "JSONResponse", + "methods": [ + "POST" + ], + "path": "/models/verify", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_status", + "endpoint_return": "JSONResponse", + "methods": [ + "GET" + ], + "path": "/status", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/autoauction/__init__.py": [ + { + "auth": "'bear'", + "endpoint": "create_listing", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "POST" + ], + "path": "/create", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "get_listings", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "GET" + ], + "path": "/list", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "run_all_tasks", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "POST" + ], + "path": "/run", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/autosignin/__init__.py": [ + { + "auth": null, + "endpoint": "signin_by_domain", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/signin_by_domain", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/brushflow/__init__.py": [ + { + "auth": "'bear'", + "endpoint": "update_settings", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/settings", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "get_status", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/status", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "create_task", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/tasks", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "delete_task", + "endpoint_return": "schemas.Response", + "methods": [ + "DELETE" + ], + "path": "/tasks/{task_id}", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "get_task_detail", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/tasks/{task_id}", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "update_task", + "endpoint_return": "schemas.Response", + "methods": [ + "PUT" + ], + "path": "/tasks/{task_id}", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "check_task", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/tasks/{task_id}/check", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "clear_task_data", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/tasks/{task_id}/clear", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "run_task", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/tasks/{task_id}/run", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "update_task_state", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/tasks/{task_id}/state", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/chatgpt/__init__.py": [ + { + "auth": "'bear'", + "endpoint": "get_cache_stats", + "endpoint_return": "dict", + "methods": [ + "GET" + ], + "path": "/cache_stats", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "clear_cache", + "endpoint_return": "dict", + "methods": [ + "GET" + ], + "path": "/clear_cache", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "clear_usage_stats", + "endpoint_return": "dict", + "methods": [ + "GET" + ], + "path": "/clear_usage_stats", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "get_usage_stats", + "endpoint_return": "dict", + "methods": [ + "GET" + ], + "path": "/usage_stats", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/dailysummary/__init__.py": [ + { + "auth": null, + "endpoint": "_api_clear_history", + "endpoint_return": "dict", + "methods": [ + "POST" + ], + "path": "/clear_history", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/doubanrank/__init__.py": [ + { + "auth": null, + "endpoint": "delete_history", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/delete_history", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/doubansync/__init__.py": [ + { + "auth": null, + "endpoint": "delete_history", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/delete_history", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/fullscreenposterwall/__init__.py": [ + { + "auth": "'bear'", + "endpoint": "api_get_config", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "GET" + ], + "path": "/config", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_update_config", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "PUT" + ], + "path": "/config", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_proxy_image", + "endpoint_return": "Any", + "methods": [ + "GET" + ], + "path": "/img", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_lan_info", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "GET" + ], + "path": "/lan-info", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_lan_wall", + "endpoint_return": "Any", + "methods": [ + "GET" + ], + "path": "/lan-wall", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_public_data", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "GET" + ], + "path": "/public-data", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_get_recommend", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "GET" + ], + "path": "/recommend", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_get_sources", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "GET" + ], + "path": "/sources", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/hrblocker/__init__.py": [ + { + "auth": null, + "endpoint": "api_records", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "GET" + ], + "path": "/records", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_clear_records", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "POST" + ], + "path": "/records/clear", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_status", + "endpoint_return": "Dict[str, Any]", + "methods": [ + "GET" + ], + "path": "/status", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/imdbsource/__init__.py": [ + { + "auth": "'bear'", + "endpoint": "imdb_discover", + "endpoint_return": "List[schemas.MediaInfo]", + "methods": [ + "GET" + ], + "path": "/imdb-discover", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "imdb_top_250", + "endpoint_return": "List[schemas.MediaInfo]", + "methods": [ + "GET" + ], + "path": "/imdb-top-250", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "imdb_trending", + "endpoint_return": "List[schemas.MediaInfo]", + "methods": [ + "GET" + ], + "path": "/imdb-trending", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "trending", + "endpoint_return": "List[schemas.MediaInfo]", + "methods": [ + "GET" + ], + "path": "/trending", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/lexiannot/__init__.py": [ + { + "auth": null, + "endpoint": "task_interface", + "endpoint_return": "Response", + "methods": [ + "POST" + ], + "path": "/tasks", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/oidcauth/__init__.py": [ + { + "auth": null, + "endpoint": "authorize", + "endpoint_return": "RedirectResponse", + "methods": [ + "GET" + ], + "path": "/authorize", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "bind_start", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/bind/start", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "callback", + "endpoint_return": "HTMLResponse", + "methods": [ + "GET" + ], + "path": "/callback", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "save_config_api", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/config", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "public_status", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/public/status", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "status", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/status", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "test_api", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/test", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "unbind", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/unbind", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/rsssubscribe/__init__.py": [ + { + "auth": null, + "endpoint": "delete_history", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/delete_history", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/sitestatistic/__init__.py": [ + { + "auth": null, + "endpoint": "refresh_by_domain", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/refresh_by_domain", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/tobypasstrackers/__init__.py": [ + { + "auth": null, + "endpoint": "bypassed_ips", + "endpoint_return": "Response", + "methods": [ + "GET" + ], + "path": "/bypassed_ips", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/traktcleaner/__init__.py": [ + { + "auth": null, + "endpoint": "__authorize_url_api", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/authorize_url", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "__clean_now_api", + "endpoint_return": "dict", + "methods": [ + "GET" + ], + "path": "/clean_now", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "__clear_seen_api", + "endpoint_return": "dict", + "methods": [ + "GET" + ], + "path": "/clear_seen", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/tvdbdiscover/__init__.py": [ + { + "auth": null, + "endpoint": "tvdb_discover", + "endpoint_return": "List[schemas.MediaInfo]", + "methods": [ + "GET" + ], + "path": "/tvdb_discover", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/tvfirstwatch/__init__.py": [ + { + "auth": null, + "endpoint": "_clear_history", + "endpoint_return": "dict", + "methods": [ + "GET" + ], + "path": "/clear_history", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "_storage_status", + "endpoint_return": "dict", + "methods": [ + "GET" + ], + "path": "/storage_status", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/updatewechatip/__init__.py": [ + { + "auth": "'apikey'", + "endpoint": "UpdateIp", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/UpdateIP", + "response_class": null, + "response_model": null + }, + { + "auth": "'apikey'", + "endpoint": "get_img", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/img/{uuid}", + "response_class": null, + "response_model": null + } + ], + "plugins.v2/wechatclawbot/__init__.py": [ + { + "auth": null, + "endpoint": "logout", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/logout", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "get_logs", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/logs", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "clear_logs", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/logs/clear", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "get_qrcode", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/qrcode", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "get_qrcode_image", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/qrcode/image", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "get_status", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/status", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "test_connection_api", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/test_connection", + "response_class": null, + "response_model": null + } + ], + "plugins.v3/agentresourceofficer/__init__.py": [ + { + "auth": null, + "endpoint": "api_assistant_action", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/action", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_actions", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/actions", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_capabilities", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/capabilities", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_cookie_update", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/cookie/update", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_history", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/history", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_maintain", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/assistant/maintain", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_pick", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/pick", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_plan_execute", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/plan/execute", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_plans", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/plans", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_plans_clear", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/plans/clear", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_preferences", + "endpoint_return": null, + "methods": [ + "GET", + "POST", + "DELETE" + ], + "path": "/assistant/preferences", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_pulse", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/pulse", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_readiness", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/readiness", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_recover", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/assistant/recover", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_request_templates", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/assistant/request_templates", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_route", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/route", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_selfcheck", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/selfcheck", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_session_state", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/assistant/session", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_session_clear", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/session/clear", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_sessions", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/sessions", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_sessions_clear", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/assistant/sessions/clear", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_startup", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/startup", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_toolbox", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/assistant/toolbox", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_assistant_workflow", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/assistant/workflow", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_config_get", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/config/get", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_config_save", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/config/save", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_feishu_health", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/feishu/health", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_account", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/account", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_checkin", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/hdhive/checkin", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_checkin_history", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/checkin/history", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_health", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/health", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_quota", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/quota", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_search", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/hdhive/search", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_search_by_keyword", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/hdhive/search_by_keyword", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_unlock", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/hdhive/unlock", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_unlock_and_route", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/hdhive/unlock_and_route", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_usage_today", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/usage_today", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_hdhive_weekly_free_quota", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/hdhive/weekly_free_quota", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_health", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/health", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_pending", + "endpoint_return": null, + "methods": [ + "GET", + "POST" + ], + "path": "/p115/pending", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_pending_cancel", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/p115/pending/cancel", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_pending_resume", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/p115/pending/resume", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_qrcode", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/qrcode", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_qrcode_check", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/qrcode/check", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_qrcode_page", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/qrcode/page", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_qrcode_page_check", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/qrcode/page/check", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_qrcode_page_refresh", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/qrcode/page/refresh", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_p115_transfer", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/p115/transfer", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_p115_ui_health", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/ui/health", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_p115_ui_qrcode", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/ui/qrcode", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_p115_ui_qrcode_check", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/p115/ui/qrcode/check", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_quark_health", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/quark/health", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_quark_transfer", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/quark/transfer", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_session_hdhive_pick", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/session/hdhive/pick", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_session_hdhive_search", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/session/hdhive/search", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "api_share_route", + "endpoint_return": null, + "methods": [ + "POST" + ], + "path": "/share/route", + "response_class": null, + "response_model": null + } + ], + "plugins.v3/animeupscale/__init__.py": [ + { + "auth": "'bear'", + "endpoint": "api_create_jobs", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/jobs", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_jobs", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/jobs", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_cancel_job", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/jobs/cancel", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_retry_job", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/jobs/retry", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_download_model", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/models/download", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_verify_models", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/models/verify", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "api_status", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/status", + "response_class": null, + "response_model": null + } + ], + "plugins.v3/brushflow/__init__.py": [ + { + "auth": "'bear'", + "endpoint": "update_settings", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/settings", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "get_status", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/status", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "create_task", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/tasks", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "delete_task", + "endpoint_return": "schemas.Response", + "methods": [ + "DELETE" + ], + "path": "/tasks/{task_id}", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "get_task_detail", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/tasks/{task_id}", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "update_task", + "endpoint_return": "schemas.Response", + "methods": [ + "PUT" + ], + "path": "/tasks/{task_id}", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "check_task", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/tasks/{task_id}/check", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "clear_task_data", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/tasks/{task_id}/clear", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "run_task", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/tasks/{task_id}/run", + "response_class": null, + "response_model": null + }, + { + "auth": "'bear'", + "endpoint": "update_task_state", + "endpoint_return": "schemas.Response", + "methods": [ + "POST" + ], + "path": "/tasks/{task_id}/state", + "response_class": null, + "response_model": null + } + ], + "plugins.v3/doubanrank/__init__.py": [ + { + "auth": null, + "endpoint": "delete_history", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/delete_history", + "response_class": null, + "response_model": null + } + ], + "plugins.v3/doubansync/__init__.py": [ + { + "auth": null, + "endpoint": "delete_history", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/delete_history", + "response_class": null, + "response_model": null + } + ], + "plugins.v3/episodegroupmeta/__init__.py": [ + { + "auth": null, + "endpoint": "delete_media_database", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/delete_media_database", + "response_class": null, + "response_model": null + }, + { + "auth": null, + "endpoint": "go_start_rt", + "endpoint_return": "schemas.Response", + "methods": [ + "GET" + ], + "path": "/start_rt", + "response_class": null, + "response_model": null + } + ], + "plugins.v3/imdbsource/__init__.py": [ + { + "auth": "'bear'", + "endpoint": "imdb_discover", + "endpoint_return": "schemas.Response[List[schemas.MediaInfo]]", + "methods": [ + "GET" + ], + "path": "/imdb-discover", + "response_class": null, + "response_model": "schemas.Response[List[schemas.MediaInfo]]" + }, + { + "auth": "'bear'", + "endpoint": "imdb_top_250", + "endpoint_return": "schemas.Response[List[schemas.MediaInfo]]", + "methods": [ + "GET" + ], + "path": "/imdb-top-250", + "response_class": null, + "response_model": "schemas.Response[List[schemas.MediaInfo]]" + }, + { + "auth": "'bear'", + "endpoint": "imdb_trending", + "endpoint_return": "schemas.Response[List[schemas.MediaInfo]]", + "methods": [ + "GET" + ], + "path": "/imdb-trending", + "response_class": null, + "response_model": "schemas.Response[List[schemas.MediaInfo]]" + }, + { + "auth": "'bear'", + "endpoint": "trending", + "endpoint_return": "schemas.Response[List[schemas.MediaInfo]]", + "methods": [ + "GET" + ], + "path": "/trending", + "response_class": null, + "response_model": "schemas.Response[List[schemas.MediaInfo]]" + } + ], + "plugins.v3/rsssubscribe/__init__.py": [ + { + "auth": null, + "endpoint": "delete_history", + "endpoint_return": null, + "methods": [ + "GET" + ], + "path": "/delete_history", + "response_class": null, + "response_model": null + } + ], + "plugins.v3/tvdbdiscover/__init__.py": [ + { + "auth": null, + "endpoint": "tvdb_discover", + "endpoint_return": "schemas.Response[List[schemas.MediaInfo]]", + "methods": [ + "GET" + ], + "path": "/tvdb_discover", + "response_class": null, + "response_model": "schemas.Response[List[schemas.MediaInfo]]" + } + ] + }, + "hooks": { + "get_actions": { + "file_count": 0, + "files": [] + }, + "get_agent_tools": { + "file_count": 3, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py" + ] + }, + "get_api": { + "file_count": 81, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/animeupscale/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/bangumiproxy/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/bugreporter/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/goldprice/__init__.py", + "plugins.v2/historytov2/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/multiclass/__init__.py", + "plugins.v2/notifyimage/__init__.py", + "plugins.v2/oidcauth/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/storagecleanup/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/synccookiecloud/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wxpusher/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/animeupscale/__init__.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "get_auth_provider": { + "file_count": 0, + "files": [] + }, + "get_command": { + "file_count": 79, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/animeupscale/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/bangumiproxy/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/bugreporter/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/goldprice/__init__.py", + "plugins.v2/historytov2/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/notifyimage/__init__.py", + "plugins.v2/oidcauth/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/storagecleanup/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/synccookiecloud/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wxpusher/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/animeupscale/__init__.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "get_dashboard": { + "file_count": 10, + "files": [ + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/imdbsource/__init__.py" + ] + }, + "get_form": { + "file_count": 81, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/animeupscale/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/bangumiproxy/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/bugreporter/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/goldprice/__init__.py", + "plugins.v2/historytov2/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/multiclass/__init__.py", + "plugins.v2/notifyimage/__init__.py", + "plugins.v2/oidcauth/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/storagecleanup/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/synccookiecloud/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wxpusher/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/animeupscale/__init__.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "get_module": { + "file_count": 5, + "files": [ + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/notifyimage/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v3/imdbsource/__init__.py" + ] + }, + "get_page": { + "file_count": 81, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/animeupscale/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/bangumiproxy/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/bugreporter/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/goldprice/__init__.py", + "plugins.v2/historytov2/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/multiclass/__init__.py", + "plugins.v2/notifyimage/__init__.py", + "plugins.v2/oidcauth/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/storagecleanup/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/synccookiecloud/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wxpusher/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/animeupscale/__init__.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "get_render_mode": { + "file_count": 11, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/oidcauth/__init__.py", + "plugins.v2/storagecleanup/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/brushflow/__init__.py" + ] + }, + "get_service": { + "file_count": 47, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/goldprice/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py" + ] + }, + "get_sidebar": { + "file_count": 0, + "files": [] + }, + "get_state": { + "file_count": 82, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/animeupscale/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/bangumiproxy/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/bugreporter/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chatgpt/openai.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/goldprice/__init__.py", + "plugins.v2/historytov2/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/multiclass/__init__.py", + "plugins.v2/notifyimage/__init__.py", + "plugins.v2/oidcauth/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/storagecleanup/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/synccookiecloud/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wxpusher/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/animeupscale/__init__.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "init_plugin": { + "file_count": 81, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/animeupscale/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/bangumiproxy/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/bugreporter/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/goldprice/__init__.py", + "plugins.v2/historytov2/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/multiclass/__init__.py", + "plugins.v2/notifyimage/__init__.py", + "plugins.v2/oidcauth/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/storagecleanup/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/synccookiecloud/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wxpusher/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/animeupscale/__init__.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "stop_service": { + "file_count": 81, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/animeupscale/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/bangumiproxy/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/bugreporter/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/goldprice/__init__.py", + "plugins.v2/historytov2/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/multiclass/__init__.py", + "plugins.v2/notifyimage/__init__.py", + "plugins.v2/oidcauth/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/storagecleanup/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/synccookiecloud/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wxpusher/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/animeupscale/__init__.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + } + }, + "imports": { + "app.adapters.external.server": { + "file_count": 1, + "files": [ + "plugins.v3/bangumicoll/__init__.py" + ] + }, + "app.agent.llm": { + "file_count": 2, + "files": [ + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/chatgpt/openai.py" + ] + }, + "app.agent.llm.helper": { + "file_count": 1, + "files": [ + "plugins.v2/lexiannot/__init__.py" + ] + }, + "app.agent.tools.base": { + "file_count": 3, + "files": [ + "plugins.v2/agentresourceofficer/agenttool.py", + "plugins.v2/lexiannot/agenttool.py", + "plugins.v3/agentresourceofficer/agenttool.py" + ] + }, + "app.agent.tools.manager": { + "file_count": 2, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py" + ] + }, + "app.api.endpoints.plugin": { + "file_count": 4, + "files": [ + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v3/brushflow/__init__.py" + ] + }, + "app.chain": { + "file_count": 2, + "files": [ + "plugins.v2/imdbsource/__init__.py", + "plugins.v3/imdbsource/__init__.py" + ] + }, + "app.chain.anilist": { + "file_count": 1, + "files": [ + "plugins.v2/fullscreenposterwall/__init__.py" + ] + }, + "app.chain.download": { + "file_count": 11, + "files": [ + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/rsssubscribe/__init__.py" + ] + }, + "app.chain.media": { + "file_count": 9, + "files": [ + "plugins.v2/agentresourceofficer/services/hdhive_openapi.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v3/agentresourceofficer/services/hdhive_openapi.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py" + ] + }, + "app.chain.mediaserver": { + "file_count": 2, + "files": [ + "plugins.v2/personmeta/__init__.py", + "plugins.v3/personmeta/__init__.py" + ] + }, + "app.chain.message": { + "file_count": 1, + "files": [ + "plugins.v2/wechatclawbot/__init__.py" + ] + }, + "app.chain.recommend": { + "file_count": 3, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py" + ] + }, + "app.chain.scraping": { + "file_count": 1, + "files": [ + "plugins.v3/libraryscraper/__init__.py" + ] + }, + "app.chain.search": { + "file_count": 3, + "files": [ + "plugins.v2/doubansync/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v3/doubansync/__init__.py" + ] + }, + "app.chain.site": { + "file_count": 1, + "files": [ + "plugins.v2/sitestatistic/__init__.py" + ] + }, + "app.chain.storage": { + "file_count": 2, + "files": [ + "plugins.v2/autoclean/__init__.py", + "plugins.v3/autoclean/__init__.py" + ] + }, + "app.chain.subscribe": { + "file_count": 10, + "files": [ + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/rsssubscribe/__init__.py" + ] + }, + "app.chain.system": { + "file_count": 1, + "files": [ + "plugins.v2/moviepilotupdatenotify/__init__.py" + ] + }, + "app.chain.tmdb": { + "file_count": 4, + "files": [ + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/personmeta/__init__.py" + ] + }, + "app.chain.torrents": { + "file_count": 4, + "files": [ + "plugins.v2/brushflow/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v3/brushflow/__init__.py" + ] + }, + "app.command": { + "file_count": 1, + "files": [ + "plugins.v2/wechatclawbot/__init__.py" + ] + }, + "app.core.auth": { + "file_count": 1, + "files": [ + "plugins.v2/oidcauth/__init__.py" + ] + }, + "app.core.auth_bridge": { + "file_count": 1, + "files": [ + "plugins.v2/oidcauth/__init__.py" + ] + }, + "app.core.cache": { + "file_count": 9, + "files": [ + "plugins.v2/clashruleprovider/services.py", + "plugins.v2/clashruleprovider/state.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/imdbsource/imdbapi.py", + "plugins.v2/imdbsource/imdbhelper.py", + "plugins.v2/imdbsource/officialapi.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/lexiannot/spacyworker.py", + "plugins.v2/mediaservermsg/__init__.py" + ] + }, + "app.core.config": { + "file_count": 70, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agentresourceofficer/services/hdhive_openapi.py", + "plugins.v2/agentresourceofficer/services/p115_transfer.py", + "plugins.v2/agentresourceofficer/services/quark_transfer.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/autosignin/sites/52pt.py", + "plugins.v2/autosignin/sites/__init__.py", + "plugins.v2/autosignin/sites/chdbits.py", + "plugins.v2/autosignin/sites/hares.py", + "plugins.v2/autosignin/sites/hdarea.py", + "plugins.v2/autosignin/sites/hdchina.py", + "plugins.v2/autosignin/sites/hdsky.py", + "plugins.v2/autosignin/sites/mteam.py", + "plugins.v2/autosignin/sites/nexushd.py", + "plugins.v2/autosignin/sites/opencd.py", + "plugins.v2/autosignin/sites/rousipro.py", + "plugins.v2/autosignin/sites/tjupt.py", + "plugins.v2/autosignin/sites/ttg.py", + "plugins.v2/autosignin/sites/u2.py", + "plugins.v2/autosignin/sites/yema.py", + "plugins.v2/autosignin/sites/zhuque.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/clashruleprovider/api.py", + "plugins.v2/clashruleprovider/services.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/contractcheck/siteuserinfo/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/imdbsource/imdbhelper.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/oidcauth/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/synccookiecloud/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py" + ] + }, + "app.core.context": { + "file_count": 17, + "files": [ + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/imdbsource/imdbhelper.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/multiclass/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py" + ] + }, + "app.core.event": { + "file_count": 40, + "files": [ + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/multiclass/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wxpusher/__init__.py" + ] + }, + "app.core.meta": { + "file_count": 3, + "files": [ + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/personmeta/__init__.py" + ] + }, + "app.core.meta.words": { + "file_count": 1, + "files": [ + "plugins.v2/airecognizerenhancer/__init__.py" + ] + }, + "app.core.metainfo": { + "file_count": 11, + "files": [ + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py" + ] + }, + "app.core.plugin": { + "file_count": 5, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agentresourceofficer/agenttool.py", + "plugins.v2/agentresourceofficer/services/p115_transfer.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/lexiannot/agenttool.py" + ] + }, + "app.db": { + "file_count": 5, + "files": [ + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/historytov2/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/historytov2/__init__.py" + ] + }, + "app.db.downloadhistory_oper": { + "file_count": 5, + "files": [ + "plugins.v2/autoclean/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/subscribeclear/__init__.py" + ] + }, + "app.db.message_oper": { + "file_count": 1, + "files": [ + "plugins.v2/wechatclawbot/__init__.py" + ] + }, + "app.db.models": { + "file_count": 3, + "files": [ + "plugins.v2/historytov2/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py" + ] + }, + "app.db.models.downloadhistory": { + "file_count": 3, + "files": [ + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v3/downloadsitetag/__init__.py" + ] + }, + "app.db.models.siteuserdata": { + "file_count": 2, + "files": [ + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/sitestatistic/__init__.py" + ] + }, + "app.db.models.subscribehistory": { + "file_count": 1, + "files": [ + "plugins.v3/bangumicoll/__init__.py" + ] + }, + "app.db.models.user": { + "file_count": 1, + "files": [ + "plugins.v2/oidcauth/__init__.py" + ] + }, + "app.db.oper.downloadhistory": { + "file_count": 2, + "files": [ + "plugins.v3/autoclean/__init__.py", + "plugins.v3/downloadsitetag/__init__.py" + ] + }, + "app.db.oper.site": { + "file_count": 2, + "files": [ + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py" + ] + }, + "app.db.oper.subscribe": { + "file_count": 3, + "files": [ + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubansync/__init__.py" + ] + }, + "app.db.oper.transferhistory": { + "file_count": 2, + "files": [ + "plugins.v3/autoclean/__init__.py", + "plugins.v3/libraryscraper/__init__.py" + ] + }, + "app.db.oper.user": { + "file_count": 1, + "files": [ + "plugins.v3/doubansync/__init__.py" + ] + }, + "app.db.plugindata_oper": { + "file_count": 3, + "files": [ + "plugins.v2/clashruleprovider/helper/dataupgrader/v_2_1_0.py", + "plugins.v2/clashruleprovider/state.py", + "plugins.v2/dailysummary/__init__.py" + ] + }, + "app.db.site_oper": { + "file_count": 10, + "files": [ + "plugins.v2/autosignin/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/synccookiecloud/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py" + ] + }, + "app.db.subscribe_oper": { + "file_count": 3, + "files": [ + "plugins.v2/brushflow/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubansync/__init__.py" + ] + }, + "app.db.systemconfig_oper": { + "file_count": 1, + "files": [ + "plugins.v2/airecognizerenhancer/__init__.py" + ] + }, + "app.db.transferhistory_oper": { + "file_count": 4, + "files": [ + "plugins.v2/autoclean/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/scrapefileclean/__init__.py" + ] + }, + "app.db.user_oper": { + "file_count": 2, + "files": [ + "plugins.v2/doubansync/__init__.py", + "plugins.v2/oidcauth/__init__.py" + ] + }, + "app.helper.browser": { + "file_count": 5, + "files": [ + "plugins.v2/agentresourceofficer/services/hdhive_browser.py", + "plugins.v2/agentresourceofficer/services/hdhive_openapi.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/autosignin/sites/__init__.py", + "plugins.v2/maoyanrank/__init__.py" + ] + }, + "app.helper.cloudflare": { + "file_count": 2, + "files": [ + "plugins.v2/autosignin/__init__.py", + "plugins.v2/contractcheck/siteuserinfo/__init__.py" + ] + }, + "app.helper.cookiecloud": { + "file_count": 1, + "files": [ + "plugins.v2/dynamicwechat/__init__.py" + ] + }, + "app.helper.directory": { + "file_count": 2, + "files": [ + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/lexiannot/__init__.py" + ] + }, + "app.helper.downloader": { + "file_count": 14, + "files": [ + "plugins.v2/brushflow/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py" + ] + }, + "app.helper.image": { + "file_count": 2, + "files": [ + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py" + ] + }, + "app.helper.llm": { + "file_count": 1, + "files": [ + "plugins.v2/airecognizerenhancer/__init__.py" + ] + }, + "app.helper.mediaserver": { + "file_count": 4, + "files": [ + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/speedlimiter/__init__.py" + ] + }, + "app.helper.module": { + "file_count": 2, + "files": [ + "plugins.v2/autosignin/__init__.py", + "plugins.v2/contractcheck/__init__.py" + ] + }, + "app.helper.nfo": { + "file_count": 1, + "files": [ + "plugins.v2/libraryscraper/__init__.py" + ] + }, + "app.helper.ocr": { + "file_count": 2, + "files": [ + "plugins.v2/autosignin/sites/hdsky.py", + "plugins.v2/autosignin/sites/opencd.py" + ] + }, + "app.helper.rss": { + "file_count": 2, + "files": [ + "plugins.v2/doubansync/__init__.py", + "plugins.v2/rsssubscribe/__init__.py" + ] + }, + "app.helper.service": { + "file_count": 1, + "files": [ + "plugins.v2/qbuploadlimiter/__init__.py" + ] + }, + "app.helper.sites": { + "file_count": 11, + "files": [ + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/sitestatistic/__init__.py" + ] + }, + "app.helper.system": { + "file_count": 1, + "files": [ + "plugins.v2/moviepilotupdatenotify/__init__.py" + ] + }, + "app.helper.thread": { + "file_count": 2, + "files": [ + "plugins.v2/animeupscale/__init__.py", + "plugins.v2/brushflow/__init__.py" + ] + }, + "app.helper.torrent": { + "file_count": 4, + "files": [ + "plugins.v2/crossseed/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/traktcleaner/__init__.py" + ] + }, + "app.helper.wallpaper": { + "file_count": 1, + "files": [ + "plugins.v2/tmdbwallpaper/__init__.py" + ] + }, + "app.log": { + "file_count": 97, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agentresourceofficer/feishu_channel.py", + "plugins.v2/agentresourceofficer/services/hdhive_browser.py", + "plugins.v2/agentresourceofficer/services/quark_transfer.py", + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/animeupscale/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/autosignin/sites/52pt.py", + "plugins.v2/autosignin/sites/__init__.py", + "plugins.v2/autosignin/sites/btschool.py", + "plugins.v2/autosignin/sites/chdbits.py", + "plugins.v2/autosignin/sites/haidan.py", + "plugins.v2/autosignin/sites/hares.py", + "plugins.v2/autosignin/sites/hdarea.py", + "plugins.v2/autosignin/sites/hdchina.py", + "plugins.v2/autosignin/sites/hdcity.py", + "plugins.v2/autosignin/sites/hdsky.py", + "plugins.v2/autosignin/sites/hdupt.py", + "plugins.v2/autosignin/sites/nexushd.py", + "plugins.v2/autosignin/sites/opencd.py", + "plugins.v2/autosignin/sites/pterclub.py", + "plugins.v2/autosignin/sites/pttime.py", + "plugins.v2/autosignin/sites/rousipro.py", + "plugins.v2/autosignin/sites/tjupt.py", + "plugins.v2/autosignin/sites/ttg.py", + "plugins.v2/autosignin/sites/u2.py", + "plugins.v2/autosignin/sites/zhuque.py", + "plugins.v2/bangumiproxy/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/clashruleprovider/api.py", + "plugins.v2/clashruleprovider/helper/dataupgrader/v_2_1_0.py", + "plugins.v2/clashruleprovider/models/configuration.py", + "plugins.v2/clashruleprovider/services.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/contractcheck/siteuserinfo/__init__.py", + "plugins.v2/contractcheck/siteuserinfo/nexus_php.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/goldprice/__init__.py", + "plugins.v2/historytov2/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/imdbsource/imdbapi.py", + "plugins.v2/imdbsource/imdbhelper.py", + "plugins.v2/imdbsource/officialapi.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/lexiannot/pipeline.py", + "plugins.v2/lexiannot/spacyworker.py", + "plugins.v2/lexiannot/subtitle.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/multiclass/__init__.py", + "plugins.v2/notifyimage/__init__.py", + "plugins.v2/oidcauth/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/synccookiecloud/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/tobypasstrackers/dns_helper.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wechatclawbot/ilink/client.py", + "plugins.v2/wxpusher/__init__.py" + ] + }, + "app.modules.bangumi": { + "file_count": 1, + "files": [ + "plugins.v2/bangumiproxy/__init__.py" + ] + }, + "app.modules.fanart": { + "file_count": 1, + "files": [ + "plugins.v2/fullscreenposterwall/__init__.py" + ] + }, + "app.modules.filemanager.storages": { + "file_count": 1, + "files": [ + "plugins.v2/blurayremux/__init__.py" + ] + }, + "app.modules.filemanager.storages.local": { + "file_count": 1, + "files": [ + "plugins.v2/blurayremux/__init__.py" + ] + }, + "app.modules.filemanager.transhandler": { + "file_count": 1, + "files": [ + "plugins.v2/blurayremux/__init__.py" + ] + }, + "app.modules.qbittorrent": { + "file_count": 3, + "files": [ + "plugins.v2/brushflow/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v3/brushflow/__init__.py" + ] + }, + "app.modules.themoviedb": { + "file_count": 3, + "files": [ + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/mediaservermsg/__init__.py" + ] + }, + "app.modules.themoviedb.tmdbapi": { + "file_count": 1, + "files": [ + "plugins.v2/fullscreenposterwall/__init__.py" + ] + }, + "app.modules.themoviedb.tmdbv3api": { + "file_count": 1, + "files": [ + "plugins.v3/episodegroupmeta/__init__.py" + ] + }, + "app.modules.transmission": { + "file_count": 3, + "files": [ + "plugins.v2/brushflow/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v3/brushflow/__init__.py" + ] + }, + "app.modules.wechat": { + "file_count": 1, + "files": [ + "plugins.v2/dynamicwechat/helper.py" + ] + }, + "app.plugins": { + "file_count": 81, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/animeupscale/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/bangumiproxy/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/bugreporter/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/goldprice/__init__.py", + "plugins.v2/historytov2/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/multiclass/__init__.py", + "plugins.v2/notifyimage/__init__.py", + "plugins.v2/oidcauth/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/storagecleanup/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/synccookiecloud/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wxpusher/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/animeupscale/__init__.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "app.plugins.autosignin.sites": { + "file_count": 21, + "files": [ + "plugins.v2/autosignin/sites/52pt.py", + "plugins.v2/autosignin/sites/btschool.py", + "plugins.v2/autosignin/sites/chdbits.py", + "plugins.v2/autosignin/sites/haidan.py", + "plugins.v2/autosignin/sites/hares.py", + "plugins.v2/autosignin/sites/hdarea.py", + "plugins.v2/autosignin/sites/hdchina.py", + "plugins.v2/autosignin/sites/hdcity.py", + "plugins.v2/autosignin/sites/hdsky.py", + "plugins.v2/autosignin/sites/hdupt.py", + "plugins.v2/autosignin/sites/mteam.py", + "plugins.v2/autosignin/sites/nexushd.py", + "plugins.v2/autosignin/sites/opencd.py", + "plugins.v2/autosignin/sites/pterclub.py", + "plugins.v2/autosignin/sites/pttime.py", + "plugins.v2/autosignin/sites/rousipro.py", + "plugins.v2/autosignin/sites/tjupt.py", + "plugins.v2/autosignin/sites/ttg.py", + "plugins.v2/autosignin/sites/u2.py", + "plugins.v2/autosignin/sites/yema.py", + "plugins.v2/autosignin/sites/zhuque.py" + ] + }, + "app.plugins.chatgpt.openai": { + "file_count": 1, + "files": [ + "plugins.v2/chatgpt/__init__.py" + ] + }, + "app.plugins.ffmpegthumb.ffmpeg_helper": { + "file_count": 1, + "files": [ + "plugins.v2/ffmpegthumb/__init__.py" + ] + }, + "app.plugins.imdbsource.imdbhelper": { + "file_count": 2, + "files": [ + "plugins.v2/imdbsource/__init__.py", + "plugins.v3/imdbsource/__init__.py" + ] + }, + "app.plugins.imdbsource.officialapi": { + "file_count": 2, + "files": [ + "plugins.v2/imdbsource/__init__.py", + "plugins.v3/imdbsource/__init__.py" + ] + }, + "app.plugins.imdbsource.schema": { + "file_count": 2, + "files": [ + "plugins.v2/imdbsource/__init__.py", + "plugins.v3/imdbsource/__init__.py" + ] + }, + "app.plugins.iyuuautoseed.iyuu_helper": { + "file_count": 1, + "files": [ + "plugins.v2/iyuuautoseed/__init__.py" + ] + }, + "app.plugins.p115strmhelper.core.config": { + "file_count": 2, + "files": [ + "plugins.v2/agentresourceofficer/services/p115_transfer.py", + "plugins.v3/agentresourceofficer/services/p115_transfer.py" + ] + }, + "app.runtime.thread": { + "file_count": 2, + "files": [ + "plugins.v3/animeupscale/__init__.py", + "plugins.v3/brushflow/__init__.py" + ] + }, + "app.scheduler": { + "file_count": 5, + "files": [ + "plugins.v2/brushflow/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/imdbsource/__init__.py" + ] + }, + "app.schemas": { + "file_count": 48, + "files": [ + "plugins.v2/autoclean/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/lexiannot/pipeline.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/subscribeclear/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "app.schemas.event": { + "file_count": 1, + "files": [ + "plugins.v2/hrblocker/__init__.py" + ] + }, + "app.schemas.types": { + "file_count": 65, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/agenttokens/__init__.py", + "plugins.v2/airecognizerenhancer/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autoclean/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/blurayremux/__init__.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/chatgpt/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/dailysummary/__init__.py", + "plugins.v2/doubansync/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/dynamicwechat/__init__.py", + "plugins.v2/dynamicwechat/helper.py", + "plugins.v2/episodetag/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/goldprice/__init__.py", + "plugins.v2/hrblocker/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/imdbsource/imdbhelper.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/lexiannot/pipeline.py", + "plugins.v2/mediaservermsg/__init__.py", + "plugins.v2/mediaserverrefresh/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/multiclass/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/playletcategory/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/qbuploadlimiter/__init__.py", + "plugins.v2/rsssubscribe/__init__.py", + "plugins.v2/scrapefileclean/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/speedlimiter/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/traktcleaner/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/tvfirstwatch/__init__.py", + "plugins.v2/updatewechatip/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wxpusher/__init__.py", + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/agentresourceofficer/services/hdhive_openapi.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/imdbsource/imdbhelper.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "app.sdk.browser": { + "file_count": 3, + "files": [ + "plugins.v3/agentresourceofficer/services/hdhive_browser.py", + "plugins.v3/agentresourceofficer/services/hdhive_openapi.py", + "plugins.v3/maoyanrank/__init__.py" + ] + }, + "app.sdk.cache": { + "file_count": 5, + "files": [ + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/imdbsource/imdbapi.py", + "plugins.v3/imdbsource/imdbhelper.py", + "plugins.v3/imdbsource/officialapi.py", + "plugins.v3/mediaservermsg/__init__.py" + ] + }, + "app.sdk.config": { + "file_count": 20, + "files": [ + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/agentresourceofficer/services/hdhive_openapi.py", + "plugins.v3/agentresourceofficer/services/p115_transfer.py", + "plugins.v3/agentresourceofficer/services/quark_transfer.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/imdbsource/imdbhelper.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "app.sdk.events": { + "file_count": 11, + "files": [ + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "app.sdk.logging": { + "file_count": 24, + "files": [ + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/agentresourceofficer/feishu_channel.py", + "plugins.v3/agentresourceofficer/services/hdhive_browser.py", + "plugins.v3/agentresourceofficer/services/quark_transfer.py", + "plugins.v3/animeupscale/__init__.py", + "plugins.v3/autoclean/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/imdbsource/imdbapi.py", + "plugins.v3/imdbsource/imdbhelper.py", + "plugins.v3/imdbsource/officialapi.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "app.sdk.media": { + "file_count": 15, + "files": [ + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/imdbsource/imdbhelper.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/neodbsync/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py" + ] + }, + "app.sdk.network": { + "file_count": 17, + "files": [ + "plugins.v3/animeupscale/__init__.py", + "plugins.v3/bangumicoll/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/doubansync/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/historytov2/__init__.py", + "plugins.v3/imdbsource/__init__.py", + "plugins.v3/imdbsource/imdbapi.py", + "plugins.v3/imdbsource/imdbhelper.py", + "plugins.v3/imdbsource/officialapi.py", + "plugins.v3/maoyanrank/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/personmeta/__init__.py", + "plugins.v3/rsssubscribe/__init__.py", + "plugins.v3/tvdbdiscover/__init__.py" + ] + }, + "app.sdk.plugins": { + "file_count": 3, + "files": [ + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/agentresourceofficer/agenttool.py", + "plugins.v3/agentresourceofficer/services/p115_transfer.py" + ] + }, + "app.sdk.services": { + "file_count": 5, + "files": [ + "plugins.v3/brushflow/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/mediaservermsg/__init__.py", + "plugins.v3/personmeta/__init__.py" + ] + }, + "app.sdk.utilities": { + "file_count": 10, + "files": [ + "plugins.v3/agentresourceofficer/__init__.py", + "plugins.v3/brushflow/__init__.py", + "plugins.v3/doubanrank/__init__.py", + "plugins.v3/downloadsitetag/__init__.py", + "plugins.v3/episodegroupmeta/__init__.py", + "plugins.v3/imdbsource/imdbapi.py", + "plugins.v3/imdbsource/imdbhelper.py", + "plugins.v3/imdbsource/officialapi.py", + "plugins.v3/libraryscraper/__init__.py", + "plugins.v3/personmeta/__init__.py" + ] + }, + "app.utils.common": { + "file_count": 3, + "files": [ + "plugins.v2/imdbsource/imdbapi.py", + "plugins.v2/imdbsource/officialapi.py", + "plugins.v2/personmeta/__init__.py" + ] + }, + "app.utils.crypto": { + "file_count": 2, + "files": [ + "plugins.v2/agentresourceofficer/__init__.py", + "plugins.v2/synccookiecloud/__init__.py" + ] + }, + "app.utils.dom": { + "file_count": 1, + "files": [ + "plugins.v2/doubanrank/__init__.py" + ] + }, + "app.utils.http": { + "file_count": 45, + "files": [ + "plugins.v2/animeupscale/__init__.py", + "plugins.v2/autoauction/__init__.py", + "plugins.v2/autosignin/__init__.py", + "plugins.v2/autosignin/sites/52pt.py", + "plugins.v2/autosignin/sites/__init__.py", + "plugins.v2/autosignin/sites/chdbits.py", + "plugins.v2/autosignin/sites/hares.py", + "plugins.v2/autosignin/sites/hdarea.py", + "plugins.v2/autosignin/sites/hdchina.py", + "plugins.v2/autosignin/sites/hdsky.py", + "plugins.v2/autosignin/sites/mteam.py", + "plugins.v2/autosignin/sites/nexushd.py", + "plugins.v2/autosignin/sites/opencd.py", + "plugins.v2/autosignin/sites/rousipro.py", + "plugins.v2/autosignin/sites/tjupt.py", + "plugins.v2/autosignin/sites/ttg.py", + "plugins.v2/autosignin/sites/u2.py", + "plugins.v2/autosignin/sites/yema.py", + "plugins.v2/autosignin/sites/zhuque.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/clashruleprovider/services.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/contractcheck/siteuserinfo/__init__.py", + "plugins.v2/doubanrank/__init__.py", + "plugins.v2/fullscreenposterwall/__init__.py", + "plugins.v2/historytov2/__init__.py", + "plugins.v2/imdbsource/__init__.py", + "plugins.v2/imdbsource/imdbapi.py", + "plugins.v2/imdbsource/imdbhelper.py", + "plugins.v2/imdbsource/officialapi.py", + "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/iyuuautoseed/iyuu_helper.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/maoyanrank/__init__.py", + "plugins.v2/meowmsg/__init__.py", + "plugins.v2/moviepilotupdatenotify/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/tmdbwallpaper/__init__.py", + "plugins.v2/tobypasstrackers/__init__.py", + "plugins.v2/tvdbdiscover/__init__.py", + "plugins.v2/wechatclawbot/__init__.py", + "plugins.v2/wechatclawbot/ilink/client.py", + "plugins.v2/wxpusher/__init__.py" + ] + }, + "app.utils.ip": { + "file_count": 1, + "files": [ + "plugins.v2/speedlimiter/__init__.py" + ] + }, + "app.utils.singleton": { + "file_count": 1, + "files": [ + "plugins.v2/lexiannot/schemas.py" + ] + }, + "app.utils.site": { + "file_count": 2, + "files": [ + "plugins.v2/autosignin/__init__.py", + "plugins.v2/contractcheck/siteuserinfo/__init__.py" + ] + }, + "app.utils.string": { + "file_count": 43, + "files": [ + "plugins.v2/autosignin/__init__.py", + "plugins.v2/autosignin/sites/52pt.py", + "plugins.v2/autosignin/sites/__init__.py", + "plugins.v2/autosignin/sites/btschool.py", + "plugins.v2/autosignin/sites/chdbits.py", + "plugins.v2/autosignin/sites/haidan.py", + "plugins.v2/autosignin/sites/hares.py", + "plugins.v2/autosignin/sites/hdarea.py", + "plugins.v2/autosignin/sites/hdchina.py", + "plugins.v2/autosignin/sites/hdcity.py", + "plugins.v2/autosignin/sites/hdsky.py", + "plugins.v2/autosignin/sites/hdupt.py", + "plugins.v2/autosignin/sites/mteam.py", + "plugins.v2/autosignin/sites/nexushd.py", + "plugins.v2/autosignin/sites/opencd.py", + "plugins.v2/autosignin/sites/pterclub.py", + "plugins.v2/autosignin/sites/pttime.py", + "plugins.v2/autosignin/sites/rousipro.py", + "plugins.v2/autosignin/sites/tjupt.py", + "plugins.v2/autosignin/sites/ttg.py", + "plugins.v2/autosignin/sites/u2.py", + "plugins.v2/autosignin/sites/zhuque.py", + "plugins.v2/brushflow/__init__.py", + "plugins.v2/clashruleprovider/__init__.py", + "plugins.v2/clashruleprovider/helper/converters/hysteria.py", + "plugins.v2/clashruleprovider/helper/converters/hysteria2.py", + "plugins.v2/clashruleprovider/helper/converters/trojan.py", + "plugins.v2/cleaninvalidseed/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/contractcheck/siteuserinfo/nexus_php.py", + "plugins.v2/contractcheck/siteuserinfo/nexus_ttg.py", + "plugins.v2/crossseed/__init__.py", + "plugins.v2/downloadsitetag/__init__.py", + "plugins.v2/imdbsource/imdbhelper.py", + "plugins.v2/iyuuautoseed/__init__.py", + "plugins.v2/lexiannot/__init__.py", + "plugins.v2/personmeta/__init__.py", + "plugins.v2/promotiontag/__init__.py", + "plugins.v2/qbcommand/__init__.py", + "plugins.v2/sitestatistic/__init__.py", + "plugins.v2/torrentremover/__init__.py", + "plugins.v2/torrenttransfer/__init__.py", + "plugins.v2/wechatclawbot/__init__.py" + ] + }, + "app.utils.system": { + "file_count": 5, + "files": [ + "plugins.v2/chinesesubfinder/__init__.py", + "plugins.v2/ffmpegthumb/__init__.py", + "plugins.v2/ffmpegthumb/ffmpeg_helper.py", + "plugins.v2/libraryscraper/__init__.py", + "plugins.v2/playletcategory/__init__.py" + ] + }, + "app.utils.timer": { + "file_count": 3, + "files": [ + "plugins.v2/autosignin/__init__.py", + "plugins.v2/contractcheck/__init__.py", + "plugins.v2/crossseed/__init__.py" + ] + }, + "app.utils.web": { + "file_count": 1, + "files": [ + "plugins.v2/mediaservermsg/__init__.py" + ] + } + }, + "schema_version": 2, + "source": { + "head": "68340c0884c3aff4ee027c9c94ce25d096871aa5", + "python_file_count": 231, + "repository": "MoviePilot-Plugins", + "roots": [ + "plugins.v2", + "plugins.v3" + ], + "source_sha256": "cb69dda1fe0547e85a36c99ebcc31e9f3de62d035d548b4a33875626a24b7197" + } +} diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json new file mode 100644 index 000000000..22c7c187b --- /dev/null +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -0,0 +1,4366 @@ +{ + "compat_manifest": { + "module_aliases": { + "app.application.filter": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.rules", + "target": "app.application.rules" + }, + "app.application.filter_rules": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.rules", + "target": "app.application.rules" + }, + "app.chain.media_interaction": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "chain", + "replacement": "app.chain.interaction", + "target": "app.chain.interaction" + }, + "app.core.auth": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.security.auth", + "target": "app.application.security.auth" + }, + "app.core.auth_bridge": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.security.auth", + "target": "app.application.security.auth" + }, + "app.core.cache": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "sdk", + "replacement": "app.sdk.cache", + "target": "app.sdk.cache" + }, + "app.core.config": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.sdk.config", + "target": "app.runtime.config" + }, + "app.core.context": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.context" + }, + "app.core.event": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.sdk.events", + "target": "app.runtime.events" + }, + "app.core.meta.customization": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.meta.customization" + }, + "app.core.meta.infopath": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.meta.infopath" + }, + "app.core.meta.metaanime": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.meta.metaanime" + }, + "app.core.meta.metabase": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.meta.metabase" + }, + "app.core.meta.metamusic": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.meta.metamusic" + }, + "app.core.meta.metavideo": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.meta.metavideo" + }, + "app.core.meta.releasegroup": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.meta.releasegroup" + }, + "app.core.meta.streamingplatform": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.meta.streamingplatform" + }, + "app.core.meta.words": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.meta.words" + }, + "app.core.metainfo": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.metainfo" + }, + "app.core.module": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.sdk.plugins", + "target": "app.runtime.extensions.module_manager" + }, + "app.core.plugin": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.sdk.plugins", + "target": "app.runtime.extensions.plugin_manager" + }, + "app.core.security": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.security.access", + "target": "app.application.security.access" + }, + "app.db.agentchat_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.agentchat", + "target": "app.db.oper.agentchat" + }, + "app.db.agenttask_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.agenttask", + "target": "app.db.oper.agenttask" + }, + "app.db.downloadfailure_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.downloadfailure", + "target": "app.db.oper.downloadfailure" + }, + "app.db.downloadhistory_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.downloadhistory", + "target": "app.db.oper.downloadhistory" + }, + "app.db.init": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "startup", + "replacement": "app.startup.database_initializer", + "target": "app.startup.database_initializer" + }, + "app.db.mediaserver_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.mediaserver", + "target": "app.db.oper.mediaserver" + }, + "app.db.message_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.message", + "target": "app.db.oper.message" + }, + "app.db.plugindata_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.plugindata", + "target": "app.db.oper.plugindata" + }, + "app.db.site_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.site", + "target": "app.db.oper.site" + }, + "app.db.subscribe_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "sdk", + "replacement": "app.application.subscribe.add_subscribe", + "target": "app.sdk._legacy.subscribe" + }, + "app.db.subscribehistory_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.subscribehistory", + "target": "app.db.oper.subscribehistory" + }, + "app.db.systemconfig_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.systemconfig", + "target": "app.db.oper.systemconfig" + }, + "app.db.transferhistory_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "sdk", + "replacement": "app.application.history", + "target": "app.sdk._legacy.history" + }, + "app.db.transferpending_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.transferpending", + "target": "app.db.oper.transferpending" + }, + "app.db.user_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "sdk", + "replacement": "app.db.oper.user 或 app.api.deps", + "target": "app.sdk._legacy.user" + }, + "app.db.userconfig_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.userconfig", + "target": "app.db.oper.userconfig" + }, + "app.db.workflow_oper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "db", + "replacement": "app.db.oper.workflow", + "target": "app.db.oper.workflow" + }, + "app.domain.string": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "sdk", + "replacement": "app.sdk.utilities", + "target": "app.sdk.string" + }, + "app.helper.agent": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.messaging.agent", + "target": "app.application.messaging.agent" + }, + "app.helper.audio": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.audio", + "target": "app.application.audio" + }, + "app.helper.browser": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.network.browser", + "target": "app.adapters.network.browser" + }, + "app.helper.cloudflare": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.network.cloudflare", + "target": "app.adapters.network.cloudflare" + }, + "app.helper.cookie": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.security.cookie", + "target": "app.application.security.cookie" + }, + "app.helper.cookiecloud": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.external.cookiecloud", + "target": "app.adapters.external.cookiecloud" + }, + "app.helper.directory": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.directory", + "target": "app.application.directory" + }, + "app.helper.display": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.system.display", + "target": "app.adapters.system.display" + }, + "app.helper.doh": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.network.doh", + "target": "app.adapters.network.doh" + }, + "app.helper.downloader": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.sdk.services", + "target": "app.application.downloader" + }, + "app.helper.format": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.formatting", + "target": "app.application.formatting" + }, + "app.helper.image": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.image", + "target": "app.application.image" + }, + "app.helper.interaction": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.messaging.interaction", + "target": "app.application.messaging.interaction" + }, + "app.helper.llm": { + "introduced": "v3.0.0", + "is_package": true, + "owner": "agent", + "replacement": "app.agent.llm", + "target": "app.agent.llm" + }, + "app.helper.locale": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.sdk.utilities", + "target": "app.runtime.localization" + }, + "app.helper.market": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.external.market", + "target": "app.adapters.external.market" + }, + "app.helper.mediaserver": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.sdk.services", + "target": "app.application.mediaserver" + }, + "app.helper.message": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.messaging.message", + "target": "app.application.messaging.message" + }, + "app.helper.module": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "foundation", + "replacement": "app.foundation.reflection", + "target": "app.foundation.reflection" + }, + "app.helper.nfo": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.scraper" + }, + "app.helper.notification": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.sdk.services", + "target": "app.application.notification" + }, + "app.helper.ocr": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.external.ocr", + "target": "app.adapters.external.ocr" + }, + "app.helper.package": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.system.package", + "target": "app.adapters.system.package" + }, + "app.helper.passkey": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.security.passkey", + "target": "app.application.security.passkey" + }, + "app.helper.plugin": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.external.market", + "target": "app.adapters.external.market" + }, + "app.helper.progress": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.runtime.progress", + "target": "app.runtime.progress" + }, + "app.helper.redis": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.cache.redis", + "target": "app.adapters.cache.redis" + }, + "app.helper.resource": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.system.resource", + "target": "app.adapters.system.resource" + }, + "app.helper.rss": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.sdk.network", + "target": "app.application.rss" + }, + "app.helper.rule": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.sdk.services", + "target": "app.application.rules" + }, + "app.helper.scraper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.domain.scraper", + "target": "app.domain.scraper" + }, + "app.helper.server": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.external.server", + "target": "app.adapters.external.server" + }, + "app.helper.service": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.sdk.services", + "target": "app.runtime.extensions.service_registry" + }, + "app.helper.sites": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.sdk.network", + "target": "app.application.site.sites" + }, + "app.helper.skill": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "agent", + "replacement": "app.agent.skills.registry", + "target": "app.agent.skills.registry" + }, + "app.helper.storage": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.sdk.services", + "target": "app.application.storage" + }, + "app.helper.system": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.sdk.services", + "target": "app.runtime.state" + }, + "app.helper.thread": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.runtime.thread", + "target": "app.runtime.thread" + }, + "app.helper.torrent": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.torrent", + "target": "app.application.torrent" + }, + "app.helper.transferhistory": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.history", + "target": "app.application.history" + }, + "app.helper.twofa": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.security.twofactor", + "target": "app.application.security.twofactor" + }, + "app.helper.wallpaper": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.application.image", + "target": "app.application.image" + }, + "app.helper.webpush": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "api", + "replacement": "app.api.endpoints.message", + "target": "app.api.endpoints.message" + }, + "app.log": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "sdk", + "replacement": "app.sdk.logging", + "target": "app.sdk.logging" + }, + "app.utils.coalesce": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.runtime.coalesce", + "target": "app.runtime.coalesce" + }, + "app.utils.common": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "sdk", + "replacement": "app.sdk.utilities", + "target": "app.sdk.utilities" + }, + "app.utils.crypto": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "foundation", + "replacement": "app.sdk.utilities", + "target": "app.foundation.crypto" + }, + "app.utils.debounce": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.runtime.debounce", + "target": "app.runtime.debounce" + }, + "app.utils.dom": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "foundation", + "replacement": "app.sdk.utilities", + "target": "app.foundation.dom" + }, + "app.utils.gc": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.runtime.gc", + "target": "app.runtime.gc" + }, + "app.utils.http": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "foundation", + "replacement": "app.sdk.network", + "target": "app.adapters.network.http" + }, + "app.utils.identity": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "foundation", + "replacement": "app.foundation.identity", + "target": "app.foundation.identity" + }, + "app.utils.ip": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.sdk.network", + "target": "app.adapters.network.ip" + }, + "app.utils.jieba": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "foundation", + "replacement": "app.sdk.utilities", + "target": "app.foundation.text" + }, + "app.utils.limit": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.runtime.rate", + "target": "app.runtime.rate" + }, + "app.utils.media": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "sdk", + "replacement": "app.sdk.media", + "target": "app.sdk.media" + }, + "app.utils.mixins": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.runtime.reload", + "target": "app.runtime.reload" + }, + "app.utils.object": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "foundation", + "replacement": "app.sdk.utilities", + "target": "app.foundation.reflection" + }, + "app.utils.otp": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.sdk.utilities", + "target": "app.application.security.otp" + }, + "app.utils.rust_accel": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.system.rust", + "target": "app.adapters.system.rust" + }, + "app.utils.security": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "application", + "replacement": "app.sdk.network", + "target": "app.application.security.url" + }, + "app.utils.singleton": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "foundation", + "replacement": "app.sdk.utilities", + "target": "app.foundation.singleton" + }, + "app.utils.site": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.network", + "target": "app.domain.site" + }, + "app.utils.stdio": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.adapters.system.stdio", + "target": "app.adapters.system.stdio" + }, + "app.utils.string": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "sdk", + "replacement": "app.sdk.utilities", + "target": "app.sdk.string" + }, + "app.utils.structures": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "foundation", + "replacement": "app.foundation.collections", + "target": "app.foundation.collections" + }, + "app.utils.system": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.sdk.utilities", + "target": "app.adapters.system.host" + }, + "app.utils.timer": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "runtime", + "replacement": "app.sdk.utilities", + "target": "app.runtime.scheduling" + }, + "app.utils.tokens": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.tokens" + }, + "app.utils.url": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "foundation", + "replacement": "app.sdk.network", + "target": "app.foundation.url" + }, + "app.utils.web": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "adapters", + "replacement": "app.sdk.network", + "target": "app.adapters.external.location" + }, + "app.utils.zhconv": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "foundation", + "replacement": "app.foundation.text", + "target": "app.foundation.text" + } + }, + "package_aliases": { + "app.core.meta": { + "introduced": "v3.0.0", + "is_package": true, + "owner": "domain", + "replacement": "app.sdk.media", + "target": "app.domain.meta" + } + }, + "package_exports": { + "app.core.meta": { + "MetaAnime": { + "replacement": "app.sdk.media.MetaAnime", + "target_module": "app.domain.meta.metaanime", + "target_name": "MetaAnime" + }, + "MetaBase": { + "replacement": "app.sdk.media.MetaBase", + "target_module": "app.domain.meta.metabase", + "target_name": "MetaBase" + }, + "MetaMusic": { + "replacement": "app.sdk.media.MetaMusic", + "target_module": "app.domain.meta.metamusic", + "target_name": "MetaMusic" + }, + "MetaVideo": { + "replacement": "app.sdk.media.MetaVideo", + "target_module": "app.domain.meta.metavideo", + "target_name": "MetaVideo" + }, + "MusicNameContext": { + "replacement": "app.sdk.media.MusicNameContext", + "target_module": "app.domain.meta.metamusic", + "target_name": "MusicNameContext" + }, + "MusicNameParseResult": { + "replacement": "app.sdk.media.MusicNameParseResult", + "target_module": "app.domain.meta.metamusic", + "target_name": "MusicNameParseResult" + }, + "MusicNameParser": { + "replacement": "app.sdk.media.MusicNameParser", + "target_module": "app.domain.meta.metamusic", + "target_name": "MusicNameParser" + }, + "MusicNamePattern": { + "replacement": "app.sdk.media.MusicNamePattern", + "target_module": "app.domain.meta.metamusic", + "target_name": "MusicNamePattern" + }, + "MusicNamePatternMatch": { + "replacement": "app.sdk.media.MusicNamePatternMatch", + "target_module": "app.domain.meta.metamusic", + "target_name": "MusicNamePatternMatch" + }, + "MusicNameRegistry": { + "replacement": "app.sdk.media.MusicNameRegistry", + "target_module": "app.domain.meta.metamusic", + "target_name": "MusicNameRegistry" + } + } + }, + "symbol_aliases": { + "app.agent.orchestrator": { + "AgentChain": { + "replacement": "app.chain.agent.AgentChain", + "target_module": "app.chain.agent", + "target_name": "AgentChain" + }, + "ReplyMode": { + "replacement": "app.schemas.types.ReplyMode", + "target_module": "app.schemas.types", + "target_name": "ReplyMode" + } + }, + "app.chain.media": { + "ScrapingChain": { + "replacement": "app.chain.scraping.ScrapingChain", + "target_module": "app.chain.scraping", + "target_name": "ScrapingChain" + }, + "ScrapingConfig": { + "replacement": "app.chain.scraping.ScrapingConfig", + "target_module": "app.chain.scraping", + "target_name": "ScrapingConfig" + }, + "ScrapingOption": { + "replacement": "app.chain.scraping.ScrapingOption", + "target_module": "app.chain.scraping", + "target_name": "ScrapingOption" + } + }, + "app.chain.message": { + "MediaInteractionChain": { + "replacement": "app.chain.interaction.MediaInteractionChain", + "target_module": "app.chain.interaction", + "target_name": "MediaInteractionChain" + } + }, + "app.domain.media": { + "MEDIA_SOURCE_ALIASES": { + "replacement": "app.schemas.media.MEDIA_SOURCE_ALIASES", + "target_module": "app.schemas.media", + "target_name": "MEDIA_SOURCE_ALIASES" + }, + "MEDIA_SOURCE_PREFIXES": { + "replacement": "app.schemas.media.MEDIA_SOURCE_PREFIXES", + "target_module": "app.schemas.media", + "target_name": "MEDIA_SOURCE_PREFIXES" + }, + "build_media_key": { + "replacement": "app.schemas.media.build_media_key", + "target_module": "app.schemas.media", + "target_name": "build_media_key" + }, + "normalize_media_identity_payload": { + "replacement": "app.schemas.media.normalize_media_identity_payload", + "target_module": "app.schemas.media", + "target_name": "normalize_media_identity_payload" + }, + "normalize_media_source": { + "replacement": "app.schemas.media.normalize_media_source", + "target_module": "app.schemas.media", + "target_name": "normalize_media_source" + }, + "parse_media_key": { + "replacement": "app.schemas.media.parse_media_key", + "target_module": "app.schemas.media", + "target_name": "parse_media_key" + }, + "resolve_media_identity": { + "replacement": "app.schemas.media.resolve_media_identity", + "target_module": "app.schemas.media", + "target_name": "resolve_media_identity" + } + }, + "app.schemas": { + "ChannelCapabilities": { + "replacement": "app.schemas.notification.ChannelCapabilities", + "target_module": "app.schemas.notification", + "target_name": "ChannelCapabilities" + }, + "ChannelCapability": { + "replacement": "app.schemas.notification.ChannelCapability", + "target_module": "app.schemas.notification", + "target_name": "ChannelCapability" + }, + "ChannelCapabilityManager": { + "replacement": "app.schemas.notification.ChannelCapabilityManager", + "target_module": "app.schemas.notification", + "target_name": "ChannelCapabilityManager" + }, + "CommingMessage": { + "replacement": "app.schemas.message.IncomingMessage", + "target_module": "app.schemas.message", + "target_name": "IncomingMessage" + }, + "MessageChannel": { + "replacement": "app.schemas.types.NotificationChannel", + "target_module": "app.schemas.types", + "target_name": "NotificationChannel" + }, + "Notification": { + "replacement": "app.schemas.message.Message", + "target_module": "app.schemas.message", + "target_name": "Message" + }, + "NotificationClearBefore": { + "replacement": "app.schemas.message.MessageClearBefore", + "target_module": "app.schemas.message", + "target_name": "MessageClearBefore" + }, + "NotificationClearData": { + "replacement": "app.schemas.message.MessageClearData", + "target_module": "app.schemas.message", + "target_name": "MessageClearData" + }, + "NotificationClearScope": { + "replacement": "app.schemas.message.MessageClearScope", + "target_module": "app.schemas.message", + "target_name": "MessageClearScope" + }, + "NotificationHistoryItem": { + "replacement": "app.schemas.message.MessageHistoryItem", + "target_module": "app.schemas.message", + "target_name": "MessageHistoryItem" + }, + "NotificationType": { + "replacement": "app.schemas.types.MessageType", + "target_module": "app.schemas.types", + "target_name": "MessageType" + }, + "TransferQueue": { + "replacement": "app.application.transfer.TransferQueue", + "target_module": "app.sdk._legacy.transfer", + "target_name": "TransferQueue" + }, + "TransferTask": { + "replacement": "app.application.transfer.TransferTask", + "target_module": "app.sdk._legacy.transfer", + "target_name": "TransferTask" + } + }, + "app.schemas.agent": { + "ReplyMode": { + "replacement": "app.schemas.types.ReplyMode", + "target_module": "app.schemas.types", + "target_name": "ReplyMode" + } + }, + "app.schemas.message": { + "ChannelCapabilities": { + "replacement": "app.schemas.notification.ChannelCapabilities", + "target_module": "app.schemas.notification", + "target_name": "ChannelCapabilities" + }, + "ChannelCapability": { + "replacement": "app.schemas.notification.ChannelCapability", + "target_module": "app.schemas.notification", + "target_name": "ChannelCapability" + }, + "ChannelCapabilityManager": { + "replacement": "app.schemas.notification.ChannelCapabilityManager", + "target_module": "app.schemas.notification", + "target_name": "ChannelCapabilityManager" + }, + "CommingMessage": { + "replacement": "app.schemas.message.IncomingMessage", + "target_module": "app.schemas.message", + "target_name": "IncomingMessage" + }, + "MessageChannel": { + "replacement": "app.schemas.types.NotificationChannel", + "target_module": "app.schemas.types", + "target_name": "NotificationChannel" + }, + "Notification": { + "replacement": "app.schemas.message.Message", + "target_module": "app.schemas.message", + "target_name": "Message" + }, + "NotificationClearBefore": { + "replacement": "app.schemas.message.MessageClearBefore", + "target_module": "app.schemas.message", + "target_name": "MessageClearBefore" + }, + "NotificationClearData": { + "replacement": "app.schemas.message.MessageClearData", + "target_module": "app.schemas.message", + "target_name": "MessageClearData" + }, + "NotificationClearScope": { + "replacement": "app.schemas.message.MessageClearScope", + "target_module": "app.schemas.message", + "target_name": "MessageClearScope" + }, + "NotificationHistoryItem": { + "replacement": "app.schemas.message.MessageHistoryItem", + "target_module": "app.schemas.message", + "target_name": "MessageHistoryItem" + }, + "NotificationType": { + "replacement": "app.schemas.types.MessageType", + "target_module": "app.schemas.types", + "target_name": "MessageType" + } + }, + "app.schemas.transfer": { + "DownloadHistory": { + "replacement": "app.schemas.history.DownloadHistory", + "target_module": "app.schemas.history", + "target_name": "DownloadHistory" + }, + "MediaType": { + "replacement": "app.schemas.types.MediaType", + "target_module": "app.schemas.types", + "target_name": "MediaType" + }, + "TmdbEpisode": { + "replacement": "app.schemas.tmdb.TmdbEpisode", + "target_module": "app.schemas.tmdb", + "target_name": "TmdbEpisode" + }, + "TransferDirectoryConf": { + "replacement": "app.schemas.system.TransferDirectoryConf", + "target_module": "app.schemas.system", + "target_name": "TransferDirectoryConf" + }, + "TransferQueue": { + "replacement": "app.application.transfer.TransferQueue", + "target_module": "app.sdk._legacy.transfer", + "target_name": "TransferQueue" + }, + "TransferTask": { + "replacement": "app.application.transfer.TransferTask", + "target_module": "app.sdk._legacy.transfer", + "target_name": "TransferTask" + } + }, + "app.schemas.types": { + "MessageChannel": { + "replacement": "app.schemas.types.NotificationChannel", + "target_module": "app.schemas.types", + "target_name": "NotificationChannel" + }, + "NotificationType": { + "replacement": "app.schemas.types.MessageType", + "target_module": "app.schemas.types", + "target_name": "MessageType" + } + }, + "app.sdk.logging": { + "CustomFormatter": { + "replacement": "app.runtime.log.CustomFormatter", + "target_module": "app.runtime.log", + "target_name": "CustomFormatter" + }, + "LogConfigModel": { + "replacement": "app.runtime.log.LogConfigModel", + "target_module": "app.runtime.log", + "target_name": "LogConfigModel" + }, + "LogEntry": { + "replacement": "app.runtime.log.LogEntry", + "target_module": "app.runtime.log", + "target_name": "LogEntry" + }, + "LogSettings": { + "replacement": "app.runtime.log.LogSettings", + "target_module": "app.runtime.log", + "target_name": "LogSettings" + }, + "LoggerManager": { + "replacement": "app.runtime.log.LoggerManager", + "target_module": "app.runtime.log", + "target_name": "LoggerManager" + }, + "NonBlockingFileHandler": { + "replacement": "app.runtime.log.NonBlockingFileHandler", + "target_module": "app.runtime.log", + "target_name": "NonBlockingFileHandler" + }, + "configure_log_settings": { + "replacement": "app.runtime.log.configure_log_settings", + "target_module": "app.runtime.log", + "target_name": "configure_log_settings" + }, + "configure_log_writer": { + "replacement": "app.runtime.log.configure_log_writer", + "target_module": "app.runtime.log", + "target_name": "configure_log_writer" + }, + "log_settings": { + "replacement": "app.runtime.log.log_settings", + "target_module": "app.runtime.log", + "target_name": "log_settings" + } + } + }, + "virtual_packages": [ + "app.core", + "app.helper", + "app.utils" + ] + }, + "events": { + "consumer_count": 15, + "dynamic_consumers": [ + { + "caller": "app.adapters.system.fsproxy", + "line": 392 + }, + { + "caller": "app.agent.llm.provider", + "line": 2253 + }, + { + "caller": "app.chain.transfer", + "line": 608 + }, + { + "caller": "app.db.oper.transferpending", + "line": 23 + }, + { + "caller": "app.runtime.events", + "line": 138 + }, + { + "caller": "app.runtime.events", + "line": 461 + }, + { + "caller": "app.runtime.events", + "line": 465 + }, + { + "caller": "app.testing.bootstrap", + "line": 111 + }, + { + "caller": "app.workflow", + "line": 311 + } + ], + "dynamic_producers": [ + { + "caller": "app.agent.tools.impl._filter_rule_utils", + "line": 433 + }, + { + "caller": "app.agent.tools.impl.update_system_settings", + "line": 276 + }, + { + "caller": "app.api.endpoints.system", + "line": 816 + }, + { + "caller": "app.api.endpoints.system", + "line": 930 + }, + { + "caller": "app.api.endpoints.system", + "line": 984 + }, + { + "caller": "app.api.endpoints.system", + "line": 998 + }, + { + "caller": "app.chain._messaging", + "line": 217 + }, + { + "caller": "app.chain._messaging", + "line": 227 + }, + { + "caller": "app.chain._messaging", + "line": 333 + }, + { + "caller": "app.chain._messaging", + "line": 343 + }, + { + "caller": "app.chain._recognition", + "line": 460 + }, + { + "caller": "app.chain._recognition", + "line": 500 + }, + { + "caller": "app.chain.user", + "line": 160 + }, + { + "caller": "app.chain.user", + "line": 223 + }, + { + "caller": "app.command", + "line": 440 + }, + { + "caller": "app.modules._base.mediaserver", + "line": 56 + }, + { + "caller": "app.modules.navidrome", + "line": 91 + }, + { + "caller": "app.modules.plex", + "line": 99 + }, + { + "caller": "app.runtime.extensions.module_manager", + "line": 238 + } + ], + "event_count": 53, + "events": { + "ChainEventType.AgentLLMProvider": { + "consumers": [], + "producers": [ + { + "caller": "app.agent.orchestrator", + "line": 1272 + } + ] + }, + "ChainEventType.AuthIntercept": { + "consumers": [], + "producers": [] + }, + "ChainEventType.AuthVerification": { + "consumers": [], + "producers": [] + }, + "ChainEventType.CommandRegister": { + "consumers": [], + "producers": [ + { + "caller": "app.command", + "line": 267 + }, + { + "caller": "app.modules._base.notification", + "line": 92 + } + ] + }, + "ChainEventType.DiscoverSource": { + "consumers": [], + "producers": [ + { + "caller": "app.api.endpoints.discover", + "line": 31 + } + ] + }, + "ChainEventType.MediaRecognize": { + "consumers": [], + "producers": [] + }, + "ChainEventType.MediaRecognizeConvert": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.media", + "line": 1502 + }, + { + "caller": "app.chain.media", + "line": 2048 + } + ] + }, + "ChainEventType.MusicMediaRecognize": { + "consumers": [], + "producers": [] + }, + "ChainEventType.MusicNameRecognize": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.media", + "line": 815 + }, + { + "caller": "app.chain.media", + "line": 1753 + } + ] + }, + "ChainEventType.NameRecognize": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.media", + "line": 753 + }, + { + "caller": "app.chain.media", + "line": 1691 + } + ] + }, + "ChainEventType.PluginDataReset": { + "consumers": [], + "producers": [ + { + "caller": "app.api.deps", + "line": 230 + } + ] + }, + "ChainEventType.RecommendSource": { + "consumers": [], + "producers": [ + { + "caller": "app.api.endpoints.recommend", + "line": 42 + }, + { + "caller": "app.workflow.actions.fetch_medias", + "line": 111 + } + ] + }, + "ChainEventType.ResourceDownload": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.download", + "line": 1052 + } + ] + }, + "ChainEventType.ResourceSelection": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.download", + "line": 1419 + } + ] + }, + "ChainEventType.StorageOperSelection": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.transfer", + "line": 1094 + }, + { + "caller": "app.chain.transfer", + "line": 1110 + } + ] + }, + "ChainEventType.SubscribeCompletionCheck": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.subscribe", + "line": 2786 + } + ] + }, + "ChainEventType.SubscribeEpisodesRefresh": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.subscribe", + "line": 3524 + }, + { + "caller": "app.chain.subscribe", + "line": 3546 + } + ] + }, + "ChainEventType.TransferIntercept": { + "consumers": [], + "producers": [ + { + "caller": "app.modules.filemanager.transhandler", + "line": 983 + }, + { + "caller": "app.modules.filemanager.transhandler", + "line": 1105 + } + ] + }, + "ChainEventType.TransferOverwriteCheck": { + "consumers": [], + "producers": [ + { + "caller": "app.modules.filemanager.transhandler", + "line": 509 + } + ] + }, + "ChainEventType.TransferRename": { + "consumers": [], + "producers": [ + { + "caller": "app.modules.filemanager.transhandler", + "line": 1329 + } + ] + }, + "ChainEventType.TransferRenameBuild": { + "consumers": [], + "producers": [ + { + "caller": "app.modules.filemanager.transhandler", + "line": 1308 + } + ] + }, + "ChainEventType.WorkflowExecution": { + "consumers": [], + "producers": [ + { + "caller": "app.workflow.actions.send_event", + "line": 46 + } + ] + }, + "EventType.AgentTokensUsage": { + "consumers": [], + "producers": [ + { + "caller": "app.agent.orchestrator", + "line": 865 + } + ] + }, + "EventType.AudioTransferComplete": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.transfer", + "line": 388 + } + ] + }, + "EventType.AudioTransferFailed": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.transfer", + "line": 267 + } + ] + }, + "EventType.CommandExcute": { + "consumers": [ + { + "caller": "app.command", + "line": 442 + } + ], + "producers": [ + { + "caller": "app.agent.tools.impl.run_slash_command", + "line": 90 + }, + { + "caller": "app.chain.message", + "line": 443 + } + ] + }, + "EventType.ConfigChanged": { + "consumers": [ + { + "caller": "app.runtime.extensions.module_manager", + "line": 69 + }, + { + "caller": "app.runtime.reload", + "line": 81 + }, + { + "caller": "app.startup.agent_initializer", + "line": 91 + } + ], + "producers": [] + }, + "EventType.DownloadAdded": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.download", + "line": 1240 + } + ] + }, + "EventType.DownloadDeleted": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.download", + "line": 2085 + } + ] + }, + "EventType.DownloadFileDeleted": { + "consumers": [ + { + "caller": "app.chain.download", + "line": 2069 + } + ], + "producers": [ + { + "caller": "app.api.deps", + "line": 216 + } + ] + }, + "EventType.HistoryDeleted": { + "consumers": [], + "producers": [] + }, + "EventType.MessageAction": { + "consumers": [], + "producers": [ + { + "caller": "app.application.messaging.plugin", + "line": 414 + }, + { + "caller": "app.application.messaging.plugin", + "line": 445 + }, + { + "caller": "app.application.messaging.plugin", + "line": 475 + }, + { + "caller": "app.chain.message", + "line": 687 + } + ] + }, + "EventType.MetadataScrape": { + "consumers": [ + { + "caller": "app.chain.scraping", + "line": 586 + } + ], + "producers": [ + { + "caller": "app.chain._transfer", + "line": 460 + }, + { + "caller": "app.chain._transfer", + "line": 635 + } + ] + }, + "EventType.ModuleReload": { + "consumers": [ + { + "caller": "app.command", + "line": 476 + } + ], + "producers": [] + }, + "EventType.NoticeMessage": { + "consumers": [ + { + "caller": "app.api.endpoints.agent", + "line": 1337 + } + ], + "producers": [] + }, + "EventType.PluginAction": { + "consumers": [], + "producers": [] + }, + "EventType.PluginReload": { + "consumers": [ + { + "caller": "app.scheduler", + "line": 1046 + } + ], + "producers": [ + { + "caller": "app.runtime.extensions.plugin_manager", + "line": 756 + } + ] + }, + "EventType.PluginTriggered": { + "consumers": [], + "producers": [] + }, + "EventType.SiteDeleted": { + "consumers": [ + { + "caller": "app.chain.search", + "line": 2861 + }, + { + "caller": "app.chain.subscribe", + "line": 3001 + } + ], + "producers": [ + { + "caller": "app.api.deps", + "line": 133 + }, + { + "caller": "app.api.endpoints.site", + "line": 199 + } + ] + }, + "EventType.SiteRefreshed": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.site", + "line": 74 + }, + { + "caller": "app.chain.site", + "line": 163 + } + ] + }, + "EventType.SiteUpdated": { + "consumers": [ + { + "caller": "app.chain.site", + "line": 586 + }, + { + "caller": "app.chain.site", + "line": 628 + }, + { + "caller": "app.chain.site", + "line": 650 + } + ], + "producers": [ + { + "caller": "app.agent.tools.impl.update_site", + "line": 200 + }, + { + "caller": "app.api.deps", + "line": 128 + }, + { + "caller": "app.chain.site", + "line": 566 + } + ] + }, + "EventType.SubscribeAdded": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.subscribe", + "line": 996 + }, + { + "caller": "app.chain.subscribe", + "line": 1200 + } + ] + }, + "EventType.SubscribeComplete": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.subscribe", + "line": 2824 + } + ] + }, + "EventType.SubscribeDeleted": { + "consumers": [], + "producers": [ + { + "caller": "app.agent.tools.impl.delete_subscribe", + "line": 70 + }, + { + "caller": "app.api.deps", + "line": 64 + } + ] + }, + "EventType.SubscribeModified": { + "consumers": [], + "producers": [ + { + "caller": "app.agent.tools.impl.update_subscribe", + "line": 316 + }, + { + "caller": "app.api.endpoints.subscribe", + "line": 333 + }, + { + "caller": "app.api.endpoints.subscribe", + "line": 366 + }, + { + "caller": "app.api.endpoints.subscribe", + "line": 444 + } + ] + }, + "EventType.SubtitleTransferComplete": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.transfer", + "line": 374 + } + ] + }, + "EventType.SubtitleTransferFailed": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.transfer", + "line": 253 + } + ] + }, + "EventType.SystemError": { + "consumers": [], + "producers": [ + { + "caller": "app.chain", + "line": 136 + }, + { + "caller": "app.chain", + "line": 162 + }, + { + "caller": "app.runtime.events", + "line": 114 + }, + { + "caller": "app.scheduler", + "line": 696 + } + ] + }, + "EventType.TransferComplete": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.transfer", + "line": 360 + } + ] + }, + "EventType.TransferFailed": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.transfer", + "line": 239 + } + ] + }, + "EventType.UserMessage": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.message", + "line": 505 + } + ] + }, + "EventType.WebhookMessage": { + "consumers": [], + "producers": [ + { + "caller": "app.chain.webhook", + "line": 21 + } + ] + }, + "EventType.WorkflowExecute": { + "consumers": [ + { + "caller": "app.chain.workflow", + "line": 1161 + } + ], + "producers": [ + { + "caller": "app.workflow", + "line": 379 + } + ] + } + }, + "producer_count": 68 + }, + "run_module": { + "call_count": 259, + "dynamic_call_count": 0, + "dynamic_calls": [], + "method_count": 211, + "methods": { + "anilist_credits": [ + { + "caller": "app.chain.anilist", + "line": 95, + "mode": "sync" + } + ], + "anilist_discover": [ + { + "caller": "app.chain.anilist", + "line": 77, + "mode": "sync" + } + ], + "anilist_info": [ + { + "caller": "app.chain.anilist", + "line": 20, + "mode": "sync" + } + ], + "anilist_person_credits": [ + { + "caller": "app.chain.anilist", + "line": 164, + "mode": "sync" + } + ], + "anilist_person_detail": [ + { + "caller": "app.chain.anilist", + "line": 144, + "mode": "sync" + } + ], + "anilist_popular_this_season": [ + { + "caller": "app.chain.anilist", + "line": 55, + "mode": "sync" + } + ], + "anilist_recommendations": [ + { + "caller": "app.chain.anilist", + "line": 119, + "mode": "sync" + } + ], + "anilist_trending": [ + { + "caller": "app.chain.anilist", + "line": 37, + "mode": "sync" + } + ], + "any_files": [ + { + "caller": "app.chain.storage", + "line": 41, + "mode": "sync" + } + ], + "async_anilist_credits": [ + { + "caller": "app.chain.anilist", + "line": 107, + "mode": "async" + } + ], + "async_anilist_discover": [ + { + "caller": "app.chain.anilist", + "line": 85, + "mode": "async" + } + ], + "async_anilist_info": [ + { + "caller": "app.chain.anilist", + "line": 29, + "mode": "async" + } + ], + "async_anilist_person_credits": [ + { + "caller": "app.chain.anilist", + "line": 176, + "mode": "async" + } + ], + "async_anilist_person_detail": [ + { + "caller": "app.chain.anilist", + "line": 152, + "mode": "async" + } + ], + "async_anilist_popular_this_season": [ + { + "caller": "app.chain.anilist", + "line": 67, + "mode": "async" + } + ], + "async_anilist_recommendations": [ + { + "caller": "app.chain.anilist", + "line": 131, + "mode": "async" + } + ], + "async_anilist_trending": [ + { + "caller": "app.chain.anilist", + "line": 45, + "mode": "async" + } + ], + "async_bangumi_calendar": [ + { + "caller": "app.chain.bangumi", + "line": 65, + "mode": "async" + }, + { + "caller": "app.chain.recommend", + "line": 560, + "mode": "async" + } + ], + "async_bangumi_credits": [ + { + "caller": "app.chain.bangumi", + "line": 86, + "mode": "async" + } + ], + "async_bangumi_discover": [ + { + "caller": "app.chain.bangumi", + "line": 71, + "mode": "async" + } + ], + "async_bangumi_info": [ + { + "caller": "app.chain", + "line": 455, + "mode": "async" + }, + { + "caller": "app.chain.bangumi", + "line": 79, + "mode": "async" + } + ], + "async_bangumi_person_credits": [ + { + "caller": "app.chain.bangumi", + "line": 107, + "mode": "async" + } + ], + "async_bangumi_person_detail": [ + { + "caller": "app.chain.bangumi", + "line": 100, + "mode": "async" + } + ], + "async_bangumi_recommend": [ + { + "caller": "app.chain.bangumi", + "line": 93, + "mode": "async" + } + ], + "async_douban_discover": [ + { + "caller": "app.chain.douban", + "line": 382, + "mode": "async" + }, + { + "caller": "app.chain.recommend", + "line": 606, + "mode": "async" + }, + { + "caller": "app.chain.recommend", + "line": 617, + "mode": "async" + } + ], + "async_douban_info": [ + { + "caller": "app.chain", + "line": 392, + "mode": "async" + } + ], + "async_douban_movie_credits": [ + { + "caller": "app.chain.douban", + "line": 411, + "mode": "async" + } + ], + "async_douban_movie_recommend": [ + { + "caller": "app.chain.douban", + "line": 425, + "mode": "async" + } + ], + "async_douban_person_credits": [ + { + "caller": "app.chain.douban", + "line": 339, + "mode": "async" + } + ], + "async_douban_person_detail": [ + { + "caller": "app.chain.douban", + "line": 331, + "mode": "async" + } + ], + "async_douban_tv_credits": [ + { + "caller": "app.chain.douban", + "line": 418, + "mode": "async" + } + ], + "async_douban_tv_recommend": [ + { + "caller": "app.chain.douban", + "line": 432, + "mode": "async" + } + ], + "async_identify_music_by_fingerprint": [ + { + "caller": "app.chain.acoustid", + "line": 26, + "mode": "async" + } + ], + "async_match_doubaninfo": [ + { + "caller": "app.chain", + "line": 265, + "mode": "async" + } + ], + "async_match_music_album": [ + { + "caller": "app.chain.musicbrainz", + "line": 283, + "mode": "async" + } + ], + "async_match_tmdbinfo": [ + { + "caller": "app.chain", + "line": 307, + "mode": "async" + } + ], + "async_movie_hot": [ + { + "caller": "app.chain.douban", + "line": 397, + "mode": "async" + }, + { + "caller": "app.chain.recommend", + "line": 663, + "mode": "async" + } + ], + "async_movie_showing": [ + { + "caller": "app.chain.douban", + "line": 355, + "mode": "async" + }, + { + "caller": "app.chain.recommend", + "line": 569, + "mode": "async" + } + ], + "async_movie_top250": [ + { + "caller": "app.chain.douban", + "line": 348, + "mode": "async" + }, + { + "caller": "app.chain.recommend", + "line": 627, + "mode": "async" + } + ], + "async_obtain_images": [ + { + "caller": "app.chain", + "line": 329, + "mode": "async" + } + ], + "async_recognize_media": [ + { + "caller": "app.chain._recognition", + "line": 108, + "mode": "async" + }, + { + "caller": "app.chain.douban", + "line": 70, + "mode": "async" + }, + { + "caller": "app.chain.musicbrainz", + "line": 63, + "mode": "async" + } + ], + "async_refresh_torrents": [ + { + "caller": "app.chain", + "line": 687, + "mode": "async" + } + ], + "async_search_collections": [ + { + "caller": "app.chain", + "line": 561, + "mode": "async" + } + ], + "async_search_medias": [ + { + "caller": "app.chain", + "line": 509, + "mode": "async" + } + ], + "async_search_persons": [ + { + "caller": "app.chain", + "line": 535, + "mode": "async" + } + ], + "async_search_subtitles": [ + { + "caller": "app.chain", + "line": 645, + "mode": "async" + } + ], + "async_search_torrents": [ + { + "caller": "app.chain", + "line": 628, + "mode": "async" + } + ], + "async_tmdb_collection": [ + { + "caller": "app.chain.tmdb", + "line": 218, + "mode": "async" + } + ], + "async_tmdb_discover": [ + { + "caller": "app.chain.recommend", + "line": 498, + "mode": "async" + }, + { + "caller": "app.chain.recommend", + "line": 526, + "mode": "async" + }, + { + "caller": "app.chain.tmdb", + "line": 194, + "mode": "async" + } + ], + "async_tmdb_episodes": [ + { + "caller": "app.chain.tmdb", + "line": 242, + "mode": "async" + } + ], + "async_tmdb_group_seasons": [ + { + "caller": "app.chain.tmdb", + "line": 232, + "mode": "async" + } + ], + "async_tmdb_info": [ + { + "caller": "app.chain", + "line": 437, + "mode": "async" + } + ], + "async_tmdb_movie_credits": [ + { + "caller": "app.chain.tmdb", + "line": 279, + "mode": "async" + } + ], + "async_tmdb_movie_recommend": [ + { + "caller": "app.chain.tmdb", + "line": 264, + "mode": "async" + } + ], + "async_tmdb_movie_similar": [ + { + "caller": "app.chain.tmdb", + "line": 250, + "mode": "async" + } + ], + "async_tmdb_person_credits": [ + { + "caller": "app.chain.tmdb", + "line": 302, + "mode": "async" + } + ], + "async_tmdb_person_detail": [ + { + "caller": "app.chain.tmdb", + "line": 294, + "mode": "async" + } + ], + "async_tmdb_seasons": [ + { + "caller": "app.chain.tmdb", + "line": 225, + "mode": "async" + } + ], + "async_tmdb_trending": [ + { + "caller": "app.chain.recommend", + "line": 547, + "mode": "async" + }, + { + "caller": "app.chain.tmdb", + "line": 211, + "mode": "async" + } + ], + "async_tmdb_tv_credits": [ + { + "caller": "app.chain.tmdb", + "line": 287, + "mode": "async" + } + ], + "async_tmdb_tv_recommend": [ + { + "caller": "app.chain.tmdb", + "line": 271, + "mode": "async" + } + ], + "async_tmdb_tv_similar": [ + { + "caller": "app.chain.tmdb", + "line": 257, + "mode": "async" + } + ], + "async_tv_animation": [ + { + "caller": "app.chain.douban", + "line": 390, + "mode": "async" + }, + { + "caller": "app.chain.recommend", + "line": 654, + "mode": "async" + } + ], + "async_tv_hot": [ + { + "caller": "app.chain.douban", + "line": 404, + "mode": "async" + }, + { + "caller": "app.chain.recommend", + "line": 672, + "mode": "async" + } + ], + "async_tv_weekly_chinese": [ + { + "caller": "app.chain.douban", + "line": 362, + "mode": "async" + }, + { + "caller": "app.chain.recommend", + "line": 636, + "mode": "async" + } + ], + "async_tv_weekly_global": [ + { + "caller": "app.chain.douban", + "line": 369, + "mode": "async" + }, + { + "caller": "app.chain.recommend", + "line": 645, + "mode": "async" + } + ], + "async_update_recognize_cache": [ + { + "caller": "app.chain._recognition", + "line": 78, + "mode": "async" + } + ], + "bangumi_calendar": [ + { + "caller": "app.chain.bangumi", + "line": 17, + "mode": "sync" + } + ], + "bangumi_credits": [ + { + "caller": "app.chain.bangumi", + "line": 38, + "mode": "sync" + } + ], + "bangumi_discover": [ + { + "caller": "app.chain.bangumi", + "line": 23, + "mode": "sync" + } + ], + "bangumi_info": [ + { + "caller": "app.chain", + "line": 447, + "mode": "sync" + }, + { + "caller": "app.chain.bangumi", + "line": 31, + "mode": "sync" + } + ], + "bangumi_person_credits": [ + { + "caller": "app.chain.bangumi", + "line": 59, + "mode": "sync" + } + ], + "bangumi_person_detail": [ + { + "caller": "app.chain.bangumi", + "line": 52, + "mode": "sync" + } + ], + "bangumi_recommend": [ + { + "caller": "app.chain.bangumi", + "line": 45, + "mode": "sync" + } + ], + "channel_manage": [ + { + "caller": "app.chain.notification", + "line": 28, + "mode": "sync" + } + ], + "clear_cache": [ + { + "caller": "app.chain", + "line": 1043, + "mode": "sync" + } + ], + "create_folder": [ + { + "caller": "app.chain.storage", + "line": 47, + "mode": "sync" + } + ], + "delete_file": [ + { + "caller": "app.chain.storage", + "line": 77, + "mode": "sync" + } + ], + "delete_message": [ + { + "caller": "app.chain._messaging", + "line": 410, + "mode": "sync" + } + ], + "douban_discover": [ + { + "caller": "app.chain.douban", + "line": 277, + "mode": "sync" + } + ], + "douban_info": [ + { + "caller": "app.chain", + "line": 372, + "mode": "sync" + } + ], + "douban_movie_credits": [ + { + "caller": "app.chain.douban", + "line": 303, + "mode": "sync" + } + ], + "douban_movie_recommend": [ + { + "caller": "app.chain.douban", + "line": 317, + "mode": "sync" + } + ], + "douban_person_credits": [ + { + "caller": "app.chain.douban", + "line": 238, + "mode": "sync" + } + ], + "douban_person_detail": [ + { + "caller": "app.chain.douban", + "line": 230, + "mode": "sync" + } + ], + "douban_tv_credits": [ + { + "caller": "app.chain.douban", + "line": 310, + "mode": "sync" + } + ], + "douban_tv_recommend": [ + { + "caller": "app.chain.douban", + "line": 324, + "mode": "sync" + } + ], + "download": [ + { + "caller": "app.chain", + "line": 732, + "mode": "sync" + } + ], + "download_added": [ + { + "caller": "app.chain", + "line": 756, + "mode": "sync" + } + ], + "download_discord_file_bytes": [ + { + "caller": "app.chain.message", + "line": 1412, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1742, + "mode": "sync" + } + ], + "download_feishu_file_bytes": [ + { + "caller": "app.chain.message", + "line": 1446, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1734, + "mode": "sync" + } + ], + "download_feishu_image_to_data_url": [ + { + "caller": "app.chain.message", + "line": 1553, + "mode": "sync" + } + ], + "download_file": [ + { + "caller": "app.chain.storage", + "line": 61, + "mode": "sync" + } + ], + "download_qq_file_bytes": [ + { + "caller": "app.chain.message", + "line": 1419, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1746, + "mode": "sync" + } + ], + "download_slack_file_bytes": [ + { + "caller": "app.chain.message", + "line": 1405, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1738, + "mode": "sync" + } + ], + "download_slack_file_to_data_url": [ + { + "caller": "app.chain.message", + "line": 1561, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1759, + "mode": "sync" + } + ], + "download_synologychat_file_bytes": [ + { + "caller": "app.chain.message", + "line": 1435, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1754, + "mode": "sync" + } + ], + "download_telegram_file_bytes": [ + { + "caller": "app.chain.message", + "line": 1378, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1384, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1699, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1704, + "mode": "sync" + } + ], + "download_telegram_file_to_base64": [ + { + "caller": "app.chain.message", + "line": 1531, + "mode": "sync" + } + ], + "download_vocechat_file_bytes": [ + { + "caller": "app.chain.message", + "line": 1426, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1750, + "mode": "sync" + } + ], + "download_vocechat_image_to_data_url": [ + { + "caller": "app.chain.message", + "line": 1569, + "mode": "sync" + } + ], + "download_wechat_image_to_data_url": [ + { + "caller": "app.chain.message", + "line": 1545, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1716, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1721, + "mode": "sync" + } + ], + "download_wechat_media_bytes": [ + { + "caller": "app.chain.message", + "line": 1389, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1396, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1708, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1712, + "mode": "sync" + }, + { + "caller": "app.chain.message", + "line": 1730, + "mode": "sync" + } + ], + "downloader_info": [ + { + "caller": "app.chain.dashboard", + "line": 22, + "mode": "sync" + } + ], + "edit_message": [ + { + "caller": "app.chain._messaging", + "line": 456, + "mode": "sync" + } + ], + "filter_torrents": [ + { + "caller": "app.chain", + "line": 704, + "mode": "sync" + } + ], + "finalize_message": [ + { + "caller": "app.chain._messaging", + "line": 488, + "mode": "sync" + } + ], + "get_file_item": [ + { + "caller": "app.chain.storage", + "line": 101, + "mode": "sync" + } + ], + "get_folder": [ + { + "caller": "app.chain.storage", + "line": 53, + "mode": "sync" + } + ], + "get_parent_item": [ + { + "caller": "app.chain.storage", + "line": 107, + "mode": "sync" + } + ], + "get_search_page_size": [ + { + "caller": "app.chain", + "line": 573, + "mode": "sync" + } + ], + "get_torrent_trackers": [ + { + "caller": "app.chain", + "line": 950, + "mode": "sync" + } + ], + "identify_music_by_fingerprint": [ + { + "caller": "app.chain.acoustid", + "line": 15, + "mode": "sync" + } + ], + "list_files": [ + { + "caller": "app.chain.storage", + "line": 35, + "mode": "sync" + } + ], + "list_torrents": [ + { + "caller": "app.chain", + "line": 778, + "mode": "sync" + } + ], + "load_category_config": [ + { + "caller": "app.chain", + "line": 1019, + "mode": "sync" + } + ], + "mark_message_processing_finished": [ + { + "caller": "app.chain._messaging", + "line": 85, + "mode": "sync" + } + ], + "mark_message_processing_started": [ + { + "caller": "app.chain._messaging", + "line": 47, + "mode": "sync" + } + ], + "match_doubaninfo": [ + { + "caller": "app.chain", + "line": 237, + "mode": "sync" + } + ], + "match_music_album": [ + { + "caller": "app.chain.musicbrainz", + "line": 268, + "mode": "sync" + } + ], + "match_tmdbinfo": [ + { + "caller": "app.chain", + "line": 289, + "mode": "sync" + } + ], + "media_category": [ + { + "caller": "app.chain", + "line": 1013, + "mode": "sync" + } + ], + "media_exists": [ + { + "caller": "app.chain", + "line": 980, + "mode": "sync" + } + ], + "media_files": [ + { + "caller": "app.chain", + "line": 990, + "mode": "sync" + } + ], + "media_statistic": [ + { + "caller": "app.chain.dashboard", + "line": 16, + "mode": "sync" + }, + { + "caller": "app.chain.mediaserver", + "line": 163, + "mode": "sync" + } + ], + "mediaserver_image_cookies": [ + { + "caller": "app.chain.mediaserver", + "line": 269, + "mode": "sync" + } + ], + "mediaserver_iteminfo": [ + { + "caller": "app.chain.mediaserver", + "line": 177, + "mode": "sync" + } + ], + "mediaserver_items": [ + { + "caller": "app.chain.mediaserver", + "line": 139, + "mode": "sync" + } + ], + "mediaserver_items_count": [ + { + "caller": "app.chain.mediaserver", + "line": 150, + "mode": "sync" + } + ], + "mediaserver_latest": [ + { + "caller": "app.chain.mediaserver", + "line": 205, + "mode": "sync" + } + ], + "mediaserver_latest_images": [ + { + "caller": "app.chain.mediaserver", + "line": 218, + "mode": "sync" + } + ], + "mediaserver_librarys": [ + { + "caller": "app.chain.mediaserver", + "line": 92, + "mode": "sync" + } + ], + "mediaserver_play_url": [ + { + "caller": "app.chain.mediaserver", + "line": 243, + "mode": "sync" + } + ], + "mediaserver_playing": [ + { + "caller": "app.chain.mediaserver", + "line": 191, + "mode": "sync" + } + ], + "mediaserver_season_episode_ids": [ + { + "caller": "app.chain.mediaserver", + "line": 255, + "mode": "sync" + } + ], + "mediaserver_tv_episodes": [ + { + "caller": "app.chain.mediaserver", + "line": 183, + "mode": "sync" + } + ], + "message_parser": [ + { + "caller": "app.chain", + "line": 471, + "mode": "sync" + } + ], + "metadata_img": [ + { + "caller": "app.chain", + "line": 1004, + "mode": "sync" + } + ], + "metadata_nfo": [ + { + "caller": "app.chain.scraping", + "line": 552, + "mode": "sync" + } + ], + "movie_hot": [ + { + "caller": "app.chain.douban", + "line": 290, + "mode": "sync" + } + ], + "movie_showing": [ + { + "caller": "app.chain.douban", + "line": 252, + "mode": "sync" + } + ], + "movie_top250": [ + { + "caller": "app.chain.douban", + "line": 246, + "mode": "sync" + } + ], + "music_album": [ + { + "caller": "app.chain.douban", + "line": 86, + "mode": "sync" + }, + { + "caller": "app.chain.douban", + "line": 98, + "mode": "async" + }, + { + "caller": "app.chain.musicbrainz", + "line": 79, + "mode": "sync" + }, + { + "caller": "app.chain.musicbrainz", + "line": 91, + "mode": "async" + } + ], + "music_album_related": [ + { + "caller": "app.chain.douban", + "line": 114, + "mode": "async" + }, + { + "caller": "app.chain.musicbrainz", + "line": 107, + "mode": "async" + } + ], + "music_artist": [ + { + "caller": "app.chain.musicbrainz", + "line": 120, + "mode": "async" + } + ], + "music_artist_albums": [ + { + "caller": "app.chain.musicbrainz", + "line": 138, + "mode": "async" + } + ], + "music_artist_related": [ + { + "caller": "app.chain.musicbrainz", + "line": 157, + "mode": "async" + } + ], + "music_cache_clear": [ + { + "caller": "app.chain.musicbrainz", + "line": 303, + "mode": "sync" + } + ], + "music_cache_delete": [ + { + "caller": "app.chain.musicbrainz", + "line": 298, + "mode": "sync" + } + ], + "music_cache_items": [ + { + "caller": "app.chain.musicbrainz", + "line": 293, + "mode": "sync" + } + ], + "music_chart": [ + { + "caller": "app.chain.listenbrainz", + "line": 34, + "mode": "sync" + }, + { + "caller": "app.chain.listenbrainz", + "line": 51, + "mode": "async" + } + ], + "music_discover": [ + { + "caller": "app.chain.douban", + "line": 132, + "mode": "sync" + }, + { + "caller": "app.chain.douban", + "line": 154, + "mode": "async" + } + ], + "music_fresh_releases": [ + { + "caller": "app.chain.listenbrainz", + "line": 70, + "mode": "async" + } + ], + "music_lyrics": [ + { + "caller": "app.chain.lrclib", + "line": 16, + "mode": "sync" + }, + { + "caller": "app.chain.lrclib", + "line": 26, + "mode": "async" + } + ], + "obtain_images": [ + { + "caller": "app.chain", + "line": 319, + "mode": "sync" + } + ], + "obtain_specific_image": [ + { + "caller": "app.chain", + "line": 349, + "mode": "sync" + } + ], + "recognize_media": [ + { + "caller": "app.chain._recognition", + "line": 99, + "mode": "sync" + }, + { + "caller": "app.chain.douban", + "line": 50, + "mode": "sync" + }, + { + "caller": "app.chain.musicbrainz", + "line": 43, + "mode": "sync" + } + ], + "recommend_name": [ + { + "caller": "app.chain._transfer", + "line": 668, + "mode": "sync" + }, + { + "caller": "app.chain._transfer", + "line": 675, + "mode": "sync" + } + ], + "refresh_torrents": [ + { + "caller": "app.chain", + "line": 666, + "mode": "sync" + } + ], + "refresh_userdata": [ + { + "caller": "app.chain.site", + "line": 68, + "mode": "sync" + } + ], + "register_commands": [ + { + "caller": "app.chain", + "line": 1031, + "mode": "sync" + } + ], + "remove_torrents": [ + { + "caller": "app.chain", + "line": 860, + "mode": "sync" + } + ], + "rename_file": [ + { + "caller": "app.chain.storage", + "line": 83, + "mode": "sync" + } + ], + "save_category_config": [ + { + "caller": "app.chain", + "line": 1025, + "mode": "sync" + } + ], + "scheduler_job": [ + { + "caller": "app.chain", + "line": 1037, + "mode": "sync" + } + ], + "search_collections": [ + { + "caller": "app.chain", + "line": 548, + "mode": "sync" + } + ], + "search_medias": [ + { + "caller": "app.chain", + "line": 496, + "mode": "sync" + } + ], + "search_music": [ + { + "caller": "app.chain.douban", + "line": 19, + "mode": "sync" + }, + { + "caller": "app.chain.douban", + "line": 33, + "mode": "async" + }, + { + "caller": "app.chain.musicbrainz", + "line": 16, + "mode": "sync" + }, + { + "caller": "app.chain.musicbrainz", + "line": 26, + "mode": "async" + } + ], + "search_persons": [ + { + "caller": "app.chain", + "line": 522, + "mode": "sync" + } + ], + "search_subtitles": [ + { + "caller": "app.chain", + "line": 609, + "mode": "sync" + } + ], + "search_torrents": [ + { + "caller": "app.chain", + "line": 592, + "mode": "sync" + } + ], + "search_tvdb": [ + { + "caller": "app.chain.tvdb", + "line": 12, + "mode": "sync" + } + ], + "send_direct_message": [ + { + "caller": "app.chain._messaging", + "line": 475, + "mode": "sync" + } + ], + "set_torrents_tag": [ + { + "caller": "app.chain", + "line": 899, + "mode": "sync" + } + ], + "site_subtitle_links": [ + { + "caller": "app.chain.download", + "line": 567, + "mode": "sync" + } + ], + "snapshot_storage": [ + { + "caller": "app.chain.storage", + "line": 120, + "mode": "sync" + } + ], + "start_torrents": [ + { + "caller": "app.chain", + "line": 876, + "mode": "sync" + } + ], + "stop_torrents": [ + { + "caller": "app.chain", + "line": 887, + "mode": "sync" + } + ], + "storage_manage": [ + { + "caller": "app.chain.storage", + "line": 28, + "mode": "sync" + } + ], + "tmdb_cache_clear": [ + { + "caller": "app.chain.tmdb", + "line": 344, + "mode": "sync" + } + ], + "tmdb_cache_delete": [ + { + "caller": "app.chain.tmdb", + "line": 337, + "mode": "sync" + } + ], + "tmdb_cache_items": [ + { + "caller": "app.chain.tmdb", + "line": 330, + "mode": "sync" + } + ], + "tmdb_collection": [ + { + "caller": "app.chain.tmdb", + "line": 64, + "mode": "sync" + } + ], + "tmdb_discover": [ + { + "caller": "app.chain.tmdb", + "line": 40, + "mode": "sync" + } + ], + "tmdb_episodes": [ + { + "caller": "app.chain._transfer", + "line": 661, + "mode": "sync" + }, + { + "caller": "app.chain.tmdb", + "line": 87, + "mode": "sync" + } + ], + "tmdb_group_seasons": [ + { + "caller": "app.chain.tmdb", + "line": 78, + "mode": "sync" + } + ], + "tmdb_info": [ + { + "caller": "app.chain", + "line": 425, + "mode": "sync" + } + ], + "tmdb_movie_credits": [ + { + "caller": "app.chain.tmdb", + "line": 123, + "mode": "sync" + } + ], + "tmdb_movie_recommend": [ + { + "caller": "app.chain.tmdb", + "line": 108, + "mode": "sync" + } + ], + "tmdb_movie_similar": [ + { + "caller": "app.chain.tmdb", + "line": 94, + "mode": "sync" + } + ], + "tmdb_person_credits": [ + { + "caller": "app.chain.tmdb", + "line": 146, + "mode": "sync" + } + ], + "tmdb_person_detail": [ + { + "caller": "app.chain.tmdb", + "line": 138, + "mode": "sync" + } + ], + "tmdb_seasons": [ + { + "caller": "app.chain.tmdb", + "line": 71, + "mode": "sync" + } + ], + "tmdb_trending": [ + { + "caller": "app.chain.tmdb", + "line": 57, + "mode": "sync" + } + ], + "tmdb_tv_credits": [ + { + "caller": "app.chain.tmdb", + "line": 131, + "mode": "sync" + } + ], + "tmdb_tv_recommend": [ + { + "caller": "app.chain.tmdb", + "line": 115, + "mode": "sync" + } + ], + "tmdb_tv_similar": [ + { + "caller": "app.chain.tmdb", + "line": 101, + "mode": "sync" + } + ], + "torrent_files": [ + { + "caller": "app.chain", + "line": 965, + "mode": "sync" + } + ], + "transfer": [ + { + "caller": "app.chain", + "line": 821, + "mode": "sync" + } + ], + "transfer_completed": [ + { + "caller": "app.chain", + "line": 845, + "mode": "sync" + } + ], + "tv_animation": [ + { + "caller": "app.chain.douban", + "line": 284, + "mode": "sync" + } + ], + "tv_hot": [ + { + "caller": "app.chain.douban", + "line": 296, + "mode": "sync" + } + ], + "tv_weekly_chinese": [ + { + "caller": "app.chain.douban", + "line": 258, + "mode": "sync" + } + ], + "tv_weekly_global": [ + { + "caller": "app.chain.douban", + "line": 264, + "mode": "sync" + } + ], + "tvdb_info": [ + { + "caller": "app.chain", + "line": 405, + "mode": "sync" + } + ], + "tvdb_slug": [ + { + "caller": "app.chain", + "line": 413, + "mode": "sync" + } + ], + "update_recognize_cache": [ + { + "caller": "app.chain._recognition", + "line": 62, + "mode": "sync" + } + ], + "update_torrent": [ + { + "caller": "app.chain", + "line": 926, + "mode": "sync" + } + ], + "upload_file": [ + { + "caller": "app.chain.storage", + "line": 71, + "mode": "sync" + } + ], + "user_authenticate": [ + { + "caller": "app.chain.user", + "line": 156, + "mode": "sync" + } + ], + "webhook_parser": [ + { + "caller": "app.chain", + "line": 485, + "mode": "sync" + } + ] + } + }, + "schema_version": 1, + "sdk_exports": { + "app.sdk": [], + "app.sdk.browser": [ + { + "kind": "import", + "name": "Any", + "target": "typing.Any" + }, + { + "kind": "import", + "name": "annotations", + "target": "__future__.annotations" + }, + { + "kind": "FunctionDef", + "name": "launch_browser_context", + "target": "" + }, + { + "kind": "AsyncFunctionDef", + "name": "launch_browser_context_async", + "target": "" + } + ], + "app.sdk.cache": [ + { + "kind": "import", + "name": "AsyncCache", + "target": "app.runtime.cache.AsyncCache" + }, + { + "kind": "import", + "name": "AsyncCacheBackend", + "target": "app.runtime.cache.AsyncCacheBackend" + }, + { + "kind": "import", + "name": "AsyncFileBackend", + "target": "app.adapters.cache.backends.AsyncFileBackend" + }, + { + "kind": "import", + "name": "AsyncFileCache", + "target": "app.runtime.cache.AsyncFileCache" + }, + { + "kind": "import", + "name": "AsyncMemoryBackend", + "target": "app.runtime.cache.AsyncMemoryBackend" + }, + { + "kind": "import", + "name": "AsyncRedisBackend", + "target": "app.adapters.cache.backends.AsyncRedisBackend" + }, + { + "kind": "import", + "name": "Cache", + "target": "app.runtime.cache.Cache" + }, + { + "kind": "import", + "name": "CacheBackend", + "target": "app.runtime.cache.CacheBackend" + }, + { + "kind": "import", + "name": "FileBackend", + "target": "app.adapters.cache.backends.FileBackend" + }, + { + "kind": "import", + "name": "FileCache", + "target": "app.runtime.cache.FileCache" + }, + { + "kind": "import", + "name": "LRUCache", + "target": "app.runtime.cache.LRUCache" + }, + { + "kind": "import", + "name": "MemoryBackend", + "target": "app.runtime.cache.MemoryBackend" + }, + { + "kind": "import", + "name": "RedisBackend", + "target": "app.adapters.cache.backends.RedisBackend" + }, + { + "kind": "import", + "name": "TTLCache", + "target": "app.runtime.cache.TTLCache" + }, + { + "kind": "import", + "name": "async_fresh", + "target": "app.runtime.cache.async_fresh" + }, + { + "kind": "import", + "name": "cached", + "target": "app.runtime.cache.cached" + }, + { + "kind": "import", + "name": "fresh", + "target": "app.runtime.cache.fresh" + }, + { + "kind": "import", + "name": "is_fresh", + "target": "app.runtime.cache.is_fresh" + } + ], + "app.sdk.config": [ + { + "kind": "import", + "name": "global_vars", + "target": "app.runtime.config.global_vars" + }, + { + "kind": "import", + "name": "settings", + "target": "app.runtime.config.settings" + } + ], + "app.sdk.events": [ + { + "kind": "import", + "name": "Event", + "target": "app.runtime.events.Event" + }, + { + "kind": "import", + "name": "EventManager", + "target": "app.runtime.events.EventManager" + }, + { + "kind": "import", + "name": "eventmanager", + "target": "app.runtime.events.eventmanager" + } + ], + "app.sdk.logging": [ + { + "kind": "import", + "name": "logger", + "target": "app.runtime.log.logger" + } + ], + "app.sdk.media": [ + { + "kind": "import", + "name": "Context", + "target": "app.domain.context.Context" + }, + { + "kind": "import", + "name": "MEDIA_SOURCE_ALIASES", + "target": "app.schemas.media.MEDIA_SOURCE_ALIASES" + }, + { + "kind": "import", + "name": "MEDIA_SOURCE_PREFIXES", + "target": "app.schemas.media.MEDIA_SOURCE_PREFIXES" + }, + { + "kind": "import", + "name": "MUSIC_MEDIA_SOURCES", + "target": "app.domain.media.MUSIC_MEDIA_SOURCES" + }, + { + "kind": "import", + "name": "MUSIC_MEDIA_SOURCE_ORDER", + "target": "app.domain.media.MUSIC_MEDIA_SOURCE_ORDER" + }, + { + "kind": "import", + "name": "MediaInfo", + "target": "app.domain.context.MediaInfo" + }, + { + "kind": "import", + "name": "MetaAnime", + "target": "app.domain.meta.metaanime.MetaAnime" + }, + { + "kind": "import", + "name": "MetaBase", + "target": "app.domain.meta.metabase.MetaBase" + }, + { + "kind": "import", + "name": "MetaInfo", + "target": "app.domain.metainfo.MetaInfo" + }, + { + "kind": "import", + "name": "MetaInfoPath", + "target": "app.domain.metainfo.MetaInfoPath" + }, + { + "kind": "import", + "name": "MetaMusic", + "target": "app.domain.meta.metamusic.MetaMusic" + }, + { + "kind": "import", + "name": "MetaVideo", + "target": "app.domain.meta.metavideo.MetaVideo" + }, + { + "kind": "import", + "name": "MusicNameContext", + "target": "app.domain.meta.metamusic.MusicNameContext" + }, + { + "kind": "import", + "name": "MusicNameParseResult", + "target": "app.domain.meta.metamusic.MusicNameParseResult" + }, + { + "kind": "import", + "name": "MusicNameParser", + "target": "app.domain.meta.metamusic.MusicNameParser" + }, + { + "kind": "import", + "name": "MusicNamePattern", + "target": "app.domain.meta.metamusic.MusicNamePattern" + }, + { + "kind": "import", + "name": "MusicNamePatternMatch", + "target": "app.domain.meta.metamusic.MusicNamePatternMatch" + }, + { + "kind": "import", + "name": "MusicNameRegistry", + "target": "app.domain.meta.metamusic.MusicNameRegistry" + }, + { + "kind": "import", + "name": "NfoReader", + "target": "app.domain.scraper.NfoReader" + }, + { + "kind": "import", + "name": "Tokens", + "target": "app.domain.tokens.Tokens" + }, + { + "kind": "import", + "name": "TorrentInfo", + "target": "app.domain.context.TorrentInfo" + }, + { + "kind": "import", + "name": "WordsMatcher", + "target": "app.domain.meta.words.WordsMatcher" + }, + { + "kind": "import", + "name": "build_media_key", + "target": "app.schemas.media.build_media_key" + }, + { + "kind": "import", + "name": "configure_search_source_provider", + "target": "app.domain.media.configure_search_source_provider" + }, + { + "kind": "import", + "name": "is_media_source_enabled", + "target": "app.domain.media.is_media_source_enabled" + }, + { + "kind": "import", + "name": "is_media_source_selected", + "target": "app.domain.media.is_media_source_selected" + }, + { + "kind": "import", + "name": "is_music_media_source", + "target": "app.domain.media.is_music_media_source" + }, + { + "kind": "import", + "name": "normalize_media_identity_payload", + "target": "app.schemas.media.normalize_media_identity_payload" + }, + { + "kind": "import", + "name": "normalize_media_source", + "target": "app.schemas.media.normalize_media_source" + }, + { + "kind": "import", + "name": "normalize_music_type", + "target": "app.domain.media.normalize_music_type" + }, + { + "kind": "import", + "name": "parse_media_key", + "target": "app.schemas.media.parse_media_key" + }, + { + "kind": "import", + "name": "parse_media_source_selection", + "target": "app.domain.media.parse_media_source_selection" + }, + { + "kind": "import", + "name": "resolve_media_identity", + "target": "app.schemas.media.resolve_media_identity" + } + ], + "app.sdk.network": [ + { + "kind": "import", + "name": "AsyncRequestUtils", + "target": "app.adapters.network.http.AsyncRequestUtils" + }, + { + "kind": "import", + "name": "IpUtils", + "target": "app.adapters.network.ip.IpUtils" + }, + { + "kind": "import", + "name": "RequestUtils", + "target": "app.adapters.network.http.RequestUtils" + }, + { + "kind": "import", + "name": "RssHelper", + "target": "app.application.rss.RssHelper" + }, + { + "kind": "import", + "name": "SecurityUtils", + "target": "app.application.security.url.SecurityUtils" + }, + { + "kind": "import", + "name": "SiteUtils", + "target": "app.domain.site.SiteUtils" + }, + { + "kind": "import", + "name": "SitesHelper", + "target": "app.application.site.sites.SitesHelper" + }, + { + "kind": "import", + "name": "UrlUtils", + "target": "app.foundation.url.UrlUtils" + }, + { + "kind": "import", + "name": "WebUtils", + "target": "app.adapters.external.location.WebUtils" + } + ], + "app.sdk.plugins": [ + { + "kind": "import", + "name": "ModuleManager", + "target": "app.runtime.extensions.module_manager.ModuleManager" + }, + { + "kind": "import", + "name": "PluginManager", + "target": "app.runtime.extensions.plugin_manager.PluginManager" + } + ], + "app.sdk.services": [ + { + "kind": "import", + "name": "DownloaderHelper", + "target": "app.application.downloader.DownloaderHelper" + }, + { + "kind": "import", + "name": "MediaServerHelper", + "target": "app.application.mediaserver.MediaServerHelper" + }, + { + "kind": "import", + "name": "MediaServerIdentityHelper", + "target": "app.application.mediaserver.MediaServerIdentityHelper" + }, + { + "kind": "import", + "name": "MusicMediaServerHelper", + "target": "app.application.mediaserver.MusicMediaServerHelper" + }, + { + "kind": "import", + "name": "NotificationHelper", + "target": "app.application.notification.NotificationHelper" + }, + { + "kind": "import", + "name": "RuleHelper", + "target": "app.application.rules.RuleHelper" + }, + { + "kind": "import", + "name": "ServiceBaseHelper", + "target": "app.runtime.extensions.service_registry.ServiceBaseHelper" + }, + { + "kind": "import", + "name": "ServiceConfigHelper", + "target": "app.runtime.extensions.service_registry.ServiceConfigHelper" + }, + { + "kind": "import", + "name": "StorageHelper", + "target": "app.application.storage.StorageHelper" + }, + { + "kind": "import", + "name": "SystemHelper", + "target": "app.runtime.state.SystemHelper" + } + ], + "app.sdk.string": [ + { + "kind": "import", + "name": "Callable", + "target": "typing.Callable" + }, + { + "kind": "import", + "name": "DomUtils", + "target": "app.foundation.dom.DomUtils" + }, + { + "kind": "import", + "name": "HashUtils", + "target": "app.foundation.crypto.HashUtils" + }, + { + "kind": "ClassDef", + "name": "StringUtils", + "target": "" + }, + { + "kind": "import", + "name": "base_url", + "target": "app.foundation.url.base_url" + }, + { + "kind": "import", + "name": "common_prefix", + "target": "app.foundation.text.common_prefix" + }, + { + "kind": "import", + "name": "compact_numbers", + "target": "app.domain.episode.compact_numbers" + }, + { + "kind": "import", + "name": "compare_version", + "target": "app.foundation.version.compare_version" + }, + { + "kind": "import", + "name": "contains_chinese", + "target": "app.foundation.text.contains_chinese" + }, + { + "kind": "import", + "name": "contains_japanese", + "target": "app.foundation.text.contains_japanese" + }, + { + "kind": "import", + "name": "contains_korean", + "target": "app.foundation.text.contains_korean" + }, + { + "kind": "import", + "name": "cookiejar_to_string", + "target": "app.foundation.text.cookiejar_to_string" + }, + { + "kind": "import", + "name": "count_words", + "target": "app.foundation.text.count_words" + }, + { + "kind": "import", + "name": "escape_markdown", + "target": "app.foundation.text.escape_markdown" + }, + { + "kind": "import", + "name": "extract_domain", + "target": "app.domain.site.extract_domain" + }, + { + "kind": "import", + "name": "extract_named_ids", + "target": "app.foundation.text.extract_named_ids" + }, + { + "kind": "import", + "name": "format_amount", + "target": "app.foundation.text.format_amount" + }, + { + "kind": "import", + "name": "format_approx_duration", + "target": "app.foundation.temporal.format_approx_duration" + }, + { + "kind": "import", + "name": "format_compact_size", + "target": "app.foundation.size.format_compact_size" + }, + { + "kind": "import", + "name": "format_duration", + "target": "app.foundation.temporal.format_duration" + }, + { + "kind": "import", + "name": "format_minutes", + "target": "app.foundation.temporal.format_minutes" + }, + { + "kind": "import", + "name": "format_ranges", + "target": "app.domain.episode.format_ranges" + }, + { + "kind": "import", + "name": "format_remaining", + "target": "app.foundation.temporal.format_remaining" + }, + { + "kind": "import", + "name": "format_size", + "target": "app.foundation.size.format_size" + }, + { + "kind": "import", + "name": "format_timestamp", + "target": "app.foundation.temporal.format_timestamp" + }, + { + "kind": "import", + "name": "host_label", + "target": "app.foundation.url.host_label" + }, + { + "kind": "import", + "name": "is_all_chinese", + "target": "app.foundation.text.is_all_chinese" + }, + { + "kind": "import", + "name": "is_english_word", + "target": "app.foundation.text.is_english_word" + }, + { + "kind": "import", + "name": "is_link", + "target": "app.foundation.url.is_link" + }, + { + "kind": "import", + "name": "is_magnet_link", + "target": "app.domain.torrent.is_magnet_link" + }, + { + "kind": "import", + "name": "is_media_title_like", + "target": "app.domain.title.is_media_title_like" + }, + { + "kind": "import", + "name": "is_number", + "target": "app.foundation.text.is_number" + }, + { + "kind": "import", + "name": "natural_sort_key", + "target": "app.foundation.text.natural_sort_key" + }, + { + "kind": "import", + "name": "normalize_datetime", + "target": "app.foundation.temporal.normalize_datetime" + }, + { + "kind": "import", + "name": "normalize_upper", + "target": "app.foundation.text.normalize_upper" + }, + { + "kind": "import", + "name": "parse_address", + "target": "app.foundation.url.parse_address" + }, + { + "kind": "import", + "name": "parse_bool", + "target": "app.foundation.text.parse_bool" + }, + { + "kind": "import", + "name": "parse_datetime", + "target": "app.foundation.temporal.parse_datetime" + }, + { + "kind": "import", + "name": "parse_float", + "target": "app.foundation.text.parse_float" + }, + { + "kind": "import", + "name": "parse_int", + "target": "app.foundation.text.parse_int" + }, + { + "kind": "import", + "name": "parse_search_keyword", + "target": "app.domain.title.parse_search_keyword" + }, + { + "kind": "import", + "name": "parse_size", + "target": "app.foundation.size.parse_size" + }, + { + "kind": "import", + "name": "parse_timestamp", + "target": "app.foundation.temporal.parse_timestamp" + }, + { + "kind": "import", + "name": "random_string", + "target": "app.foundation.text.random_string" + }, + { + "kind": "import", + "name": "remove_punctuation", + "target": "app.foundation.text.remove_punctuation" + }, + { + "kind": "import", + "name": "sanitize_filename", + "target": "app.foundation.text.sanitize_filename" + }, + { + "kind": "import", + "name": "second_level_label", + "target": "app.foundation.url.second_level_label" + }, + { + "kind": "import", + "name": "signature", + "target": "inspect.signature" + }, + { + "kind": "import", + "name": "split_by_bytes", + "target": "app.foundation.text.split_by_bytes" + }, + { + "kind": "import", + "name": "split_netloc", + "target": "app.foundation.url.split_netloc" + }, + { + "kind": "import", + "name": "strip_optional", + "target": "app.foundation.text.strip_optional" + }, + { + "kind": "import", + "name": "title_case", + "target": "app.foundation.text.title_case" + }, + { + "kind": "import", + "name": "urls_match", + "target": "app.domain.site.urls_match" + }, + { + "kind": "import", + "name": "wraps", + "target": "functools.wraps" + } + ], + "app.sdk.utilities": [ + { + "kind": "import", + "name": "CryptoJsUtils", + "target": "app.foundation.crypto.CryptoJsUtils" + }, + { + "kind": "import", + "name": "DomUtils", + "target": "app.foundation.dom.DomUtils" + }, + { + "kind": "import", + "name": "LocaleHelper", + "target": "app.runtime.localization.LocaleHelper" + }, + { + "kind": "import", + "name": "ObjectUtils", + "target": "app.foundation.reflection.ObjectUtils" + }, + { + "kind": "import", + "name": "OtpUtils", + "target": "app.application.security.otp.OtpUtils" + }, + { + "kind": "import", + "name": "Singleton", + "target": "app.foundation.singleton.Singleton" + }, + { + "kind": "import", + "name": "StringUtils", + "target": "app.sdk.string.StringUtils" + }, + { + "kind": "import", + "name": "SystemUtils", + "target": "app.adapters.system.host.SystemUtils" + }, + { + "kind": "import", + "name": "TimerUtils", + "target": "app.runtime.scheduling.TimerUtils" + }, + { + "kind": "import", + "name": "cut", + "target": "app.foundation.text.cut" + }, + { + "kind": "import", + "name": "log_execution_time", + "target": "app.runtime.execution.log_execution_time" + }, + { + "kind": "import", + "name": "retry", + "target": "app.runtime.execution.retry" + } + ] + } +} diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json new file mode 100644 index 000000000..f4d96c70c --- /dev/null +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -0,0 +1,208 @@ +{ + "schema_version": 1, + "generated_at": "2026-08-17T15:05:56.902649+00:00", + "platform": "macOS-26.5.2-arm64-arm-64bit", + "python": "3.12.6", + "repeat": 3, + "targets": { + "app.startup.lifecycle": { + "loaded_module_count": 1779, + "max_ms": 968.467, + "median_ms": 968.335, + "min_ms": 960.971, + "samples_ms": [ + 968.335, + 968.467, + 960.971 + ] + }, + "app.factory": { + "loaded_module_count": 1791, + "max_ms": 1026.357, + "median_ms": 1019.56, + "min_ms": 997.442, + "samples_ms": [ + 1019.56, + 1026.357, + 997.442 + ] + }, + "app.main": { + "loaded_module_count": 1933, + "max_ms": 1091.202, + "median_ms": 1083.385, + "min_ms": 1076.762, + "samples_ms": [ + 1091.202, + 1076.762, + 1083.385 + ] + } + }, + "lifecycle": { + "scope": "isolated no-op component callbacks; no plugin/network/database I/O", + "modes": { + "normal": { + "samples": [ + { + "mode": "normal", + "enabled_component_count": 14, + "startup_ms": 0.55, + "full_lifespan_ms": 0.621, + "stage_ms": { + "HTTP 基础能力": 0.071, + "领域依赖装配": 0.035, + "数据库引擎预热": 0.029, + "数据库连接预算": 0.026, + "路由": 0.023, + "模块服务": 0.021, + "插件备份恢复": 0.021, + "插件": 0.02, + "定时器": 0.023, + "监控器": 0.022, + "待处理整理回放": 0.024, + "命令服务": 0.023, + "工作流": 0.022 + }, + "threads_before": 2, + "threads_started": 2, + "threads_after": 2, + "tasks_before": 1, + "tasks_started": 2, + "tasks_after": 1, + "database_connections_started": 0 + }, + { + "mode": "normal", + "enabled_component_count": 14, + "startup_ms": 0.548, + "full_lifespan_ms": 0.613, + "stage_ms": { + "HTTP 基础能力": 0.075, + "领域依赖装配": 0.035, + "数据库引擎预热": 0.028, + "数据库连接预算": 0.028, + "路由": 0.026, + "模块服务": 0.022, + "插件备份恢复": 0.023, + "插件": 0.023, + "定时器": 0.021, + "监控器": 0.019, + "待处理整理回放": 0.024, + "命令服务": 0.023, + "工作流": 0.022 + }, + "threads_before": 2, + "threads_started": 2, + "threads_after": 2, + "tasks_before": 1, + "tasks_started": 2, + "tasks_after": 1, + "database_connections_started": 0 + }, + { + "mode": "normal", + "enabled_component_count": 14, + "startup_ms": 0.549, + "full_lifespan_ms": 0.616, + "stage_ms": { + "HTTP 基础能力": 0.07, + "领域依赖装配": 0.034, + "数据库引擎预热": 0.027, + "数据库连接预算": 0.028, + "路由": 0.026, + "模块服务": 0.023, + "插件备份恢复": 0.024, + "插件": 0.023, + "定时器": 0.021, + "监控器": 0.019, + "待处理整理回放": 0.024, + "命令服务": 0.023, + "工作流": 0.022 + }, + "threads_before": 2, + "threads_started": 2, + "threads_after": 2, + "tasks_before": 1, + "tasks_started": 2, + "tasks_after": 1, + "database_connections_started": 0 + } + ], + "median_startup_ms": 0.549, + "median_full_lifespan_ms": 0.616, + "enabled_component_count": 14 + }, + "safe": { + "samples": [ + { + "mode": "safe", + "enabled_component_count": 6, + "startup_ms": 0.399, + "full_lifespan_ms": 0.46, + "stage_ms": { + "HTTP 基础能力": 0.069, + "领域依赖装配": 0.035, + "数据库引擎预热": 0.027, + "数据库连接预算": 0.029, + "路由": 0.024, + "模块服务": 0.026 + }, + "threads_before": 2, + "threads_started": 2, + "threads_after": 2, + "tasks_before": 1, + "tasks_started": 2, + "tasks_after": 1, + "database_connections_started": 0 + }, + { + "mode": "safe", + "enabled_component_count": 6, + "startup_ms": 0.382, + "full_lifespan_ms": 0.443, + "stage_ms": { + "HTTP 基础能力": 0.065, + "领域依赖装配": 0.031, + "数据库引擎预热": 0.025, + "数据库连接预算": 0.025, + "路由": 0.022, + "模块服务": 0.023 + }, + "threads_before": 2, + "threads_started": 2, + "threads_after": 2, + "tasks_before": 1, + "tasks_started": 2, + "tasks_after": 1, + "database_connections_started": 0 + }, + { + "mode": "safe", + "enabled_component_count": 6, + "startup_ms": 0.416, + "full_lifespan_ms": 0.482, + "stage_ms": { + "HTTP 基础能力": 0.074, + "领域依赖装配": 0.034, + "数据库引擎预热": 0.028, + "数据库连接预算": 0.027, + "路由": 0.025, + "模块服务": 0.025 + }, + "threads_before": 2, + "threads_started": 2, + "threads_after": 2, + "tasks_before": 1, + "tasks_started": 2, + "tasks_after": 1, + "database_connections_started": 0 + } + ], + "median_startup_ms": 0.399, + "median_full_lifespan_ms": 0.46, + "enabled_component_count": 6 + } + } + } +} diff --git a/tests/test_agent_plugin_tools.py b/tests/test_agent_plugin_tools.py index a2c9789cd..b6e720e8d 100644 --- a/tests/test_agent_plugin_tools.py +++ b/tests/test_agent_plugin_tools.py @@ -283,8 +283,8 @@ def test_install_plugin_runtime_reloads_in_threadpool() -> None: return_value=plugin_helper, ), patch( - "app.agent.tools.impl._plugin_tool_utils.reload_plugin_runtime", - ) as reload_runtime, + "app.agent.tools.impl._plugin_tool_utils.refresh_plugin_registrations", + ) as refresh_registrations, patch( "app.agent.tools.impl._plugin_tool_utils.MoviePilotServerHelper.async_install_plugin_reg", AsyncMock(return_value=True), @@ -309,11 +309,15 @@ def test_install_plugin_runtime_reloads_in_threadpool() -> None: plugin_id="DemoPlugin", repo_url="https://example.com/market", ) - assert len(calls) == 1 + assert len(calls) == 2 assert calls[0][0] == "plugin" - assert calls[0][1] == reload_runtime + assert calls[0][1] == plugin_manager.reload_plugin assert calls[0][2] == ("DemoPlugin",) assert calls[0][3] == {} + assert calls[1][0] == "plugin" + assert calls[1][1] == refresh_registrations + assert calls[1][2] == ("DemoPlugin",) + assert calls[1][3] == {} def test_uninstall_plugin_uninstalls_installed_candidate() -> None: diff --git a/tests/test_api_response.py b/tests/test_api_response.py index 194c5ba65..631ea9572 100644 --- a/tests/test_api_response.py +++ b/tests/test_api_response.py @@ -4,7 +4,10 @@ from typing import Any import httpx import pytest from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute from pydantic import BaseModel, ValidationError +from starlette.responses import Response as StarletteResponse from starlette.responses import StreamingResponse from app.api.response import ( @@ -683,7 +686,7 @@ def test_openapi_success_models_have_no_implicit_empty_nested_schemas(): def test_plugin_routes_only_register_v1(monkeypatch): - """插件动态路由只应注册 v1 地址并由应用统一路由类处理。""" + """插件动态路由只注册 v1 地址,并显式绕过主程序响应路由。""" from app.application import plugins class FakeApp: @@ -691,11 +694,14 @@ def test_plugin_routes_only_register_v1(monkeypatch): def __init__(self): self.routes = [] + self.route_options = [] self.openapi_schema = None + self.router = self def add_api_route(self, **kwargs): """记录新增的路由路径。""" self.routes.append(SimpleNamespace(path=kwargs["path"])) + self.route_options.append(kwargs) def setup(self): """模拟 FastAPI 路由重建。""" @@ -722,6 +728,7 @@ def test_plugin_routes_only_register_v1(monkeypatch): assert [route.path for route in fake_app.routes] == [ "/api/v1/plugin/DemoPlugin/health" ] + assert fake_app.route_options[0]["route_class_override"] is APIRoute plugins._update_plugin_api_routes("DemoPlugin", action="remove") assert fake_app.routes == [] @@ -739,8 +746,8 @@ def test_response_router_uses_response_route_class(): assert isinstance(router.routes[0], ResponseAPIRoute) -def test_dynamic_route_without_annotation_uses_recursive_json_model(): - """动态插件未声明模型时应以 OpenAPI 可递归展示的 JSON 类型约束 data。""" +def test_dynamic_host_route_without_annotation_uses_recursive_json_model(): + """主应用动态路由未声明模型时仍应使用统一响应模型。""" app = FastAPI() app.router.route_class = ResponseAPIRoute @@ -755,6 +762,125 @@ def test_dynamic_route_without_annotation_uses_recursive_json_model(): assert generic_args == (JsonData,) +def build_plugin_api_app(monkeypatch) -> FastAPI: + """构造覆盖插件自由返回类型的动态路由测试应用。""" + from app.application import plugins + + class PluginPayload(BaseModel): + """插件自行声明的响应模型。""" + + ok: bool + + def dict_endpoint() -> dict[str, bool]: + """返回插件自定义字典。""" + return {"ok": True} + + def model_endpoint() -> PluginPayload: + """返回插件自定义模型。""" + return PluginPayload(ok=True) + + def response_endpoint() -> JSONResponse: + """返回插件自定义状态码和响应头。""" + return JSONResponse( + {"accepted": True}, + status_code=202, + headers={"X-Plugin-Response": "yes"}, + ) + + async def stream_endpoint() -> StreamingResponse: + """返回插件自定义事件流。""" + async def stream_source(): + """生成插件测试事件。""" + yield "data: plugin\n\n" + + return StreamingResponse(stream_source(), media_type="text/event-stream") + + def empty_endpoint() -> StarletteResponse: + """返回插件自定义空响应。""" + return StarletteResponse(status_code=204) + + class FakePluginManager: + """返回覆盖插件响应边界的路由声明。""" + + def get_plugin_apis(self, plugin_id): + """返回测试插件 API。""" + assert plugin_id == "DemoPlugin" + common = {"methods": ["GET"], "allow_anonymous": True} + return [ + { + **common, + "path": "/DemoPlugin/dict", + "endpoint": dict_endpoint, + }, + { + **common, + "path": "/DemoPlugin/model", + "endpoint": model_endpoint, + "response_model": PluginPayload, + }, + { + **common, + "path": "/DemoPlugin/response", + "endpoint": response_endpoint, + }, + { + **common, + "path": "/DemoPlugin/stream", + "endpoint": stream_endpoint, + "response_model": None, + }, + { + **common, + "path": "/DemoPlugin/empty", + "endpoint": empty_endpoint, + "status_code": 204, + "response_model": None, + }, + ] + + app = FastAPI() + app.router.route_class = ResponseAPIRoute + monkeypatch.setattr(plugins, "_api_app", app) + monkeypatch.setattr(plugins, "PluginManager", FakePluginManager) + plugins._update_plugin_api_routes("DemoPlugin", action="add") + return app + + +async def test_plugin_dynamic_routes_preserve_raw_runtime_responses(monkeypatch): + """插件动态 API 应完整保留自行选择的响应体、状态码和流。""" + app = build_plugin_api_app(monkeypatch) + + async with make_client(app) as client: + dict_response = await client.get("/api/v1/plugin/DemoPlugin/dict") + model_response = await client.get("/api/v1/plugin/DemoPlugin/model") + native_response = await client.get("/api/v1/plugin/DemoPlugin/response") + stream_response = await client.get("/api/v1/plugin/DemoPlugin/stream") + empty_response = await client.get("/api/v1/plugin/DemoPlugin/empty") + + assert dict_response.json() == {"ok": True} + assert model_response.json() == {"ok": True} + assert native_response.status_code == 202 + assert native_response.json() == {"accepted": True} + assert native_response.headers["X-Plugin-Response"] == "yes" + assert stream_response.text == "data: plugin\n\n" + assert empty_response.status_code == 204 + assert empty_response.content == b"" + + +def test_plugin_dynamic_routes_keep_plugin_openapi_model_raw(monkeypatch): + """插件声明的模型应直接进入 OpenAPI,不得套入主程序 Response。""" + app = build_plugin_api_app(monkeypatch) + + operation = app.openapi()["paths"]["/api/v1/plugin/DemoPlugin/model"]["get"] + response_schema = operation["responses"]["200"]["content"][ + "application/json" + ]["schema"] + + assert response_schema == { + "$ref": "#/components/schemas/PluginPayload", + } + + def test_dynamic_bare_response_uses_recursive_json_without_double_wrapping(): """动态插件声明裸 Response 时应补齐递归 JSON 类型且不重复封装。""" router = ResponseAPIRouter() diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py new file mode 100644 index 000000000..c0ae75c51 --- /dev/null +++ b/tests/test_architecture_contract_baseline.py @@ -0,0 +1,156 @@ +import json +import subprocess +import sys +from pathlib import Path + +from app.schemas.types import ChainEventType, EventType + + +PROJECT_ROOT = Path(__file__).parents[1] +BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture" + + +def test_architecture_contract_baselines_match_current_source(): + """宿主依赖图和公开运行契约变化必须显式刷新基线。""" + result = subprocess.run( + [sys.executable, "scripts/architecture/baseline.py", "--check"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_official_plugin_baseline_records_external_source(): + """官方插件快照必须绑定独立仓提交,且不得引用宿主插件副本。""" + baseline_path = BASELINE_ROOT / "official-plugin-baseline.json" + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + + assert baseline["source"]["repository"] == "MoviePilot-Plugins" + assert len(baseline["source"]["head"]) == 40 + assert baseline["source"]["roots"] == ["plugins.v2", "plugins.v3"] + assert all( + not path.startswith("app/plugins/") + for contract in (*baseline["imports"].values(), *baseline["hooks"].values()) + for path in contract["files"] + ) + assert all( + not path.startswith("app/plugins/") + for path in baseline["api_routes"] + ) + + +def test_official_discovery_plugins_explicitly_keep_host_page_envelope(): + """宿主探索页消费的官方插件 API 不得依赖动态路由隐式包装。""" + baseline_path = BASELINE_ROOT / "official-plugin-baseline.json" + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + routes = { + (path, route["path"]): route + for path, file_routes in baseline["api_routes"].items() + for route in file_routes + } + + expected_paths = { + ("plugins.v3/imdbsource/__init__.py", "/imdb-discover"), + ("plugins.v3/imdbsource/__init__.py", "/imdb-top-250"), + ("plugins.v3/imdbsource/__init__.py", "/imdb-trending"), + ("plugins.v3/imdbsource/__init__.py", "/trending"), + ("plugins.v3/tvdbdiscover/__init__.py", "/tvdb_discover"), + } + for route_key in expected_paths: + route = routes[route_key] + assert route["response_model"] == "schemas.Response[List[schemas.MediaInfo]]" + assert route["endpoint_return"] == "schemas.Response[List[schemas.MediaInfo]]" + + +def test_startup_performance_baseline_records_all_cold_import_targets(): + """启动性能基线必须包含关键入口的可比较冷导入采样。""" + baseline_path = BASELINE_ROOT / "startup-performance-baseline.json" + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + + assert baseline["repeat"] >= 3 + assert set(baseline["targets"]) == { + "app.startup.lifecycle", + "app.factory", + "app.main", + } + for contract in baseline["targets"].values(): + assert len(contract["samples_ms"]) == baseline["repeat"] + assert contract["min_ms"] <= contract["median_ms"] <= contract["max_ms"] + assert contract["loaded_module_count"] > 0 + + +def test_startup_performance_baseline_records_normal_and_safe_lifecycle_resources(): + """非功能基线必须同时记录正常/安全模式和隔离资源增量。""" + baseline_path = BASELINE_ROOT / "startup-performance-baseline.json" + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + lifecycle = baseline["lifecycle"] + + assert "no-op" in lifecycle["scope"] + assert set(lifecycle["modes"]) == {"normal", "safe"} + normal = lifecycle["modes"]["normal"] + safe = lifecycle["modes"]["safe"] + assert normal["enabled_component_count"] > safe["enabled_component_count"] + for mode in (normal, safe): + assert len(mode["samples"]) == baseline["repeat"] + for sample in mode["samples"]: + assert sample["threads_after"] == sample["threads_before"] + assert sample["tasks_after"] == sample["tasks_before"] + assert sample["database_connections_started"] == 0 + assert sample["stage_ms"] + + +def test_schema_export_manifest_matches_current_modules(): + """Schema 公开符号或冲突来源变化必须显式刷新生成清单。""" + result = subprocess.run( + [sys.executable, "scripts/schema/exports.py", "--check"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + + +def test_schema_root_import_does_not_eagerly_load_schema_graph(): + """仅导入 schema 根包时不得加载任一业务 schema 子模块。""" + script = """ +import sys +import app.schemas + +loaded = sorted( + name + for name in sys.modules + if name.startswith('app.schemas.') and name != 'app.schemas.exports' +) +assert not loaded, loaded +assert len(app.schemas.__all__) >= 400 +""" + result = subprocess.run( + [sys.executable, "-c", script], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_event_contract_baseline_covers_every_public_event_enum() -> None: + """事件生产者/消费者快照必须覆盖全部广播和链式事件枚举。""" + baseline_path = BASELINE_ROOT / "runtime-contract-baseline.json" + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + events = baseline["events"] + expected = { + *(f"EventType.{member.name}" for member in EventType), + *(f"ChainEventType.{member.name}" for member in ChainEventType), + } + + assert set(events["events"]) == expected + assert events["event_count"] == len(expected) + assert events["producer_count"] > 0 + assert events["consumer_count"] > 0 diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 42bfb72bd..a196d5f1c 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -66,6 +66,17 @@ RETIRED_CANONICAL_FILES = ( "app/adapters/network/rss.py", "app/adapters/network/sites.pyi", ) +PLUGIN_COMPONENT_ROOTS = ( + "app/adapters/external/plugin", + "app/adapters/system/plugin", + "app/application/plugin", + "app/runtime/extensions/plugin", +) +PLUGIN_LEGACY_ABI_NAMES = { + "MoviePilotServerHelper", + "PluginHelper", + "PluginManager", +} FORBIDDEN_IMPORT_PREFIXES = { "app.foundation": ( "app.adapters", @@ -282,6 +293,86 @@ def test_host_code_does_not_import_legacy_roots(): assert violations == {} +def test_plugin_components_do_not_reexport_legacy_abi_names(): + """新插件组件只提供 canonical 能力,不得复制旧 Helper、Manager 或 Oper 导出。""" + violations: list[str] = [] + for root in PLUGIN_COMPONENT_ROOTS: + for path in (PROJECT_ROOT / root).rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.name == "__getattr__" or node.name in PLUGIN_LEGACY_ABI_NAMES: + violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.name}") + elif isinstance(node, ast.ClassDef): + if node.name in PLUGIN_LEGACY_ABI_NAMES or node.name.endswith("Oper"): + violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.name}") + elif isinstance(node, ast.ImportFrom): + for alias in node.names: + is_legacy_name = ( + alias.name in PLUGIN_LEGACY_ABI_NAMES + or alias.name.endswith("Oper") + ) + is_private = bool(alias.asname and alias.asname.startswith("_")) + if is_legacy_name and not is_private: + violations.append( + f"{path.relative_to(PROJECT_ROOT)}:{alias.name}" + ) + elif isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + names = { + target.id + for target in targets + if isinstance(target, ast.Name) + } + forbidden = { + name + for name in names + if name == "__all__" + or name in PLUGIN_LEGACY_ABI_NAMES + or name.endswith("Oper") + } + violations.extend( + f"{path.relative_to(PROJECT_ROOT)}:{name}" + for name in sorted(forbidden) + ) + assert violations == [] + + +def test_host_code_uses_precise_schema_modules(): + """宿主不得重新依赖 schema 聚合入口或星号导出。""" + violations: list[str] = [] + for path in APP_ROOT.rglob("*.py"): + relative = path.relative_to(APP_ROOT) + if relative.parts[0] in {"plugins", "schemas"}: + continue + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module == "app": + if any(alias.name == "schemas" for alias in node.names): + violations.append(str(relative)) + break + if isinstance(node, ast.ImportFrom) and node.module == "app.schemas": + violations.append(str(relative)) + break + assert violations == [] + + +def test_database_internals_do_not_import_db_facades(): + """DB 子模块必须依赖具体实现文件,不得回流到包级兼容入口。""" + violations: list[str] = [] + for path in (APP_ROOT / "db").rglob("*.py"): + if path.name == "__init__.py": + continue + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + if any( + isinstance(node, ast.ImportFrom) + and node.module in {"app.db", "app.db.models"} + for node in ast.walk(tree) + ): + violations.append(str(path.relative_to(PROJECT_ROOT))) + assert violations == [] + + def test_migrated_modules_are_not_in_import_cycles(): """任何 canonical 迁移模块都不得进入完整应用依赖图的环。""" modules = _discover_modules() diff --git a/tests/test_chain_rate_limit.py b/tests/test_chain_rate_limit.py index 421358f30..41d7df842 100644 --- a/tests/test_chain_rate_limit.py +++ b/tests/test_chain_rate_limit.py @@ -10,10 +10,13 @@ sys.modules.setdefault("transmission_rpc", ModuleType("transmission_rpc")) setattr(sys.modules["transmission_rpc"], "File", object) from app.chain import ChainBase +from app.application.chain.context import ChainRuntimeContext from app.schemas import RateLimitExceededException class _LimitedModule: + """模拟始终触发本地限流的宿主模块。""" + def get_name(self): """ 返回测试模块名称。 @@ -40,18 +43,31 @@ class _LimitedModule: class ChainRateLimitTest(unittest.TestCase): + """验证模块限流异常的兼容传播和告警语义。""" + def _build_chain(self): """ 构造隔离的 ChainBase,避免依赖真实模块和插件运行状态。 """ - chain = ChainBase() limited_module = _LimitedModule() - chain.pluginmanager = Mock() - chain.pluginmanager.get_plugin_modules.return_value = {} - chain.modulemanager = Mock() - chain.modulemanager.get_running_modules.return_value = [limited_module] - chain.messagehelper = Mock() - chain.eventmanager = Mock() + plugin_manager = Mock() + plugin_manager.get_plugin_modules.return_value = {} + module_manager = Mock() + module_manager.get_running_modules.return_value = [limited_module] + message_helper = Mock() + event_manager = Mock() + chain = ChainBase( + ChainRuntimeContext( + module_manager=module_manager, + plugin_manager=plugin_manager, + event_manager=event_manager, + message_oper=Mock(), + message_helper=message_helper, + file_cache=Mock(), + async_file_cache=Mock(), + message_queue_factory=lambda _callback: Mock(), + ) + ) return chain def test_rate_limit_is_not_reported_as_system_error(self): diff --git a/tests/test_chain_runtime_context.py b/tests/test_chain_runtime_context.py new file mode 100644 index 000000000..bd5e94c2e --- /dev/null +++ b/tests/test_chain_runtime_context.py @@ -0,0 +1,47 @@ +"""Chain 运行上下文注入和无参兼容 provider 测试。""" + +from unittest.mock import Mock + +from app.application.chain.context import ChainRuntimeContext +from app.application.chain import context as chain_context +from app.chain import ChainBase + + +def _context() -> ChainRuntimeContext: + """构造不连接数据库、不启动线程的最小 Chain 上下文。""" + return ChainRuntimeContext( + module_manager=Mock(), + plugin_manager=Mock(), + event_manager=Mock(), + message_oper=Mock(), + message_helper=Mock(), + file_cache=Mock(), + async_file_cache=Mock(), + message_queue_factory=Mock(return_value=Mock()), + ) + + +def test_chain_accepts_explicit_runtime_context() -> None: + """新代码应能显式注入最小运行时依赖而不创建真实管理器。""" + context = _context() + + chain = ChainBase(context) + + assert chain.modulemanager is context.module_manager + assert chain.pluginmanager is context.plugin_manager + assert chain.eventmanager is context.event_manager + assert chain.messagehelper is context.message_helper + context.message_queue_factory.assert_called_once_with(chain.run_module) + + +def test_no_arg_chain_uses_compatibility_context_provider(monkeypatch) -> None: + """V3 兼容期内无参 Chain() 应从组合根 provider 获取相同上下文。""" + context = _context() + provider = Mock(return_value=context) + monkeypatch.setattr(chain_context, "_context_provider", provider) + + chain = ChainBase() + + provider.assert_called_once_with() + assert chain.modulemanager is context.module_manager + assert chain.pluginmanager is context.plugin_manager diff --git a/tests/test_chain_vertical_slices.py b/tests/test_chain_vertical_slices.py new file mode 100644 index 000000000..bb34eeae6 --- /dev/null +++ b/tests/test_chain_vertical_slices.py @@ -0,0 +1,79 @@ +"""阶段 4 六个重点 Chain 的纵向切片数量守卫。""" + +import inspect + +import pytest + +from app.chain.download import DownloadChain +from app.chain.media import MediaChain +from app.chain.message import MessageChain +from app.chain.search import SearchChain +from app.chain.subscribe import SubscribeChain +from app.chain.transfer import TransferChain + + +@pytest.mark.parametrize( + ("chain_type", "method_tokens"), + [ + ( + SubscribeChain, + { + "exists": "_subscription_query", + "get_subscribe_by_source": "_subscription_query", + "has_music_subscribe": "_subscription_query", + }, + ), + ( + SearchChain, + { + "save_last_search_params": "_search_state", + "last_search_params": "_search_state", + "last_search_results": "_search_state", + }, + ), + ( + TransferChain, + { + "put_to_queue": "_transfer_queue_service", + "remove_from_queue": "_transfer_queue_service", + "get_queue_tasks": "_transfer_queue_service", + }, + ), + ( + DownloadChain, + { + "downloading": "_download_task_service", + "set_downloading": "_download_task_service", + "remove_downloading": "_download_task_service", + }, + ), + ( + MediaChain, + { + "normalize_music_candidates": "MusicCatalogService", + "search_music": "_music_catalog", + "async_search_music": "_music_catalog", + }, + ), + ( + MessageChain, + { + "remote_clear_session": "_message_session_service", + "remote_stop_agent": "_message_session_service", + "remote_session_status": "_message_session_service", + }, + ), + ], +) +def test_key_chain_keeps_three_application_service_slices( + chain_type: type, + method_tokens: dict[str, str], +) -> None: + """每个重点 Chain 至少三个公开方法必须继续委托窄应用服务。""" + assert len(method_tokens) >= 3 + missing = [] + for method_name, service_token in method_tokens.items(): + method = getattr(chain_type, method_name) + if service_token not in inspect.getsource(method): + missing.append(f"{chain_type.__name__}.{method_name}->{service_token}") + assert missing == [] diff --git a/tests/test_data_cleanup_chain.py b/tests/test_data_cleanup_chain.py index 607d018b0..4d82391c1 100644 --- a/tests/test_data_cleanup_chain.py +++ b/tests/test_data_cleanup_chain.py @@ -147,7 +147,9 @@ class DataCleanupChainTest(unittest.TestCase): ) db.commit() - with self._cleanup_settings(), patch("app.scheduler.SessionFactory", self.SessionFactory): + with self._cleanup_settings(), patch( + "app.application.maintenance.SessionFactory", self.SessionFactory + ): report = SchedulerChain().cleanup(batch_size=1) self.assertEqual(report["tables"]["message"]["deleted"], 3) @@ -184,7 +186,9 @@ class DataCleanupChainTest(unittest.TestCase): ) db.commit() - with self._cleanup_settings(), patch("app.scheduler.SessionFactory", self.SessionFactory): + with self._cleanup_settings(), patch( + "app.application.maintenance.SessionFactory", self.SessionFactory + ): report = SchedulerChain().cleanup(batch_size=10) self.assertEqual(report["tables"]["transferhistory"]["deleted"], 0) @@ -204,7 +208,7 @@ class DataCleanupChainTest(unittest.TestCase): db.commit() with self._cleanup_settings(DATA_CLEANUP_ENABLE=False), patch( - "app.scheduler.SessionFactory", self.SessionFactory + "app.application.maintenance.SessionFactory", self.SessionFactory ): report = SchedulerChain().cleanup(batch_size=10) @@ -233,7 +237,7 @@ class DataCleanupChainTest(unittest.TestCase): db.commit() with self._cleanup_settings(DATA_CLEANUP_MESSAGE_DAYS=7), patch( - "app.scheduler.SessionFactory", self.SessionFactory + "app.application.maintenance.SessionFactory", self.SessionFactory ): report = SchedulerChain().cleanup(batch_size=10) @@ -271,7 +275,7 @@ class DataCleanupChainTest(unittest.TestCase): db.commit() with self._cleanup_settings(DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS=0), patch( - "app.scheduler.SessionFactory", self.SessionFactory + "app.application.maintenance.SessionFactory", self.SessionFactory ): report = SchedulerChain().cleanup(batch_size=10) diff --git a/tests/test_data_cleanup_service.py b/tests/test_data_cleanup_service.py new file mode 100644 index 000000000..5296ab05e --- /dev/null +++ b/tests/test_data_cleanup_service.py @@ -0,0 +1,145 @@ +import ast +from contextlib import nullcontext +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from app.application.maintenance import CleanupPolicy, DataCleanupService +from app.scheduler import SchedulerChain + + +class FakeCleanupRepository: + """记录维护用例调用,并可模拟单表失败。""" + + def __init__(self, *, failing_table: str | None = None) -> None: + """保存故障表名并初始化调用记录。""" + self.failing_table = failing_table + self.calls: list[str] = [] + self._message_results = iter((2, 1, 0)) + + def session(self): + """返回无需真实数据库的上下文。""" + return nullcontext(object()) + + def _delete(self, name: str) -> int: + """记录删除调用并按配置模拟结果或异常。""" + self.calls.append(name) + if name == self.failing_table: + raise RuntimeError("boom") + if name == "message": + return next(self._message_results) + return 0 + + def delete_messages(self, db, cutoff: str, limit: int) -> int: + """模拟消息删除。""" + return self._delete("message") + + def delete_download_history(self, db, cutoff: str, limit: int) -> int: + """模拟下载历史删除。""" + return self._delete("downloadhistory") + + def delete_download_orphans(self, db, limit: int) -> int: + """模拟孤儿文件删除。""" + return self._delete("downloadfiles") + + def delete_site_userdata(self, db, cutoff: str, limit: int) -> int: + """模拟站点用户数据删除。""" + return self._delete("siteuserdata") + + def delete_transfer_history(self, db, cutoff: str, limit: int) -> int: + """模拟整理历史删除。""" + return self._delete("transferhistory") + + def delete_download_failures(self, db, cutoff: str, limit: int) -> int: + """模拟下载失败记录删除。""" + return self._delete("downloadfailure") + + +def _policy(**overrides) -> CleanupPolicy: + """构造所有表默认启用的测试策略。""" + values = { + "enabled": True, + "message_days": 1, + "download_history_days": 1, + "site_userdata_days": 1, + "transfer_history_days": 1, + "download_failure_days": 1, + } + values.update(overrides) + return CleanupPolicy(**values) + + +def test_cleanup_service_owns_batching_report_and_progress() -> None: + """应用服务应独立完成分批循环、报告和进度语义。""" + repository = FakeCleanupRepository() + progress = MagicMock() + service = DataCleanupService( + repository=repository, + policy_reader=_policy, + clock=lambda: datetime(2026, 8, 17, 12, 0, 0), + ) + + report = service.execute(batch_size=2, progress_callback=progress) + + assert report["started_at"] == "2026-08-17 12:00:00" + assert report["tables"]["message"]["deleted"] == 3 + assert report["tables"]["message"]["batches"] == 2 + assert report["total_deleted"] == 3 + assert repository.calls == [ + "message", + "message", + "message", + "downloadhistory", + "downloadfiles", + "siteuserdata", + "transferhistory", + "downloadfailure", + ] + assert progress.call_args.kwargs["value"] == 100 + + +def test_cleanup_service_finishes_other_tables_before_raising_partial_failure() -> None: + """单表失败应被汇总,后续表仍继续处理,再按旧契约抛出异常。""" + repository = FakeCleanupRepository(failing_table="downloadhistory") + service = DataCleanupService( + repository=repository, + policy_reader=_policy, + clock=lambda: datetime(2026, 8, 17, 12, 0, 0), + ) + + with pytest.raises(RuntimeError, match="downloadhistory: boom"): + service.execute(batch_size=2) + + assert repository.calls[-1] == "downloadfailure" + + +def test_scheduler_cleanup_is_a_compatibility_delegate() -> None: + """旧 SchedulerChain 入口应原样转发参数和返回值。""" + service = MagicMock() + service.execute.return_value = {"enabled": True} + progress = MagicMock() + + with patch("app.scheduler.build_cleanup_service", return_value=service): + result = SchedulerChain().cleanup(batch_size=7, progress_callback=progress) + + assert result == {"enabled": True} + service.execute.assert_called_once_with( + batch_size=7, + progress_callback=progress, + ) + + +def test_scheduler_does_not_reclaim_database_cleanup_ownership() -> None: + """调度模块不得重新导入清理模型或数据库会话。""" + scheduler_path = Path(__file__).parents[1] / "app" / "scheduler.py" + tree = ast.parse(scheduler_path.read_text(encoding="utf-8")) + imports = { + node.module + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module + } + + assert "app.db.session" not in imports + assert not any(module.startswith("app.db.models") for module in imports) diff --git a/tests/test_db_declarative_2_0.py b/tests/test_db_declarative_2_0.py index 1a890f91f..334997947 100644 --- a/tests/test_db_declarative_2_0.py +++ b/tests/test_db_declarative_2_0.py @@ -16,8 +16,11 @@ from pathlib import Path import pytest from sqlalchemy.orm import DeclarativeBase -import app.db.models # noqa: F401 确保全部模型完成注册 from app.db import Base +from app.db.models import load_all_models + + +load_all_models() from app.db.base import get_id_column PROJECT_ROOT = Path(__file__).resolve().parents[1] diff --git a/tests/test_download_paths_endpoint.py b/tests/test_download_paths_endpoint.py index 23f36f738..3536dfca0 100644 --- a/tests/test_download_paths_endpoint.py +++ b/tests/test_download_paths_endpoint.py @@ -2,19 +2,24 @@ import unittest from unittest.mock import patch from app.api.endpoints import download as download_endpoint +from app.schemas.token import TokenPayload +from app.schemas.system import TransferDirectoryConf class DownloadPathsEndpointTest(unittest.TestCase): + """验证下载路径接口生成可直接提交给下载接口的路径数据。""" + def test_paths_returns_api_ready_save_paths(self): + """配置的本地和远程目录应转换为完整下载路径响应。""" mocked_dirs = [ - download_endpoint.schemas.TransferDirectoryConf( + TransferDirectoryConf( name="电影目录", priority=1, storage="local", download_path="/downloads/movies", media_type="movie", ), - download_endpoint.schemas.TransferDirectoryConf( + TransferDirectoryConf( name="动漫远程目录", priority=2, storage="rclone", @@ -25,7 +30,7 @@ class DownloadPathsEndpointTest(unittest.TestCase): ] with patch.object(download_endpoint.DirectoryHelper, "get_download_dirs", return_value=mocked_dirs): - ret = download_endpoint.paths(_=download_endpoint.schemas.TokenPayload()) + ret = download_endpoint.paths(_=TokenPayload()) self.assertEqual(len(ret), 2) self.assertEqual(ret[0].name, "电影目录") @@ -45,7 +50,8 @@ class DownloadPathsEndpointTest(unittest.TestCase): self.assertEqual(ret[1].media_category, "动漫") def test_paths_returns_empty_list_when_unconfigured(self): + """未配置目录时接口应返回空列表。""" with patch.object(download_endpoint.DirectoryHelper, "get_download_dirs", return_value=[]): - ret = download_endpoint.paths(_=download_endpoint.schemas.TokenPayload()) + ret = download_endpoint.paths(_=TokenPayload()) self.assertEqual(ret, []) diff --git a/tests/test_download_task_service.py b/tests/test_download_task_service.py new file mode 100644 index 000000000..003b346f6 --- /dev/null +++ b/tests/test_download_task_service.py @@ -0,0 +1,52 @@ +"""下载任务应用服务测试。""" + +from types import SimpleNamespace + +from app.application.download.tasks import DownloadTaskService + + +def test_download_task_service_enriches_history_and_controls_task(): + """下载任务查询应附加历史媒体信息,控制方法只转发规范参数。""" + torrent = SimpleNamespace(hash="hash", media=None) + history = SimpleNamespace( + media_source="tmdb", + media_id="123", + type="电影", + title="测试电影", + seasons=[1], + episodes=[2], + poster="poster", + image="backdrop", + torrent_site="站点", + userid=1, + username="alice", + ) + calls = [] + service = DownloadTaskService( + list_torrents=lambda **kwargs: [torrent], + get_history_by_hashes=lambda hashes: {hashes[0]: history}, + start_torrents=lambda **kwargs: calls.append(("start", kwargs)) or True, + stop_torrents=lambda **kwargs: calls.append(("stop", kwargs)) or True, + remove_torrents=lambda **kwargs: calls.append(("remove", kwargs)) or True, + ) + + assert service.downloading("qb") == [torrent] + assert torrent.media["media_id"] == "123" + assert torrent.username == "alice" + assert service.set_downloading("hash", "start", "qb") is True + assert service.set_downloading("hash", "stop", "qb") is True + assert service.remove_downloading("hash", "qb") is True + assert [call[0] for call in calls] == ["start", "stop", "remove"] + + +def test_download_task_service_rejects_unknown_operation(): + """未知操作保持旧的 False 返回语义。""" + service = DownloadTaskService( + list_torrents=lambda **_kwargs: [], + get_history_by_hashes=lambda _hashes: {}, + start_torrents=lambda **_kwargs: True, + stop_torrents=lambda **_kwargs: True, + remove_torrents=lambda **_kwargs: True, + ) + + assert service.set_downloading("hash", "pause") is False diff --git a/tests/test_event_runtime_components.py b/tests/test_event_runtime_components.py new file mode 100644 index 000000000..72c47195a --- /dev/null +++ b/tests/test_event_runtime_components.py @@ -0,0 +1,128 @@ +"""事件注册、绑定、调度和错误策略组件的独立测试。""" + +import threading +from unittest.mock import Mock + +from app.runtime.event.binding import ( + EventBindingResolver, + EventHandlerBinding, +) +from app.runtime.event.errors import EventErrorPolicy +from app.runtime.events import Event +from app.schemas.types import EventType +from app.startup.modules_initializer import get_host_event_handler_factories + + +class _UnmanagedHandler: + """记录构造次数,用于证明绑定未命中时不会被总线实例化。""" + + constructed = 0 + + def __init__(self) -> None: + """记录任何非预期的隐式构造。""" + type(self).constructed += 1 + + def handle(self, _event: Event) -> None: + """提供可解析的实例方法声明。""" + + +def test_binding_miss_does_not_construct_handler_owner() -> None: + """resolver 未命中时只记录诊断,不能调用 owner_class()。""" + resolvers = {} + binding = EventBindingResolver( + lock=threading.Lock(), + resolvers=lambda: resolvers, + ) + _UnmanagedHandler.constructed = 0 + + assert binding.resolve(_UnmanagedHandler.handle) is None + assert _UnmanagedHandler.constructed == 0 + assert binding.unresolved_handlers() == ( + f"{__name__}._UnmanagedHandler.handle", + ) + + +def test_binding_uses_explicit_resolver_instance() -> None: + """显式 resolver 应返回当前托管实例上的绑定方法。""" + instance = object.__new__(_UnmanagedHandler) + resolvers = { + "test": lambda owner: EventHandlerBinding( + instance=instance, + owner_name="托管处理器", + ) + if owner is _UnmanagedHandler + else None + } + binding = EventBindingResolver( + lock=threading.Lock(), + resolvers=lambda: resolvers, + ) + + method, resolved, class_name, method_name = binding.resolve( + _UnmanagedHandler.handle + ) + + assert method.__self__ is instance + assert resolved.owner_name == "托管处理器" + assert class_name == "_UnmanagedHandler" + assert method_name == "handle" + + +def test_system_error_failure_does_not_rebroadcast() -> None: + """SystemError 处理器自身失败时只能通知和日志降级,不能再次发送事件。""" + notifier = Mock() + emit = Mock() + policy = EventErrorPolicy( + notifier=lambda: notifier, + emit_system_error=emit, + ) + + policy.handle( + event=Event(EventType.SystemError, {}), + module_name="测试模块", + class_name="BrokenHandler", + method_name="handle", + error=RuntimeError("broken"), + ) + + notifier.assert_called_once() + emit.assert_not_called() + + +def test_regular_event_failure_emits_one_system_error() -> None: + """普通事件失败应生成一次结构稳定的 SystemError 载荷。""" + emit = Mock() + policy = EventErrorPolicy( + notifier=lambda: None, + emit_system_error=emit, + ) + + policy.handle( + event=Event(EventType.ConfigChanged, {}), + module_name="测试模块", + class_name="BrokenHandler", + method_name="handle", + error=RuntimeError("broken"), + ) + + payload = emit.call_args.args[0] + assert payload["type"] == "event" + assert payload["event_type"] is EventType.ConfigChanged + assert payload["event_handle"] == "BrokenHandler.handle" + assert payload["error"] == "broken" + + +def test_all_decorated_host_handler_classes_have_explicit_factories() -> None: + """宿主中使用事件装饰器的类必须全部由组合根 resolver 白名单接管。""" + factories = get_host_event_handler_factories() + + assert {owner.__name__ for owner in factories} == { + "Command", + "DownloadChain", + "Scheduler", + "ScrapingChain", + "SearchChain", + "SiteChain", + "SubscribeChain", + "WorkflowChain", + } diff --git a/tests/test_history_mutation_command.py b/tests/test_history_mutation_command.py new file mode 100644 index 000000000..59e66fc64 --- /dev/null +++ b/tests/test_history_mutation_command.py @@ -0,0 +1,123 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app.application.history import ( + DownloadHistoryMutationCommand, + TransferHistoryMutationCommand, +) + + +def _transfer_command(*, history=None, delete_result=True, commit_error=None): + """构造可观察整理历史事务和外部副作用的命令。""" + repository = Mock() + repository.get.return_value = history + download_repository = Mock() + unit_of_work = Mock() + unit_of_work.commit.side_effect = commit_error + dependencies = { + "repository": repository, + "download_repository": download_repository, + "unit_of_work": unit_of_work, + "file_item_factory": lambda payload: SimpleNamespace(**payload), + "delete_media_file": Mock(return_value=delete_result), + "publish_download_file_deleted": Mock(), + "clear_failures": Mock(), + } + return TransferHistoryMutationCommand(**dependencies), dependencies + + +def _history(): + """构造包含源和目标文件信息的整理历史快照。""" + return SimpleNamespace( + id=7, + src="/downloads/demo.mkv", + src_storage="local", + download_hash="abc", + src_fileitem={"path": "/downloads/demo.mkv"}, + dest_fileitem={"path": "/media/demo.mkv"}, + ) + + +def test_download_history_delete_rolls_back_commit_failure(): + """下载历史提交失败时必须回滚请求级事务。""" + repository = Mock() + unit_of_work = Mock() + unit_of_work.commit.side_effect = RuntimeError("commit failed") + command = DownloadHistoryMutationCommand( + repository=repository, + unit_of_work=unit_of_work, + ) + + with pytest.raises(RuntimeError, match="commit failed"): + command.delete(8) + + repository.stage_delete_history.assert_called_once_with(8) + unit_of_work.rollback.assert_called_once_with() + + +def test_transfer_source_delete_failure_keeps_database_unchanged(): + """源文件删除失败时不得删除历史或更新下载文件状态。""" + command, dependencies = _transfer_command( + history=_history(), + delete_result=False, + ) + + result = command.delete(7, delete_source=True) + + assert result.success is False + assert result.message == "/downloads/demo.mkv 删除失败" + dependencies["repository"].stage_delete.assert_not_called() + dependencies["download_repository"].stage_delete_file_by_fullpath.assert_not_called() + dependencies["unit_of_work"].commit.assert_not_called() + + +def test_transfer_delete_commits_before_event_and_retry_cleanup(): + """整理记录提交成功后才发送文件删除事件并清理失败计数。""" + calls = [] + command, dependencies = _transfer_command(history=_history()) + dependencies["unit_of_work"].commit.side_effect = lambda: calls.append("commit") + dependencies["publish_download_file_deleted"].side_effect = ( + lambda _payload: calls.append("event") + ) + dependencies["clear_failures"].side_effect = lambda *_args: calls.append("clear") + + result = command.delete( + 7, + delete_source=True, + delete_destination=True, + ) + + assert result.success is True + dependencies["download_repository"].stage_delete_file_by_fullpath.assert_called_once_with( + "/downloads/demo.mkv" + ) + dependencies["repository"].stage_delete.assert_called_once_with(7) + assert calls == ["commit", "event", "clear"] + + +def test_transfer_commit_failure_suppresses_event_and_retry_cleanup(): + """数据库提交失败时不得发布已删除事件或清除重试状态。""" + command, dependencies = _transfer_command( + history=_history(), + commit_error=RuntimeError("commit failed"), + ) + + with pytest.raises(RuntimeError, match="commit failed"): + command.delete(7, delete_source=True) + + dependencies["unit_of_work"].rollback.assert_called_once_with() + dependencies["publish_download_file_deleted"].assert_not_called() + dependencies["clear_failures"].assert_not_called() + + +def test_transfer_truncate_uses_single_transaction(): + """清空整理历史只暂存一次并统一提交。""" + command, dependencies = _transfer_command() + + result = command.truncate() + + assert result.success is True + dependencies["repository"].stage_truncate.assert_called_once_with() + dependencies["unit_of_work"].commit.assert_called_once_with() diff --git a/tests/test_legacy_import_compat.py b/tests/test_legacy_import_compat.py index 0dd4d8932..2081a870f 100644 --- a/tests/test_legacy_import_compat.py +++ b/tests/test_legacy_import_compat.py @@ -270,12 +270,27 @@ def test_physical_modules_resolve_moved_symbols_without_reverse_imports(): schema_media = importlib.import_module("app.schemas.media") transfer_schema = importlib.import_module("app.schemas.transfer") legacy_transfer = importlib.import_module("app.sdk._legacy.transfer") + history_schema = importlib.import_module("app.schemas.history") + system_schema = importlib.import_module("app.schemas.system") + tmdb_schema = importlib.import_module("app.schemas.tmdb") + types_schema = importlib.import_module("app.schemas.types") + agent_schema = importlib.import_module("app.schemas.agent") + sdk_logging = importlib.import_module("app.sdk.logging") + legacy_logging = importlib.import_module("app.log") + runtime_logging = importlib.import_module("app.runtime.log") schemas_package = importlib.import_module("app.schemas") assert domain_media.build_media_key is schema_media.build_media_key assert domain_media.resolve_media_identity is schema_media.resolve_media_identity assert transfer_schema.TransferTask is legacy_transfer.TransferTask assert transfer_schema.TransferQueue is legacy_transfer.TransferQueue + assert transfer_schema.DownloadHistory is history_schema.DownloadHistory + assert transfer_schema.TransferDirectoryConf is system_schema.TransferDirectoryConf + assert transfer_schema.TmdbEpisode is tmdb_schema.TmdbEpisode + assert transfer_schema.MediaType is types_schema.MediaType + assert agent_schema.ReplyMode is types_schema.ReplyMode + assert sdk_logging.LoggerManager is runtime_logging.LoggerManager + assert legacy_logging.LoggerManager is runtime_logging.LoggerManager assert schemas_package.TransferTask is legacy_transfer.TransferTask assert schemas_package.TransferQueue is legacy_transfer.TransferQueue @@ -360,6 +375,22 @@ def test_symbol_alias_manifest_covers_all_moved_public_symbols(): assert set(SYMBOL_ALIASES["app.schemas.transfer"]) == { "TransferTask", "TransferQueue", + "DownloadHistory", + "TransferDirectoryConf", + "TmdbEpisode", + "MediaType", + } + assert set(SYMBOL_ALIASES["app.schemas.agent"]) == {"ReplyMode"} + assert set(SYMBOL_ALIASES["app.sdk.logging"]) == { + "CustomFormatter", + "LogConfigModel", + "LogEntry", + "LogSettings", + "LoggerManager", + "NonBlockingFileHandler", + "configure_log_settings", + "configure_log_writer", + "log_settings", } assert set(SYMBOL_ALIASES["app.schemas.types"]) == { "MessageChannel", diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index 0c1cf8568..38b1131ca 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -113,6 +113,147 @@ def test_lifespan_continues_after_each_shutdown_owner_failure( _assert_completed_once(step) +def test_lifespan_normal_mode_starts_full_runtime(monkeypatch): + """正常模式必须初始化插件及后台服务,并在退出时逐项停止。""" + shutdown_steps = _patch_lifespan(monkeypatch) + + async def run_lifespan(): + async with lifecycle.lifespan(FastAPI()): + pass + + asyncio.run(run_lifespan()) + + lifecycle.init_modules.assert_awaited_once_with() + for name in ( + "init_plugins", + "init_scheduler", + "init_monitor", + "replay_pending_transfers", + "init_command", + "init_workflow", + ): + getattr(lifecycle, name).assert_called_once_with() + for step in shutdown_steps.values(): + _assert_completed_once(step) + + +def test_lifespan_safe_mode_skips_optional_runtime(monkeypatch): + """安全模式只启动基础模块,并跳过插件及可选后台服务。""" + shutdown_steps = _patch_lifespan(monkeypatch) + monkeypatch.setattr(lifecycle.settings, "MOVIEPILOT_SAFE_MODE", True) + + async def run_lifespan(): + async with lifecycle.lifespan(FastAPI()): + pass + + asyncio.run(run_lifespan()) + + lifecycle.init_modules.assert_awaited_once_with() + for name in ( + "init_plugins", + "init_scheduler", + "init_monitor", + "replay_pending_transfers", + "init_command", + "init_workflow", + ): + getattr(lifecycle, name).assert_not_called() + for name in ( + "backup_plugins", + "stop_workflow", + "stop_command", + "stop_monitor", + "stop_scheduler", + "stop_plugins", + ): + shutdown_steps[name].assert_not_called() + _assert_completed_once(shutdown_steps["stop_modules"]) + _assert_completed_once(shutdown_steps["close_http"]) + _assert_completed_once(shutdown_steps["logger"]) + + +def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None: + """组件清单应显式冻结依赖、模式、启动/关闭顺序和超时预算。""" + app = FastAPI() + normal = lifecycle.get_lifecycle_manifest(app, safe_mode=False) + safe = lifecycle.get_lifecycle_manifest(app, safe_mode=True) + + normal_start = [ + item["name"] + for item in sorted( + (entry for entry in normal if entry["start_order"] is not None), + key=lambda entry: entry["start_order"], + ) + ] + normal_stop = [ + item["name"] + for item in sorted( + (entry for entry in normal if entry["stop_order"] is not None), + key=lambda entry: entry["stop_order"], + ) + ] + safe_names = {item["name"] for item in safe} + + assert normal_start == [ + "HTTP 基础能力", + "领域依赖装配", + "数据库引擎预热", + "数据库连接预算", + "路由", + "模块服务", + "插件备份恢复", + "插件", + "定时器", + "监控器", + "待处理整理回放", + "命令服务", + "工作流", + ] + assert normal_stop == [ + "插件备份", + "工作流", + "命令服务", + "监控器", + "定时器", + "插件", + "模块服务", + "HTTP 基础能力", + ] + assert safe_names == { + "HTTP 基础能力", + "领域依赖装配", + "数据库引擎预热", + "数据库连接预算", + "路由", + "模块服务", + } + assert all(item["start_failure"] == "fail_fast" for item in normal) + assert all(item["stop_failure"] == "continue" for item in normal) + assert all( + item["start_timeout_seconds"] or item["stop_timeout_seconds"] + for item in normal + ) + + +def test_startup_step_records_duration_without_changing_result(monkeypatch): + """启动阶段计时必须保留返回值,并输出稳定的阶段名称和毫秒耗时。""" + perf_counter = MagicMock(side_effect=[10.0, 10.125]) + logger_info = MagicMock() + monkeypatch.setattr(lifecycle.time, "perf_counter", perf_counter) + monkeypatch.setattr(lifecycle.logger, "info", logger_info) + + result = asyncio.run( + lifecycle.run_startup_step("契约测试", lambda: "ready") + ) + + assert result == "ready" + logger_info.assert_called_once_with( + "启动%s完成,耗时=%.2fms", + "契约测试", + 125.0, + ) + + def test_lifespan_creates_global_async_engine_at_startup(monkeypatch): """启动期必须把全局异步引擎建出来一次,让异步侧恢复 fail-fast @@ -260,6 +401,13 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch): ] +def test_asgi_and_main_entrypoints_share_the_same_app_instance(): + """ASGI 工厂入口与主程序入口必须暴露同一个 FastAPI 实例。""" + from app import factory, main + + assert main.app is factory.app + + def test_application_does_not_start_server_after_migration_failure(monkeypatch): """数据库迁移失败时不得启动 API 服务。""" from app import main diff --git a/tests/test_llm_provider_bedrock.py b/tests/test_llm_provider_bedrock.py index c3a6f2657..7a379f47f 100644 --- a/tests/test_llm_provider_bedrock.py +++ b/tests/test_llm_provider_bedrock.py @@ -10,14 +10,20 @@ from app.agent.llm.provider import ( LLMProviderAuthError, LLMProviderManager, ) +from app.foundation.singleton import Singleton + + +_MANAGER_SINGLETON_KEY = (LLMProviderManager, (), frozenset()) @pytest.fixture(autouse=True) def _reset_manager_singleton(): """每个用例前后清理 LLMProviderManager 单例,避免缓存互相污染""" - LLMProviderManager._instances.clear() + previous_manager = Singleton._instances.pop(_MANAGER_SINGLETON_KEY, None) yield - LLMProviderManager._instances.clear() + Singleton._instances.pop(_MANAGER_SINGLETON_KEY, None) + if previous_manager is not None: + Singleton._instances[_MANAGER_SINGLETON_KEY] = previous_manager def test_bedrock_provider_registered(): diff --git a/tests/test_llm_provider_registry.py b/tests/test_llm_provider_registry.py index eed3f6c9f..72a298522 100644 --- a/tests/test_llm_provider_registry.py +++ b/tests/test_llm_provider_registry.py @@ -9,14 +9,25 @@ from app.agent.llm.provider import ( LLMProviderManager, PendingAuthSession, ) +from app.foundation.singleton import Singleton + + +_MANAGER_SINGLETON_KEY = (LLMProviderManager, (), frozenset()) class LlmProviderRegistryTest(unittest.TestCase): def setUp(self): - LLMProviderManager._instances.clear() + """隔离当前测试使用的 LLM 管理器单例,不影响其他运行时单例。""" + self._previous_manager = Singleton._instances.pop( + _MANAGER_SINGLETON_KEY, + None, + ) def tearDown(self): - LLMProviderManager._instances.clear() + """恢复测试前的 LLM 管理器单例。""" + Singleton._instances.pop(_MANAGER_SINGLETON_KEY, None) + if self._previous_manager is not None: + Singleton._instances[_MANAGER_SINGLETON_KEY] = self._previous_manager def test_dynamic_provider_is_exposed_from_models_dev_cache(self): manager = LLMProviderManager() diff --git a/tests/test_media_interaction.py b/tests/test_media_interaction.py index 91f9481d2..3e8fcdd2e 100644 --- a/tests/test_media_interaction.py +++ b/tests/test_media_interaction.py @@ -1736,10 +1736,10 @@ def test_torrent_selection_prompts_download_dir_buttons_before_download(): request.phase = "torrent" with patch( - "app.chain.message.DirectoryHelper.get_download_dirs", + "app.chain.interaction.DirectoryHelper.get_download_dirs", return_value=_build_multiple_movie_download_dirs(), ), patch.object(chain, "post_message") as post_message, patch( - "app.chain.message.DownloadChain.download_single" + "app.chain.interaction.DownloadChain.download_single" ) as download_single: handled = chain.handle_text_interaction( channel=NotificationChannel.Telegram, @@ -1781,10 +1781,10 @@ def test_torrent_selection_skips_download_dir_when_only_one_dir_matches_media(): request.phase = "torrent" with patch( - "app.chain.message.DirectoryHelper.get_download_dirs", + "app.chain.interaction.DirectoryHelper.get_download_dirs", return_value=_build_download_dirs(), ), patch.object(chain, "post_message") as post_message, patch( - "app.chain.message.DownloadChain.download_single", + "app.chain.interaction.DownloadChain.download_single", return_value="hash", ) as download_single: handled = chain.handle_text_interaction( @@ -1821,10 +1821,10 @@ def test_torrent_selection_skips_download_dir_when_user_has_single_dir(): request.phase = "torrent" with patch( - "app.chain.message.DirectoryHelper.get_download_dirs", + "app.chain.interaction.DirectoryHelper.get_download_dirs", return_value=_build_single_download_dir(), ), patch.object(chain, "post_message") as post_message, patch( - "app.chain.message.DownloadChain.download_single", + "app.chain.interaction.DownloadChain.download_single", return_value="hash", ) as download_single: handled = chain.handle_text_interaction( @@ -1861,7 +1861,7 @@ def test_torrent_selection_prompts_text_download_dir_for_plain_channel(): request.phase = "torrent" with patch( - "app.chain.message.DirectoryHelper.get_download_dirs", + "app.chain.interaction.DirectoryHelper.get_download_dirs", return_value=_build_multiple_movie_download_dirs(), ), patch.object(chain, "post_message") as post_message: handled = chain.handle_text_interaction( @@ -1903,10 +1903,10 @@ def test_download_dir_callback_runs_pending_single_download_without_save_path_fo request.pending_download_context = context with patch( - "app.chain.message.DirectoryHelper.get_download_dirs", + "app.chain.interaction.DirectoryHelper.get_download_dirs", return_value=_build_multiple_movie_download_dirs(), ), patch( - "app.chain.message.DownloadChain.download_single", + "app.chain.interaction.DownloadChain.download_single", return_value="hash", ) as download_single: request.download_dirs = chain._get_download_dirs(context.media_info) @@ -1945,10 +1945,10 @@ def test_download_dir_callback_runs_pending_single_download_with_save_path(): request.pending_download_context = context with patch( - "app.chain.message.DirectoryHelper.get_download_dirs", + "app.chain.interaction.DirectoryHelper.get_download_dirs", return_value=_build_multiple_movie_download_dirs(), ), patch( - "app.chain.message.DownloadChain.download_single", + "app.chain.interaction.DownloadChain.download_single", return_value="hash", ) as download_single: request.download_dirs = chain._get_download_dirs(context.media_info) @@ -1987,10 +1987,10 @@ def test_download_dir_text_reply_runs_pending_single_download_without_save_path( request.pending_download_context = context with patch( - "app.chain.message.DirectoryHelper.get_download_dirs", + "app.chain.interaction.DirectoryHelper.get_download_dirs", return_value=_build_multiple_movie_download_dirs(), ), patch( - "app.chain.message.DownloadChain.download_single", + "app.chain.interaction.DownloadChain.download_single", return_value="hash", ) as download_single: request.download_dirs = chain._get_download_dirs() @@ -2015,7 +2015,7 @@ def test_get_download_dirs_keeps_matching_tv_category_dir(): context = _build_tv_context() with patch( - "app.chain.message.DirectoryHelper.get_download_dirs", + "app.chain.interaction.DirectoryHelper.get_download_dirs", return_value=_build_download_dirs(), ): download_dirs = chain._get_download_dirs(context.media_info) diff --git a/tests/test_media_source_routing.py b/tests/test_media_source_routing.py index 02268bc32..83af9ed95 100644 --- a/tests/test_media_source_routing.py +++ b/tests/test_media_source_routing.py @@ -119,14 +119,13 @@ def test_default_recognition_passes_empty_generic_identity() -> None: def test_module_dispatch_always_reaches_plugins() -> None: """模块调度必须始终先执行插件模块。""" chain = _chain_without_init() - chain._ChainBase__execute_plugin_modules = Mock(return_value="plugin") - chain._ChainBase__execute_system_modules = Mock(return_value="system") + chain._module_dispatcher = Mock() + chain._module_dispatcher.dispatch.return_value = "plugin" result = chain.run_module("search_medias", meta=MetaBase("test")) assert result == "plugin" - chain._ChainBase__execute_plugin_modules.assert_called_once() - chain._ChainBase__execute_system_modules.assert_not_called() + chain._module_dispatcher.dispatch.assert_called_once() def test_explicit_search_source_reaches_plugins() -> None: diff --git a/tests/test_mediaserver_conf_sync_interval.py b/tests/test_mediaserver_conf_sync_interval.py index 57b36dc00..0f3883545 100644 --- a/tests/test_mediaserver_conf_sync_interval.py +++ b/tests/test_mediaserver_conf_sync_interval.py @@ -16,8 +16,8 @@ def test_mediaserver_conf_tolerates_blank_sync_interval(): def test_get_configs_skips_invalid_entries(monkeypatch): """单条配置校验失败时应跳过该条,不影响其它服务配置的加载。""" monkeypatch.setattr( - "app.runtime.extensions.service_registry.SystemConfigOper.get", - lambda self, key: [ + "app.runtime.extensions.service_config._service_config_reader", + lambda key: [ {"name": "good", "type": "emby", "enabled": True}, "bad-format", {"name": "bad-type", "type": "plex", "enabled": "maybe"}, diff --git a/tests/test_message_session_service.py b/tests/test_message_session_service.py new file mode 100644 index 000000000..7edfcdc6f --- /dev/null +++ b/tests/test_message_session_service.py @@ -0,0 +1,64 @@ +from datetime import datetime, timedelta +from unittest.mock import Mock + +from app.application.messaging.session import MessageSessionService + + +def test_message_session_service_reuses_and_refreshes_active_session(): + """复用窗口内应返回原会话并刷新最后活动时间。""" + now = datetime(2026, 8, 17, 12, 0, 0) + sessions = {"user": ("session-1", now - timedelta(minutes=5))} + service = MessageSessionService( + sessions=sessions, + timeout_minutes=60, + expired_handler=Mock(), + clock=lambda: now, + ) + + result = service.resolve("user") + + assert result.session_id == "session-1" + assert result.reused is True + assert result.inactive_minutes == 5 + assert sessions["user"] == ("session-1", now) + + +def test_message_session_service_cleans_expired_before_creating_session(): + """创建新会话前应释放同一映射中的全部过期 Agent 会话。""" + now = datetime(2026, 8, 17, 12, 0, 0) + sessions = {"old": ("session-old", now - timedelta(minutes=61))} + expired_handler = Mock() + service = MessageSessionService( + sessions=sessions, + timeout_minutes=60, + expired_handler=expired_handler, + clock=lambda: now, + session_id_factory=lambda user_id, _now: f"new-{user_id}", + ) + + result = service.resolve("new") + + assert result.session_id == "new-new" + assert result.reused is False + assert "old" not in sessions + expired_handler.assert_called_once_with("session-old", "old") + + +def test_message_session_service_bind_and_clear_preserve_old_cleanup_contract(): + """替换绑定时释放旧会话,显式清理只返回被移除的会话 ID。""" + now = datetime(2026, 8, 17, 12, 0, 0) + sessions = {"user": ("session-old", now)} + expired_handler = Mock() + service = MessageSessionService( + sessions=sessions, + timeout_minutes=60, + expired_handler=expired_handler, + clock=lambda: now, + ) + + service.bind("user", "session-new") + + expired_handler.assert_called_once_with("session-old", "user") + assert service.get("user") == ("session-new", now) + assert service.clear("user") == "session-new" + assert service.clear("user") is None diff --git a/tests/test_module_invocation_dispatcher.py b/tests/test_module_invocation_dispatcher.py new file mode 100644 index 000000000..a6182a02e --- /dev/null +++ b/tests/test_module_invocation_dispatcher.py @@ -0,0 +1,209 @@ +"""模块调用调度器的同步、异步协议回归测试。""" + +from __future__ import annotations + +from collections.abc import Callable +from unittest.mock import Mock + +import pytest + +from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher + + +class _PluginCatalog: + """提供固定插件方法表的内存目录。""" + + def __init__(self, modules: dict) -> None: + """保存测试提供的插件模块快照。""" + self.modules = modules + + def get_plugin_modules(self) -> dict: + """返回当前插件模块快照。""" + return self.modules + + +class _ModuleCatalog: + """提供固定宿主模块序列的内存目录。""" + + def __init__(self, modules: list) -> None: + """保存测试提供的宿主模块。""" + self.modules = modules + + def get_running_modules(self, _method: str) -> list: + """返回所有测试模块,由调度器负责优先级排序。""" + return list(self.modules) + + +class _Module: + """实现可配置优先级和调用函数的测试宿主模块。""" + + def __init__(self, name: str, priority: int, func: Callable) -> None: + """保存展示名、优先级和测试调用函数。""" + self._name = name + self._priority = priority + self._func = func + + def get_name(self) -> str: + """返回测试模块展示名。""" + return self._name + + def get_priority(self) -> int: + """返回调度优先级。""" + return self._priority + + def execute(self, *args, **kwargs): + """把模块调用转发到测试函数。""" + return self._func(*args, **kwargs) + + +def _dispatcher( + *, + plugins: dict | None = None, + modules: list | None = None, + async_runner: Callable | None = None, +) -> tuple[ModuleInvocationDispatcher, Mock, Mock, Mock]: + """构造完全内存化的调度器及错误策略替身。""" + plugin_error = Mock() + system_error = Mock() + rate_error = Mock() + + async def default_runner(func, *args, **kwargs): + """在测试事件循环中直接运行同步函数。""" + return func(*args, **kwargs) + + dispatcher = ModuleInvocationDispatcher( + module_catalog=_ModuleCatalog(modules or []), + plugin_catalog=_PluginCatalog(plugins or {}), + plugin_error_handler=plugin_error, + system_error_handler=system_error, + rate_limit_handler=rate_error, + async_function_runner=async_runner or default_runner, + ) + return dispatcher, plugin_error, system_error, rate_error + + +def test_plugin_scalar_short_circuits_system_modules() -> None: + """插件返回非空标量时不得继续执行宿主模块。""" + system_call = Mock(return_value="system") + dispatcher, _, _, _ = _dispatcher( + plugins={("P1", "插件一"): {"execute": lambda: "plugin"}}, + modules=[_Module("系统", 10, system_call)], + ) + + assert dispatcher.dispatch("execute") == "plugin" + system_call.assert_not_called() + + +def test_list_results_merge_in_plugin_then_priority_order() -> None: + """列表结果应先按插件顺序合并,再按宿主优先级继续合并。""" + calls = [] + + def result(value: str) -> Callable: + """生成记录调用顺序并返回单项列表的模块函数。""" + return lambda: calls.append(value) or [value] + + dispatcher, _, _, _ = _dispatcher( + plugins={ + ("P1", "插件一"): {"execute": result("plugin-1")}, + ("P2", "插件二"): {"execute": result("plugin-2")}, + }, + modules=[ + _Module("慢模块", 20, result("system-20")), + _Module("快模块", 10, result("system-10")), + ], + ) + + assert dispatcher.dispatch("execute") == [ + "plugin-1", + "plugin-2", + "system-10", + "system-20", + ] + assert calls == ["plugin-1", "plugin-2", "system-10", "system-20"] + + +def test_system_signature_relay_passes_previous_result() -> None: + """单参数宿主方法应接收上一模块的非列表结果。""" + class FirstModule: + """产生首个字典结果的测试模块。""" + + @staticmethod + def get_name() -> str: + """返回测试模块名。""" + return "第一步" + + @staticmethod + def get_priority() -> int: + """返回第一优先级。""" + return 10 + + @staticmethod + def execute() -> dict: + """产生首个模块结果。""" + return {"value": 1} + + class SecondModule: + """消费上一结果的测试模块。""" + + @staticmethod + def get_name() -> str: + """返回测试模块名。""" + return "第二步" + + @staticmethod + def get_priority() -> int: + """返回第二优先级。""" + return 20 + + @staticmethod + def execute(previous: dict) -> dict: + """接收上一模块结果并生成下一结果。""" + return {"value": previous["value"] + 1} + + dispatcher, _, _, _ = _dispatcher( + modules=[SecondModule(), FirstModule()] + ) + + assert dispatcher.dispatch("execute") == {"value": 2} + + +def test_module_exception_uses_error_policy_and_continues() -> None: + """普通异常应交给错误策略,后续空结果模块仍可继续运行。""" + def broken(): + """模拟模块执行失败。""" + raise RuntimeError("broken") + + dispatcher, _, system_error, _ = _dispatcher( + modules=[ + _Module("失败模块", 10, broken), + _Module("后续模块", 20, lambda: "ok"), + ], + ) + + assert dispatcher.dispatch("execute") == "ok" + system_error.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_dispatch_awaits_coroutines_and_offloads_sync_functions() -> None: + """异步路径应直接等待协程,并通过注入执行器运行同步方法。""" + offloaded = [] + + async def async_runner(func, *args, **kwargs): + """记录被移出事件循环的同步函数。""" + offloaded.append(func) + return func(*args, **kwargs) + + async def plugin_call(): + """返回插件列表结果。""" + return ["plugin"] + + sync_module = _Module("同步模块", 10, lambda: ["system"]) + dispatcher, _, _, _ = _dispatcher( + plugins={("P1", "插件一"): {"execute": plugin_call}}, + modules=[sync_module], + async_runner=async_runner, + ) + + assert await dispatcher.async_dispatch("execute") == ["plugin", "system"] + assert offloaded == [sync_module.execute] diff --git a/tests/test_module_manager_capability_adapter.py b/tests/test_module_manager_capability_adapter.py index b9e78418a..3cdad62b5 100644 --- a/tests/test_module_manager_capability_adapter.py +++ b/tests/test_module_manager_capability_adapter.py @@ -525,6 +525,7 @@ from app.runtime.extensions.host_module_adapter import ( HostModuleAdapter, build_host_module_registry, ) +from app.runtime.extensions.service_config import configure_service_config_reader from app.schemas import ConfigChangeEventData from app.schemas.types import EventType @@ -578,6 +579,7 @@ def get_config(_self, key=None): return config_values.get(key_value) SystemConfigOper.get = get_config +configure_service_config_reader(lambda key: SystemConfigOper().get(key)) from app.runtime.extensions.module_manager import ModuleManager diff --git a/tests/test_module_method_contracts.py b/tests/test_module_method_contracts.py new file mode 100644 index 000000000..8e9a41103 --- /dev/null +++ b/tests/test_module_method_contracts.py @@ -0,0 +1,55 @@ +"""模块字符串方法契约清单的架构测试。""" + +import json +from pathlib import Path + +from app.runtime.extensions.module.contracts import ( + ModuleResultAggregation, + get_module_method_contract, + is_explicit_module_method, +) + + +RUNTIME_BASELINE = ( + Path(__file__).parent / "fixtures" / "architecture" / "runtime-contract-baseline.json" +) + + +def test_all_scanned_module_methods_resolve_a_contract() -> None: + """架构快照中的所有字符串方法都必须能解析到稳定聚合规则。""" + payload = json.loads(RUNTIME_BASELINE.read_text(encoding="utf-8")) + methods = payload["run_module"]["methods"] + + assert methods + for method in methods: + contract = get_module_method_contract(method) + assert contract.aggregation is ModuleResultAggregation.LEGACY + assert contract.plugin_short_circuit is True + + +def test_high_frequency_capability_families_are_explicit() -> None: + """媒体发现、识别、存储和消息族不能退回未分类 legacy 契约。""" + expected_families = { + "async_tmdb_discover": "tmdb", + "async_douban_discover": "douban", + "bangumi_info": "bangumi", + "anilist_info": "anilist", + "recognize_media": "media-recognition", + "mediaserver_items": "media-server", + "list_files": "storage", + "finalize_message": "messaging", + "scheduler_job": "scheduling", + } + + for method, family in expected_families.items(): + assert is_explicit_module_method(method) + assert get_module_method_contract(method).family == family + + +def test_unknown_plugin_method_keeps_legacy_compatibility() -> None: + """第三方插件自定义方法仍应落入开放的 legacy 调度协议。""" + contract = get_module_method_contract("third_party_custom_method") + + assert contract.family == "legacy" + assert contract.supports_sync is True + assert contract.supports_async is True diff --git a/tests/test_music_catalog_service.py b/tests/test_music_catalog_service.py new file mode 100644 index 000000000..98ecc3e99 --- /dev/null +++ b/tests/test_music_catalog_service.py @@ -0,0 +1,52 @@ +"""音乐目录应用服务测试。""" + +import asyncio +from types import SimpleNamespace + +from app.application.music.catalog import MusicCatalogService +from app.domain.context import MusicInfo +from app.schemas.types import MediaSource + + +class _Source: + """提供同步和异步搜索接口的音乐来源替身。""" + + def search_music(self, _meta, limit=20): + """返回重复候选,验证归一化去重。""" + return [ + MusicInfo(media_source=MediaSource.MusicBrainz, media_id="1", title="A"), + MusicInfo(media_source=MediaSource.MusicBrainz, media_id="1", title="A"), + ][:limit] + + async def async_search_music(self, _meta, limit=20): + """返回异步候选。""" + return self.search_music(_meta, limit) + + +def test_music_catalog_service_searches_and_deduplicates_sources(): + """同步和异步音乐搜索都应保留来源身份并去重。""" + service = MusicCatalogService( + source_resolver=lambda source: _Source() if source == MediaSource.MusicBrainz else None, + warning=lambda _message: None, + ) + + assert len(service.search("artist title")) == 1 + assert len(asyncio.run(service.async_search("artist title"))) == 1 + + +def test_music_catalog_service_isolates_failed_source(): + """一个来源失败不应阻断其它来源。""" + errors = [] + + class _Broken: + def search_music(self, *_args, **_kwargs): + """模拟来源错误。""" + raise RuntimeError("broken") + + service = MusicCatalogService( + source_resolver=lambda _source: _Broken(), + warning=errors.append, + ) + + assert service.search("artist title") == [] + assert errors and "broken" in errors[0] diff --git a/tests/test_plugin_catalog_service.py b/tests/test_plugin_catalog_service.py new file mode 100644 index 000000000..fbac02fa7 --- /dev/null +++ b/tests/test_plugin_catalog_service.py @@ -0,0 +1,113 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from packaging.version import Version + +from app.application.plugin.catalog import PluginCatalogService + + +def _plugin(plugin_id: str, version: str, repo_url: str): + """构造目录合并测试使用的最小插件 DTO。""" + return SimpleNamespace( + id=plugin_id, + plugin_version=version, + repo_url=repo_url, + ) + + +def _service(**overrides) -> PluginCatalogService: + """构造完全依赖内存假对象的插件目录应用服务。""" + defaults = { + "market_loader": Mock(return_value={}), + "async_market_loader": Mock(), + "installed_plugins_provider": Mock(return_value=[]), + "plugin_mapper": Mock(), + "is_local_repo": lambda value: str(value).startswith("local://"), + "version_compare": ( + lambda left, operator, right: + operator == ">" and Version(left) > Version(right) + ), + "warning": Mock(), + "error": Mock(), + } + defaults.update(overrides) + return PluginCatalogService(**defaults) + + +def test_merge_prefers_higher_generation_over_same_base_entry(): + """高代际索引出现同 ID 同版本时不再保留基础索引副本。""" + service = _service() + higher = _plugin("Demo", "2.0.0", "https://market-a") + base = _plugin("Demo", "2.0.0", "https://market-b") + + result = service.merge([higher], [base], ["https://market-a", "https://market-b"]) + + assert result == [higher] + + +def test_merge_prefers_newer_version_and_remote_source(): + """相同插件保留最高版本,同版本时市场来源覆盖本地副本。""" + service = _service() + old_remote = _plugin("Demo", "1.0.0", "https://market-a") + new_local = _plugin("Demo", "2.0.0", "local://Demo") + new_remote = _plugin("Demo", "2.0.0", "https://market-b") + + result = service.merge( + [old_remote, new_local, new_remote], + [], + ["https://market-a", "https://market-b"], + ) + + assert result == [new_remote] + + +def test_load_maps_market_entries_with_installed_snapshot(): + """单市场读取只获取一次已安装快照并按索引顺序映射 DTO。""" + mapper = Mock(side_effect=lambda plugin_id, *_args: plugin_id) + installed_provider = Mock(return_value=["Installed"]) + service = _service( + market_loader=Mock(return_value={"First": {}, "Second": {}}), + installed_plugins_provider=installed_provider, + plugin_mapper=mapper, + ) + + result = service.load("https://market-a", "v3", True) + + assert result == ["First", "Second"] + installed_provider.assert_called_once_with() + assert mapper.call_args_list[0].args[3:] == (["Installed"], 2, "v3") + assert mapper.call_args_list[1].args[3:] == (["Installed"], 1, "v3") + + +@pytest.mark.asyncio +async def test_async_collect_isolates_failure_and_completes_progress(): + """异步市场单任务失败时保留成功结果,并把进度推进到完成态。""" + progress = Mock() + error = Mock() + service = _service(error=error) + + async def loader(market: str, package_version: str | None, _force: bool): + """模拟一个失败代际和其余可正常完成的市场请求。""" + await asyncio.sleep(0) + if market == "https://market-a" and package_version == "v3": + raise RuntimeError("unavailable") + version = "2.0.0" if package_version else "1.0.0" + return [_plugin(market, version, market)] + + result = await service.async_collect( + markets=["https://market-a", "https://market-b"], + compatible_flags=["v3"], + force=True, + loader=loader, + progress_callback=progress, + ) + + assert {plugin.id for plugin in result} == { + "https://market-a", + "https://market-b", + } + error.assert_called_once() + assert progress.call_args_list[0].kwargs["value"] == 0 + assert progress.call_args_list[-1].kwargs["value"] == 100 diff --git a/tests/test_plugin_config_command.py b/tests/test_plugin_config_command.py new file mode 100644 index 000000000..34a296b10 --- /dev/null +++ b/tests/test_plugin_config_command.py @@ -0,0 +1,67 @@ +from app.application.plugin.config import PluginConfigCommand + + +def _command(calls: list[tuple], *, save_result: bool = True) -> PluginConfigCommand: + """构造记录端口调用顺序的插件配置用例。""" + return PluginConfigCommand( + save_config=lambda plugin_id, config, force: ( + calls.append(("save", plugin_id, config, force)) or save_result + ), + initialize=lambda plugin_id, config: calls.append( + ("initialize", plugin_id, config) + ), + stop=lambda plugin_id: calls.append(("stop", plugin_id)), + delete_config=lambda plugin_id, force: ( + calls.append(("delete_config", plugin_id, force)) or True + ), + delete_data=lambda plugin_id, force: ( + calls.append(("delete_data", plugin_id, force)) or True + ), + reload_runtime=lambda plugin_id: calls.append(("reload", plugin_id)), + publish_reset=lambda plugin_id: calls.append(("publish", plugin_id)), + refresh_registrations=lambda plugin_id: calls.append( + ("registrations", plugin_id) + ), + ) + + +def test_update_stops_before_runtime_side_effects_when_save_fails() -> None: + """配置持久化失败时不得初始化插件或刷新宿主注册。""" + calls: list[tuple] = [] + + result = _command(calls, save_result=False).update("DemoPlugin", {"enabled": True}) + + assert result.success is False + assert result.message == "插件配置保存失败" + assert calls == [("save", "DemoPlugin", {"enabled": True}, False)] + + +def test_update_refreshes_runtime_only_after_config_is_saved() -> None: + """配置保存成功后按初始化、注册刷新顺序生效。""" + calls: list[tuple] = [] + + result = _command(calls).update("DemoPlugin", {"enabled": True}) + + assert result.success is True + assert calls == [ + ("save", "DemoPlugin", {"enabled": True}, False), + ("initialize", "DemoPlugin", {"enabled": True}), + ("registrations", "DemoPlugin"), + ] + + +def test_reset_preserves_compensation_cleanup_and_reload_order() -> None: + """重置必须先让插件补偿,再停止、清理并重建运行态和注册。""" + calls: list[tuple] = [] + + result = _command(calls).reset("DemoPlugin") + + assert result.success is True + assert calls == [ + ("publish", "DemoPlugin"), + ("stop", "DemoPlugin"), + ("delete_config", "DemoPlugin", True), + ("delete_data", "DemoPlugin", True), + ("reload", "DemoPlugin"), + ("registrations", "DemoPlugin"), + ] diff --git a/tests/test_plugin_dependency_installer.py b/tests/test_plugin_dependency_installer.py new file mode 100644 index 000000000..828193e42 --- /dev/null +++ b/tests/test_plugin_dependency_installer.py @@ -0,0 +1,80 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +from packaging.version import Version + +from app.adapters.system.plugin.dependency import PluginDependencyInstaller + + +def _write_requirements(root: Path, plugin_id: str, content: str) -> None: + """写入一个测试插件的 requirements 文件。""" + plugin_dir = root / plugin_id.lower() + plugin_dir.mkdir(parents=True) + (plugin_dir / "requirements.txt").write_text(content, encoding="utf-8") + + +def test_find_missing_merges_only_installed_plugin_constraints(tmp_path, monkeypatch): + """依赖扫描只覆盖安装清单,并合并同名包的多插件约束。""" + plugin_root = tmp_path / "plugins" + _write_requirements(plugin_root, "Alpha", "Demo-Pkg>=2\n") + _write_requirements(plugin_root, "Beta", "demo.pkg<4\n") + _write_requirements(plugin_root, "Ignored", "unused>=1\n") + installer = PluginDependencyInstaller( + Mock(), + installed_plugins_provider=lambda: ["Alpha", "Beta"], + plugin_dir=plugin_root, + ) + monkeypatch.setattr( + installer, + "_installed_packages", + lambda: {"demo_pkg": Version("1.0")}, + ) + + missing = installer.find_missing() + + assert len(missing) == 1 + assert missing[0].startswith("demo_pkg") + assert ">=2" in missing[0] + assert "<4" in missing[0] + assert all("unused" not in item for item in missing) + + +def test_find_missing_skips_satisfied_constraints(tmp_path, monkeypatch): + """已安装版本满足合并约束时不得重复调用 pip。""" + plugin_root = tmp_path / "plugins" + _write_requirements(plugin_root, "Alpha", "demo>=1,<3\n") + installer = PluginDependencyInstaller( + Mock(), + installed_plugins_provider=lambda: ["Alpha"], + plugin_dir=plugin_root, + ) + monkeypatch.setattr( + installer, + "_installed_packages", + lambda: {"demo": Version("2.0")}, + ) + + assert installer.find_missing() == [] + + +def test_install_uses_adapter_owned_temporary_requirements(tmp_path, monkeypatch): + """批量依赖文件由依赖适配器创建并在 pip 返回后清理。""" + helper = Mock() + helper.pip_install_with_fallback.return_value = (True, "installed") + monkeypatch.setattr( + "app.adapters.system.plugin.dependency.settings", + SimpleNamespace(ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp"), + ) + installer = PluginDependencyInstaller( + helper, + installed_plugins_provider=lambda: [], + plugin_dir=tmp_path / "plugins", + ) + + result = installer.install(["demo>=2", "other"]) + + assert result == (True, "installed") + requirements_file = helper.pip_install_with_fallback.call_args.args[0] + assert requirements_file.name == "requirements.txt" + assert not requirements_file.exists() diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 0c130f664..a79ce6bff 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -7,6 +7,7 @@ from app.api.endpoints.plugin import plugin_history from app.api.endpoints.plugin import plugin_releases from app.api.endpoints.plugin import reset_plugin from app.api.endpoints.system import sync_plugin_market_from_wiki +from app.application.plugin.config import PluginConfigCommand from app.runtime.config import settings from app.runtime.extensions.plugin_manager import PluginManager from app.schemas.event import PluginDataResetEventData @@ -426,13 +427,29 @@ def test_reset_plugin_sends_pre_reset_chain_event_before_deleting_data(): plugin_manager.delete_plugin_config.side_effect = delete_config plugin_manager.delete_plugin_data.side_effect = delete_data - with ( - patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.eventmanager") as eventmanager, - patch("app.api.endpoints.plugin.reload_plugin") as reload_plugin_mock, - ): - eventmanager.send_event.side_effect = lambda etype, data: calls.append(("event", etype, data)) - result = reset_plugin("SubscribeAssistantEnhanced", None) + def publish_reset(plugin_id): + """记录重置前事件,验证应用用例保留补偿时序。""" + calls.append(( + "event", + ChainEventType.PluginDataReset, + PluginDataResetEventData( + plugin_id=plugin_id, + reset_config=True, + reset_data=True, + ), + )) + + command = PluginConfigCommand( + save_config=plugin_manager.save_plugin_config, + initialize=plugin_manager.init_plugin, + stop=plugin_manager.stop, + delete_config=plugin_manager.delete_plugin_config, + delete_data=plugin_manager.delete_plugin_data, + reload_runtime=plugin_manager.reload_plugin, + publish_reset=publish_reset, + refresh_registrations=lambda _plugin_id: None, + ) + result = reset_plugin("SubscribeAssistantEnhanced", None, command) assert result.success is True assert len(calls) == 4 @@ -448,7 +465,7 @@ def test_reset_plugin_sends_pre_reset_chain_event_before_deleting_data(): ("delete_config", "SubscribeAssistantEnhanced", True), ("delete_data", "SubscribeAssistantEnhanced", True), ] - reload_plugin_mock.assert_called_once_with("SubscribeAssistantEnhanced") + plugin_manager.reload_plugin.assert_called_once_with("SubscribeAssistantEnhanced") def test_delete_plugin_config_can_force_delete_after_plugin_is_stopped(): @@ -458,11 +475,12 @@ def test_delete_plugin_config_can_force_delete_after_plugin_is_stopped(): Singleton._instances.pop((PluginManager, (), frozenset()), None) manager = PluginManager() - with patch("app.runtime.extensions.plugin_manager.SystemConfigOper") as system_config_oper: - system_config_oper.return_value.delete.return_value = True + storage = MagicMock() + storage.delete.return_value = True + with patch("app.runtime.extensions.plugin_manager.get_plugin_storage", return_value=storage): assert manager.delete_plugin_config("DemoPlugin", force=True) is True - system_config_oper.return_value.delete.assert_called_once_with("plugin.DemoPlugin") + storage.delete.assert_called_once_with("plugin.DemoPlugin") Singleton._instances.pop((PluginManager, (), frozenset()), None) @@ -474,8 +492,9 @@ def test_delete_plugin_data_can_force_delete_after_plugin_is_stopped(): manager = PluginManager() calls = [] - with patch("app.runtime.extensions.plugin_manager.PluginDataOper") as plugin_data_oper: - plugin_data_oper.return_value.del_data.side_effect = lambda pid: calls.append(pid) + storage = MagicMock() + storage.delete_data.side_effect = lambda pid: calls.append(pid) + with patch("app.runtime.extensions.plugin_manager.get_plugin_storage", return_value=storage): assert manager.delete_plugin_data("DemoPlugin", force=True) is True assert calls == ["DemoPlugin"] diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index fd330fb2c..ba96f6644 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -20,6 +20,33 @@ PLUGIN_ID = "DemoPlugin" REPO_URL = "https://github.com/demo/MoviePilot-Plugins" +@pytest.fixture(autouse=True) +def _configure_plugin_catalog_factory(monkeypatch): + """为直接构造 PluginManager 的测试注入真实目录用例和假持久化接缝。""" + from app.adapters.external.plugin.client import PluginMarketClient + from app.application.plugin.catalog import PluginCatalogService + from app.foundation.version import compare_version + from app.runtime.extensions import plugin_manager as manager_module + + def build_catalog(manager): + """按生产组合方式连接目录服务,但保留测试可替换的依赖。""" + client = PluginMarketClient() + return PluginCatalogService( + market_loader=client.get_plugins, + async_market_loader=client.async_get_plugins, + installed_plugins_provider=lambda: manager_module.get_plugin_storage().read( + manager_module.SystemConfigKey.UserInstalledPlugins + ) or [], + plugin_mapper=manager._process_plugin_info, + is_local_repo=PluginMarketClient.is_local_repo_url, + version_compare=compare_version, + warning=manager_module.logger.warning, + error=manager_module.logger.error, + ) + + monkeypatch.setattr(manager_module, "_plugin_catalog_factory", build_catalog) + + class _FakeResponse: """模拟 requests/httpx 响应对象,覆盖插件 release 安装分支读取的最小协议。""" @@ -656,7 +683,10 @@ class TestPluginHelper: monkeypatch.setattr(plugin_manager, "_plugins", {}) monkeypatch.setattr(plugin_manager, "_running_plugins", {}) monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", SimpleNamespace(VERSION_FLAG="v2")) - monkeypatch.setattr("app.runtime.extensions.plugin_manager.SystemConfigOper", lambda: SimpleNamespace(get=lambda _key: [])) + monkeypatch.setattr( + "app.runtime.extensions.plugin_manager.get_plugin_storage", + lambda: SimpleNamespace(read=lambda _key: []), + ) monkeypatch.setattr( "app.runtime.extensions.plugin_manager._site_auth_level_provider", lambda: 1, @@ -717,7 +747,10 @@ class TestPluginHelper: SimpleNamespace(VERSION_FLAG="v3", PLUGIN_MARKET=REPO_URL), ) monkeypatch.setattr("app.adapters.external.market.settings", SimpleNamespace(VERSION_FLAG="v3")) - monkeypatch.setattr("app.runtime.extensions.plugin_manager.SystemConfigOper", lambda: SimpleNamespace(get=lambda _key: [])) + monkeypatch.setattr( + "app.runtime.extensions.plugin_manager.get_plugin_storage", + lambda: SimpleNamespace(read=lambda _key: []), + ) monkeypatch.setattr( "app.runtime.extensions.plugin_manager._site_auth_level_provider", lambda: 1, @@ -876,10 +909,7 @@ class TestPluginHelper: pytest.skip(f"missing dependency: {exc}") clear_calls = [] - fake_release_method = SimpleNamespace(cache_clear=lambda: clear_calls.append("clear")) - fake_helper = SimpleNamespace(get_plugin_release_versions=fake_release_method) monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings.PLUGIN_MARKET", "https://github.com/demo/plugins") - monkeypatch.setattr("app.runtime.extensions.plugin_manager.PluginHelper", lambda: fake_helper) monkeypatch.setattr(PluginManager, "get_plugins_from_market", lambda *_args, **_kwargs: []) PluginManager().get_online_plugins(force=True) @@ -898,14 +928,10 @@ class TestPluginHelper: async def fake_clear(): clear_calls.append("clear") - fake_release_method = SimpleNamespace(cache_clear=fake_clear) - fake_helper = SimpleNamespace(async_get_plugin_release_versions=fake_release_method) - async def fake_market(*_args, **_kwargs): return [] monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings.PLUGIN_MARKET", "https://github.com/demo/plugins") - monkeypatch.setattr("app.runtime.extensions.plugin_manager.PluginHelper", lambda: fake_helper) monkeypatch.setattr(PluginManager, "async_get_plugins_from_market", fake_market) asyncio.run(PluginManager().async_get_online_plugins(force=True)) @@ -916,7 +942,6 @@ class TestPluginHelper: """单插件版本查询不构建全部本地插件信息。""" try: from app.runtime.extensions.plugin_manager import PluginManager - from app.db.oper.systemconfig import SystemConfigOper from app.schemas.types import SystemConfigKey except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -927,9 +952,12 @@ class TestPluginHelper: plugin_manager = PluginManager() monkeypatch.setattr(plugin_manager, "_plugins", {"DemoPlugin": DemoPlugin}) monkeypatch.setattr( - SystemConfigOper, - "get", - lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None, + "app.runtime.extensions.plugin_manager.get_plugin_storage", + lambda: SimpleNamespace( + read=lambda key: ["DemoPlugin"] + if key == SystemConfigKey.UserInstalledPlugins + else None + ), ) assert plugin_manager.get_local_plugin_version("DemoPlugin") == "1.2.0" diff --git a/tests/test_plugin_install_command.py b/tests/test_plugin_install_command.py new file mode 100644 index 000000000..a56ed306f --- /dev/null +++ b/tests/test_plugin_install_command.py @@ -0,0 +1,303 @@ +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.application.plugin.install import PluginInstallCommand + + +def _command( + *, + installed=None, + plugin_ids=None, + compatibility=None, + installer=None, + reporter=None, + writer=None, + reloader=None, + refresher=None, + checkpointer=None, + committer=None, + rollback=None, +): + """构造可观测每一步副作用的插件安装命令。""" + return PluginInstallCommand( + installed_plugins_reader=Mock(return_value=installed or []), + installed_plugins_writer=writer or AsyncMock(), + plugin_ids_provider=Mock(return_value=plugin_ids or []), + compatibility_checker=compatibility or AsyncMock(return_value=None), + package_installer=installer or AsyncMock(return_value=(True, "ok")), + package_checkpointer=checkpointer or AsyncMock(return_value=object()), + package_committer=committer or AsyncMock(), + package_rollback=rollback or AsyncMock(), + install_reporter=reporter or AsyncMock(), + plugin_reloader=reloader or AsyncMock(), + registration_refresher=refresher or AsyncMock(), + ) + + +@pytest.mark.asyncio +async def test_install_failure_stops_before_report_persistence_and_reload(): + """包安装失败后恢复文件快照,且不得写配置、刷新或上报。""" + reporter = AsyncMock() + writer = AsyncMock() + reloader = AsyncMock() + rollback = AsyncMock() + command = _command( + installer=AsyncMock(return_value=(False, "download failed")), + reporter=reporter, + writer=writer, + reloader=reloader, + rollback=rollback, + ) + + result = await command.execute( + plugin_id="DemoPlugin", + repo_url="https://github.com/demo/plugins", + ) + + assert result.success is False + assert result.package_installed is False + assert result.failure_stage == "package_install" + assert result.rollback.file_restored is True + assert result.rollback.dependency_supported is False + rollback.assert_awaited_once() + reporter.assert_not_awaited() + writer.assert_not_awaited() + reloader.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_success_records_completed_install_stages_in_order(): + """成功安装在提交文件快照后再执行非关键远程上报。""" + calls = [] + + async def install(*_args): + calls.append("package") + return True, "installed" + + checkpoint = object() + + async def create_checkpoint(_plugin_id): + calls.append("checkpoint") + return checkpoint + + async def commit(target): + assert target is checkpoint + calls.append("commit") + + async def report(*_args): + calls.append("report") + + async def write(_plugins): + calls.append("persist") + + async def reload(_plugin_id): + calls.append("reload") + + async def refresh(_plugin_id): + calls.append("registrations") + + result = await _command( + installer=install, + reporter=report, + writer=write, + reloader=reload, + refresher=refresh, + checkpointer=create_checkpoint, + committer=commit, + ).execute( + plugin_id="DemoPlugin", + repo_url="https://github.com/demo/plugins", + ) + + assert result.success is True + assert result.package_installed is True + assert result.installed_list_persisted is True + assert result.runtime_reloaded is True + assert result.registrations_refreshed is True + assert result.reported is True + assert calls == [ + "checkpoint", + "package", + "persist", + "reload", + "registrations", + "commit", + "report", + ] + + +@pytest.mark.asyncio +async def test_existing_plugin_checks_compatibility_without_reinstalling_package(): + """已存在插件只校验兼容性、上报和重载,不重复安装包。""" + installer = AsyncMock() + checkpointer = AsyncMock() + command = _command( + installed=["DemoPlugin"], + plugin_ids=["DemoPlugin"], + installer=installer, + checkpointer=checkpointer, + ) + + result = await command.execute( + plugin_id="DemoPlugin", + repo_url="https://github.com/demo/plugins", + ) + + assert result.success is True + assert result.refreshed_only is True + assert result.package_installed is False + assert result.installed_list_persisted is False + installer.assert_not_awaited() + checkpointer.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_persistence_failure_restores_package_without_touching_runtime(): + """已安装列表保存失败时恢复文件,且运行态尚未开始切换。""" + checkpoint = object() + rollback = AsyncMock() + reloader = AsyncMock() + command = _command( + checkpointer=AsyncMock(return_value=checkpoint), + writer=AsyncMock(side_effect=RuntimeError("db unavailable")), + rollback=rollback, + reloader=reloader, + ) + + result = await command.execute( + plugin_id="DemoPlugin", + repo_url="https://github.com/demo/plugins", + ) + + assert result.success is False + assert result.failure_stage == "installed_list_persistence" + assert result.rollback.file_restored is True + assert result.rollback.installed_list_attempted is False + assert result.rollback.runtime_attempted is False + rollback.assert_awaited_once_with(checkpoint) + reloader.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reload_failure_restores_list_files_and_previous_runtime(): + """重载失败时依次恢复已安装列表、包文件和旧运行态。""" + calls = [] + checkpoint = object() + reload_count = 0 + + async def write(plugin_ids): + calls.append(("persist", list(plugin_ids))) + + async def rollback(target): + assert target is checkpoint + calls.append(("rollback", target)) + + async def reload(_plugin_id): + nonlocal reload_count + reload_count += 1 + calls.append(("reload", reload_count)) + if reload_count == 1: + raise RuntimeError("route registration failed") + + async def refresh(_plugin_id): + calls.append(("registrations", reload_count)) + + result = await _command( + installed=[], + checkpointer=AsyncMock(return_value=checkpoint), + writer=write, + rollback=rollback, + reloader=reload, + refresher=refresh, + ).execute( + plugin_id="DemoPlugin", + repo_url="https://github.com/demo/plugins", + ) + + assert result.success is False + assert result.failure_stage == "runtime_reload" + assert result.rollback.file_restored is True + assert result.rollback.installed_list_restored is True + assert result.rollback.runtime_restored is True + assert result.rollback.registrations_restored is True + assert calls == [ + ("persist", ["DemoPlugin"]), + ("reload", 1), + ("persist", []), + ("rollback", checkpoint), + ("reload", 2), + ("registrations", 2), + ] + + +@pytest.mark.asyncio +async def test_registration_failure_restores_instance_files_and_routes() -> None: + """动态路由刷新失败时恢复列表、文件、旧实例并再次刷新旧注册。""" + calls = [] + checkpoint = object() + refresh_count = 0 + + async def write(plugin_ids): + calls.append(("persist", list(plugin_ids))) + + async def rollback(target): + assert target is checkpoint + calls.append(("rollback", target)) + + async def reload(_plugin_id): + calls.append("reload") + + async def refresh(_plugin_id): + nonlocal refresh_count + refresh_count += 1 + calls.append(("registrations", refresh_count)) + if refresh_count == 1: + raise RuntimeError("route registration failed") + + result = await _command( + checkpointer=AsyncMock(return_value=checkpoint), + writer=write, + rollback=rollback, + reloader=reload, + refresher=refresh, + ).execute( + plugin_id="DemoPlugin", + repo_url="https://github.com/demo/plugins", + ) + + assert result.success is False + assert result.failure_stage == "registration_refresh" + assert result.rollback.file_restored is True + assert result.rollback.installed_list_restored is True + assert result.rollback.runtime_restored is True + assert result.rollback.registrations_restored is True + assert calls == [ + ("persist", ["DemoPlugin"]), + "reload", + ("registrations", 1), + ("persist", []), + ("rollback", checkpoint), + "reload", + ("registrations", 2), + ] + + +@pytest.mark.asyncio +async def test_report_failure_does_not_rollback_completed_local_install(): + """统计上报失败属于非关键副作用,不得撤销已成功的本地安装。""" + rollback = AsyncMock() + result = await _command( + reporter=AsyncMock(side_effect=RuntimeError("server unavailable")), + rollback=rollback, + ).execute( + plugin_id="DemoPlugin", + repo_url="https://github.com/demo/plugins", + ) + + assert result.success is True + assert result.runtime_reloaded is True + assert result.reported is False + assert result.report_error == "server unavailable" + assert "不影响本地安装" in result.message + rollback.assert_not_awaited() diff --git a/tests/test_plugin_local_sync.py b/tests/test_plugin_local_sync.py index b982d7893..9a5ba1536 100644 --- a/tests/test_plugin_local_sync.py +++ b/tests/test_plugin_local_sync.py @@ -64,10 +64,12 @@ def _configure_local_watcher( PLUGIN_AUTO_RELOAD=True, PLUGIN_LOCAL_REPO_PATHS=str(repo_path), ROOT_PATH=tmp_path, + TEMP_PATH=tmp_path / "temp", VERSION_FLAG="v2", ) monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub) monkeypatch.setattr("app.adapters.external.market.settings", settings_stub) + monkeypatch.setattr("app.adapters.system.plugin.package.settings", settings_stub) monkeypatch.setattr("app.runtime.extensions.plugin_manager.watch", lambda *_args, **_kwargs: iter([changes])) @@ -82,6 +84,19 @@ def _set_running_render_mode( ) +def _set_installed_plugins(monkeypatch, plugin_ids: list[str]) -> None: + """注入本地同步测试所需的已安装插件读取端口。""" + storage = SimpleNamespace( + read=lambda key: plugin_ids + if key == SystemConfigKey.UserInstalledPlugins + else None, + ) + monkeypatch.setattr( + "app.runtime.extensions.plugin_manager.get_plugin_storage", + lambda: storage, + ) + + class _FakeSchedulerBackend: """提供插件服务增删所需的最小 APScheduler 契约。""" @@ -119,13 +134,16 @@ def test_dev_local_plugin_candidate_keeps_hot_sync_allowed_when_system_version_l repo_path, source_file = _build_local_plugin_repo(tmp_path) runtime_dir = tmp_path / "app" / "plugins" / "demoplugin" - monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", SimpleNamespace(DEV=True, ROOT_PATH=tmp_path)) + settings_stub = SimpleNamespace( + DEV=True, + ROOT_PATH=tmp_path, + TEMP_PATH=tmp_path / "temp", + ) + monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub) + monkeypatch.setattr("app.adapters.system.plugin.package.settings", settings_stub) monkeypatch.setattr("app.adapters.external.market.settings.PLUGIN_LOCAL_REPO_PATHS", str(repo_path)) monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.SystemConfigOper.get", - lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None, - ) + _set_installed_plugins(monkeypatch, ["DemoPlugin"]) candidate = plugin_manager._get_local_plugin_candidate_from_path(source_file) @@ -174,10 +192,7 @@ def test_local_plugin_sync_without_candidate_respects_system_version_gate( monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub) monkeypatch.setattr("app.adapters.external.market.settings", settings_stub) monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.SystemConfigOper.get", - lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None, - ) + _set_installed_plugins(monkeypatch, ["DemoPlugin"]) assert not plugin_manager._sync_local_plugin_if_installed("DemoPlugin") assert not runtime_dir.exists() @@ -204,10 +219,7 @@ def test_local_federated_asset_batch_syncs_once_without_python_reload( ) _set_running_render_mode(plugin_manager, "vue", "dist/assets") monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.SystemConfigOper.get", - lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None, - ) + _set_installed_plugins(monkeypatch, ["DemoPlugin"]) sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed) reload_spy = Mock() monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy) @@ -461,10 +473,7 @@ def test_local_python_change_still_syncs_and_reloads_plugin( {(Change.modified, str(source_file))}, ) monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.SystemConfigOper.get", - lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None, - ) + _set_installed_plugins(monkeypatch, ["DemoPlugin"]) sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed) reload_spy = Mock() monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy) @@ -493,10 +502,7 @@ def test_local_python_change_rejects_root_federated_path_and_still_reloads( ) _set_running_render_mode(plugin_manager, "vue", dist_path) monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.SystemConfigOper.get", - lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None, - ) + _set_installed_plugins(monkeypatch, ["DemoPlugin"]) sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed) reload_spy = Mock() monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy) @@ -529,10 +535,7 @@ def test_local_python_and_federated_changes_share_one_batch_sync( ) _set_running_render_mode(plugin_manager, "vue", "dist/assets") monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) - monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.SystemConfigOper.get", - lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None, - ) + _set_installed_plugins(monkeypatch, ["DemoPlugin"]) sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed) reload_spy = Mock() monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy) diff --git a/tests/test_plugin_package_manager.py b/tests/test_plugin_package_manager.py new file mode 100644 index 000000000..03fa7dbdc --- /dev/null +++ b/tests/test_plugin_package_manager.py @@ -0,0 +1,104 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +from app.adapters.system.plugin.package import PluginPackageManager + + +def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager: + """构造使用隔离运行目录和事务目录的插件包管理器。""" + monkeypatch.setattr( + "app.adapters.system.plugin.package.settings", + SimpleNamespace(ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp"), + ) + return PluginPackageManager(helper=Mock()) + + +def test_checkpoint_rollback_restores_existing_package(monkeypatch, tmp_path): + """已存在插件在后续阶段失败时应完整恢复原文件。""" + manager = _manager(monkeypatch, tmp_path) + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + plugin_dir.mkdir(parents=True) + source_file = plugin_dir / "__init__.py" + source_file.write_text("old", encoding="utf-8") + + checkpoint = manager.checkpoint("DemoPlugin") + source_file.write_text("new", encoding="utf-8") + (plugin_dir / "partial.py").write_text("partial", encoding="utf-8") + manager.rollback(checkpoint) + + assert source_file.read_text(encoding="utf-8") == "old" + assert not (plugin_dir / "partial.py").exists() + assert not checkpoint.transaction_dir.exists() + + +def test_checkpoint_rollback_removes_new_package(monkeypatch, tmp_path): + """首次安装失败时应删除安装过程创建的不完整目录。""" + manager = _manager(monkeypatch, tmp_path) + checkpoint = manager.checkpoint("DemoPlugin") + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text("partial", encoding="utf-8") + + manager.rollback(checkpoint) + + assert not plugin_dir.exists() + assert not checkpoint.transaction_dir.exists() + + +def test_local_sync_failure_restores_previous_runtime_copy(monkeypatch, tmp_path): + """本地来源不可复制时不得丢失已经运行的插件副本。""" + manager = _manager(monkeypatch, tmp_path) + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + plugin_dir.mkdir(parents=True) + source_file = plugin_dir / "__init__.py" + source_file.write_text("stable", encoding="utf-8") + missing_source = tmp_path / "missing" / "demoplugin" + + assert manager.sync_local("DemoPlugin", missing_source) is False + + assert source_file.read_text(encoding="utf-8") == "stable" + + +def test_clone_rewrites_python_and_federation_assets(monkeypatch, tmp_path): + """插件分身文件处理应由包适配器完成并隔离配置命名空间。""" + manager = _manager(monkeypatch, tmp_path) + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + dist_dir = plugin_dir / "dist" + dist_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text( + "class DemoPlugin:\n" + " plugin_name = 'Demo'\n" + " plugin_desc = 'Description'\n" + " plugin_config_prefix = 'demo_'\n" + " plugin_version = '1.0.0'\n" + " plugin_icon = 'old.png'\n" + " def init_plugin(self, config=None):\n" + " pass\n", + encoding="utf-8", + ) + (dist_dir / "demoplugin.js").write_text( + "const name = 'DemoPlugin'; const css = 'css__DemoPlugin__root';", + encoding="utf-8", + ) + + success, message = manager.clone( + plugin_id="DemoPlugin", + clone_id="DemoPluginBlue", + original_class_name="DemoPlugin", + suffix="blue", + name="Demo Blue", + description="Blue clone", + version="2.0.0", + icon="blue.png", + ) + + clone_dir = tmp_path / "app" / "plugins" / "demopluginblue" + clone_source = (clone_dir / "__init__.py").read_text(encoding="utf-8") + assert success is True + assert message == "文件修改成功" + assert "class DemoPluginblue" in clone_source + assert 'plugin_name = "Demo Blue"' in clone_source + assert 'plugin_config_prefix = "demopluginblue_"' in clone_source + assert "is_clone = True" in clone_source + assert (clone_dir / "dist" / "demopluginblue.js").is_file() diff --git a/tests/test_plugin_projection.py b/tests/test_plugin_projection.py new file mode 100644 index 000000000..b6e53237f --- /dev/null +++ b/tests/test_plugin_projection.py @@ -0,0 +1,163 @@ +"""插件能力投影的隔离契约测试。""" + +from types import SimpleNamespace + +from app.runtime.extensions.plugin.projection import PluginProjection + + +class _Plugin(SimpleNamespace): + """提供可配置插件 hook 的最小运行态插件替身。""" + + def __init__(self, enabled=True, **hooks): + """保存启用状态、插件名称和 hook 实现。""" + super().__init__(plugin_name=hooks.pop("plugin_name", "测试插件"), **hooks) + self._enabled = enabled + + def get_state(self): + """返回预设启用状态。""" + return self._enabled + + def get_name(self): + """返回插件展示名称。""" + return self.plugin_name + + +def test_projection_preserves_commands_and_api_adaptation(): + """命令补 pid,API 补宿主路径与默认认证方式。""" + command = {"cmd": "/demo"} + api = {"path": "/items", "endpoint": object()} + plugin = _Plugin( + get_command=lambda: [command], + get_api=lambda: [api], + ) + projection = PluginProjection({"Demo": plugin}) + + assert projection.commands() == [{"cmd": "/demo", "pid": "Demo"}] + assert projection.apis() == [{ + "path": "/Demo/items", + "endpoint": api["endpoint"], + "auth": "apikey", + }] + + +def test_projection_filters_disabled_stateful_hooks_but_keeps_api_contract(): + """禁用插件不暴露命令/服务/模块/动作,API 保持历史上的独立注册语义。""" + plugin = _Plugin( + enabled=False, + get_command=lambda: [{"cmd": "/demo"}], + get_api=lambda: [{"path": "/items"}], + get_service=lambda: [{"id": "job"}], + get_module=lambda: {"recognize": object()}, + get_actions=lambda: [{"id": "action"}], + ) + projection = PluginProjection({"Demo": plugin}) + + assert projection.commands() == [] + assert projection.services() == [] + assert projection.modules() == {} + assert projection.actions() == [] + assert projection.apis() == [{"path": "/Demo/items", "auth": "apikey"}] + + +def test_projection_preserves_services_modules_actions_and_pid_filter(): + """指定 pid 时只投影目标插件,并保持各 hook 的原始结构。""" + demo = _Plugin( + get_service=lambda: [{"id": "job"}], + get_module=lambda: {"recognize": "handler"}, + get_actions=lambda: [{"id": "action"}], + ) + other = _Plugin(get_service=lambda: [{"id": "other"}]) + projection = PluginProjection({"Demo": demo, "Other": other}) + + assert projection.services("Demo") == [{"id": "job"}] + assert projection.modules("Demo") == { + ("Demo", "测试插件"): {"recognize": "handler"} + } + assert projection.actions("Demo") == [{ + "plugin_id": "Demo", + "plugin_name": "测试插件", + "actions": [{"id": "action"}], + }] + + +def test_projection_isolates_one_plugin_hook_failure(): + """单个插件 hook 失败只记日志,不阻断其他插件投影。""" + errors = [] + log = SimpleNamespace(error=lambda message: errors.append(message)) + + def fail(): + """模拟插件 hook 抛出异常。""" + raise RuntimeError("broken") + + projection = PluginProjection( + { + "Broken": _Plugin(get_service=fail), + "Healthy": _Plugin(get_service=lambda: [{"id": "healthy"}]), + }, + log=log, + ) + + assert projection.services() == [{"id": "healthy"}] + assert errors and "Broken" in errors[0] + + +def test_projection_builds_federation_and_auth_provider_entries(): + """联邦远程入口和插件认证入口保持既有字段与默认值。""" + plugin = _Plugin( + get_render_mode=lambda: ("vue", "dist/assets"), + get_auth_providers=lambda: [{"id": "demo-login"}], + ) + projection = PluginProjection( + {"Demo": plugin}, + remote_entry_factory=lambda plugin_id, path: f"/{plugin_id}/{path}", + ) + + assert projection.remotes() == [{ + "id": "Demo", + "url": "/Demo/dist/assets", + "name": "测试插件", + }] + assert projection.auth_providers() == [{ + "id": "demo-login", + "type": "plugin", + "plugin_id": "Demo", + "name": "测试插件", + "enabled": True, + "component": "AuthPage", + "remote": { + "id": "Demo", + "url": "/Demo/dist/assets", + "name": "测试插件", + }, + }] + + +def test_projection_normalizes_sidebar_and_dashboard_metadata(): + """侧栏和仪表板元数据在投影层完成校验、排序与兼容默认值。""" + plugin = _Plugin( + get_render_mode=lambda: ("vue", "dist"), + get_sidebar_nav=lambda: [{ + "key": "settings", + "section": "invalid", + "permission": "invalid", + "order": "3", + }], + get_dashboard=lambda: ({}, {}, []), + get_dashboard_meta=lambda: [{"name": "状态", "key": "status"}], + ) + projection = PluginProjection({"Demo": plugin}) + + assert projection.sidebar() == [{ + "plugin_id": "Demo", + "nav_key": "settings", + "title": "测试插件", + "icon": "mdi-puzzle", + "section": "system", + "permission": None, + "order": 3, + }] + assert projection.dashboard_metadata() == [{ + "id": "Demo", + "name": "状态", + "key": "status", + }] diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py new file mode 100644 index 000000000..f0f0781b1 --- /dev/null +++ b/tests/test_plugin_registry.py @@ -0,0 +1,38 @@ +from types import SimpleNamespace + +from app.runtime.extensions.plugin.registry import PluginRegistry + + +def test_registry_owns_classes_instances_and_stable_snapshots(): + """注册表集中持有类与实例,快照不受后续热重载修改影响。""" + registry = PluginRegistry() + plugin_class = type("Demo", (), {}) + plugin_instance = SimpleNamespace(plugin_name="演示") + registry.classes["Demo"] = plugin_class + registry.running["Demo"] = plugin_instance + + snapshot = registry.running_snapshot() + registry.running["Other"] = SimpleNamespace(plugin_name="其它") + + assert registry.has_class("Demo") + assert registry.plugin_class("Demo") is plugin_class + assert registry.instance("Demo") is plugin_instance + assert registry.plugin_ids() == ["Demo"] + assert registry.running_ids() == ["Demo", "Other"] + assert list(snapshot) == ["Demo"] + + +def test_registry_clear_preserves_compatibility_mapping_identity(): + """整体停止插件时原地清空,旧调用方持有的字典引用继续有效。""" + registry = PluginRegistry() + classes = registry.classes + running = registry.running + classes["Demo"] = object() + running["Demo"] = object() + + registry.clear() + + assert registry.classes is classes + assert registry.running is running + assert classes == {} + assert running == {} diff --git a/tests/test_runtime_manager_identity.py b/tests/test_runtime_manager_identity.py new file mode 100644 index 000000000..7ca48c6e1 --- /dev/null +++ b/tests/test_runtime_manager_identity.py @@ -0,0 +1,41 @@ +from app.foundation.singleton import Singleton +from app.runtime.events import EventManager, eventmanager +from app.runtime.extensions.module_manager import ModuleManager +from app.runtime.extensions.plugin_manager import PluginManager +from app.sdk.plugins import ModuleManager as SdkModuleManager +from app.sdk.plugins import PluginManager as SdkPluginManager + + +def _singleton_key(manager_type: type) -> tuple: + """返回无参数 Singleton 管理器使用的缓存键。""" + return manager_type, (), frozenset() + + +def test_event_manager_global_and_constructor_share_identity(): + """事件全局对象与公开构造入口必须指向同一单例。""" + assert EventManager() is eventmanager + assert EventManager() is EventManager() + + +def test_module_manager_sdk_and_runtime_share_identity(monkeypatch): + """模块管理器 SDK 与运行时入口必须解析到同一单例对象。""" + instance = object.__new__(ModuleManager) + instances = dict(Singleton._instances) + instances[_singleton_key(ModuleManager)] = instance + monkeypatch.setattr(Singleton, "_instances", instances) + + assert SdkModuleManager is ModuleManager + assert SdkModuleManager() is instance + assert ModuleManager() is instance + + +def test_plugin_manager_sdk_and_runtime_share_identity(monkeypatch): + """插件管理器 SDK 与运行时入口必须解析到同一单例对象。""" + instance = object.__new__(PluginManager) + instances = dict(Singleton._instances) + instances[_singleton_key(PluginManager)] = instance + monkeypatch.setattr(Singleton, "_instances", instances) + + assert SdkPluginManager is PluginManager + assert SdkPluginManager() is instance + assert PluginManager() is instance diff --git a/tests/test_search_media_sources.py b/tests/test_search_media_sources.py index 5f1ffbb40..0c9a22ae5 100644 --- a/tests/test_search_media_sources.py +++ b/tests/test_search_media_sources.py @@ -11,6 +11,7 @@ from app.chain.subscribe import SubscribeChain from app.domain.context import MediaInfo from app.schemas.types import MediaSource, MediaType from app.schemas.media import normalize_media_source +from app.schemas.workflow import MediaInfo as SchemaMediaInfo def test_media_source_normalization_accepts_plugin_source() -> None: @@ -203,7 +204,7 @@ def test_media_detail_does_not_fallback_for_explicit_identity(monkeypatch) -> No ) ) - assert isinstance(result, media_endpoint.schemas.MediaInfo) + assert isinstance(result, SchemaMediaInfo) media_chain.async_recognize_by_meta.assert_not_awaited() @@ -221,7 +222,7 @@ def test_media_detail_rejects_zero_identity_before_chain(monkeypatch) -> None: ) ) - assert isinstance(result, media_endpoint.schemas.MediaInfo) + assert isinstance(result, SchemaMediaInfo) media_chain.assert_not_called() diff --git a/tests/test_search_state_service.py b/tests/test_search_state_service.py new file mode 100644 index 000000000..dfdbcc657 --- /dev/null +++ b/tests/test_search_state_service.py @@ -0,0 +1,69 @@ +"""搜索状态应用服务测试。""" + +import asyncio + +from app.application.search.state import SearchStateService +from app.schemas.types import MediaSource, MediaType + + +def test_search_state_normalizes_identity_and_preserves_cache_contract(): + """搜索参数保存后应保留媒体身份和前端原有字段。""" + saved = [] + service = SearchStateService( + save_cache=lambda value, key: saved.append((key, value)), + load_cache=lambda _key: saved[-1][1], + async_save_cache=lambda value, key: None, + async_load_cache=lambda _key: None, + params_key="params", + result_key="results", + subtitle_result_key="subtitles", + ) + + service.save_params( + keyword="tmdb:123", + media_source=None, + media_id=None, + mtype=MediaType.MOVIE, + sites=[1, 2], + ) + + assert saved == [("params", { + "keyword": "", + "media_source": str(MediaSource.TMDB), + "media_id": "123", + "type": MediaType.MOVIE.value, + "area": "title", + "title": "", + "year": "", + "season": "", + "episode": "", + "sites": "1,2", + "result_type": "torrent", + })] + assert service.load_params() == saved[0][1] + + +def test_search_state_async_paths_use_injected_ports(): + """异步保存和读取必须只使用注入的缓存端口。""" + saved = {} + + async def save(value, key): + """记录异步缓存写入。""" + saved[key] = value + + async def load(key): + """返回异步缓存内容。""" + return saved.get(key) + + service = SearchStateService( + save_cache=lambda *_args: None, + load_cache=lambda _key: None, + async_save_cache=save, + async_load_cache=load, + params_key="params", + result_key="results", + subtitle_result_key="subtitles", + ) + + asyncio.run(service.async_save_params(keyword="hello")) + assert asyncio.run(service.async_load_params())["keyword"] == "hello" diff --git a/tests/test_server_helper.py b/tests/test_server_helper.py index 8eb86fe8c..22bb10603 100644 --- a/tests/test_server_helper.py +++ b/tests/test_server_helper.py @@ -1,9 +1,14 @@ from __future__ import annotations import unittest -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch -from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.external.server import ( + MoviePilotServerHelper, + configure_server_application_services, +) +from app.application.server.report import ServerReportService +from app.application.server.share import ServerSharingService from app.schemas.types import MediaSource @@ -17,6 +22,32 @@ class MoviePilotServerHelperTests(unittest.TestCase): 清理安装用户 ID 缓存,避免不同用例之间互相影响。 """ MoviePilotServerHelper._user_uid = None + configure_server_application_services( + report_service=ServerReportService( + config_reader=Mock(return_value=None), + config_writer=Mock(), + installed_plugins_provider=Mock(return_value=[]), + subscribes_provider=Mock(return_value=[]), + plugin_report_sender=Mock(), + async_plugin_report_sender=AsyncMock(), + subscribe_report_sender=Mock(), + repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url, + ), + sharing_service=ServerSharingService( + subscribe_provider=Mock(return_value=None), + async_subscribe_provider=AsyncMock(return_value=None), + workflow_provider=Mock(return_value=None), + async_workflow_provider=AsyncMock(return_value=None), + user_uuid_provider=Mock(return_value="user-1"), + subscribe_sender=Mock(), + async_subscribe_sender=AsyncMock(), + workflow_sender=Mock(), + async_workflow_sender=AsyncMock(), + response_handler=Mock(return_value=(True, "")), + subscribe_cache_clearer=Mock(), + workflow_cache_clearer=Mock(), + ), + ) def test_server_request_adds_user_uid_header(self): """ diff --git a/tests/test_server_report_service.py b/tests/test_server_report_service.py new file mode 100644 index 000000000..37f408645 --- /dev/null +++ b/tests/test_server_report_service.py @@ -0,0 +1,80 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from app.application.server.report import ServerReportService + + +def _service(**overrides) -> ServerReportService: + """构造不依赖数据库和网络的中心服务上报用例。""" + defaults = { + "config_reader": Mock(return_value=None), + "config_writer": Mock(), + "installed_plugins_provider": Mock(return_value=[]), + "subscribes_provider": Mock(return_value=[]), + "plugin_report_sender": Mock( + return_value=SimpleNamespace(status_code=200) + ), + "async_plugin_report_sender": Mock(), + "subscribe_report_sender": Mock( + return_value=SimpleNamespace(status_code=200) + ), + "repo_url_sanitizer": lambda value: value, + } + defaults.update(overrides) + return ServerReportService(**defaults) + + +def test_subscribe_report_uses_fake_local_reader_and_transport(): + """订阅存量上报只依赖注入的数据读取和发送端口。""" + sender = Mock(return_value=SimpleNamespace(status_code=200)) + subscribe = SimpleNamespace(to_dict=lambda: { + "name": "Demo", + "type": "电影", + "media_source": "themoviedb", + "media_id": "123", + "username": "private", + }) + service = _service( + subscribes_provider=Mock(return_value=[subscribe]), + subscribe_report_sender=sender, + ) + + assert service.report_subscribes(enabled=True) is True + sender.assert_called_once_with([{ + "name": "Demo", + "type": "电影", + "media_source": "themoviedb", + "media_id": "123", + }]) + + +def test_initial_report_marker_is_written_only_after_success(): + """首次上报失败时不得提前写完成标记。""" + writer = Mock() + service = _service(config_writer=writer) + + service.init_report( + enabled=True, + state_key="report", + reporter=Mock(return_value=False), + ) + + writer.assert_not_called() + + +def test_plugin_report_sanitizes_explicit_sources_before_transport(): + """插件统计载荷在进入传输适配器前完成来源脱敏。""" + sender = Mock(return_value=SimpleNamespace(status_code=200)) + service = _service( + plugin_report_sender=sender, + repo_url_sanitizer=lambda value: "local://Demo" if value else value, + ) + + assert service.report_plugins( + enabled=True, + items=[("Demo", "local://Demo?path=/private/repo")], + ) is True + sender.assert_called_once_with([{ + "plugin_id": "Demo", + "repo_url": "local://Demo", + }]) diff --git a/tests/test_server_sharing_service.py b/tests/test_server_sharing_service.py new file mode 100644 index 000000000..e4a598442 --- /dev/null +++ b/tests/test_server_sharing_service.py @@ -0,0 +1,112 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from app.application.server.share import ServerSharingService + + +def _service(**overrides) -> ServerSharingService: + """构造不依赖数据库和网络的中心服务分享用例。""" + def handle_response(response, clear): + """模拟旧 Helper 的成功响应和缓存失效顺序。""" + clear() + return response.status_code == 200, "" + + defaults = { + "subscribe_provider": Mock(return_value=None), + "async_subscribe_provider": AsyncMock(return_value=None), + "workflow_provider": Mock(return_value=None), + "async_workflow_provider": AsyncMock(return_value=None), + "user_uuid_provider": Mock(return_value="user-1"), + "subscribe_sender": Mock(), + "async_subscribe_sender": AsyncMock(), + "workflow_sender": Mock(), + "async_workflow_sender": AsyncMock(), + "response_handler": handle_response, + "subscribe_cache_clearer": Mock(), + "workflow_cache_clearer": Mock(), + } + defaults.update(overrides) + return ServerSharingService(**defaults) + + +def test_subscribe_share_builds_public_payload_and_clears_cache_after_success(): + """订阅分享隐藏本地字段,并在成功响应后触发缓存失效。""" + sender = Mock(return_value=SimpleNamespace(status_code=200)) + clear = Mock() + subscribe = SimpleNamespace(to_dict=lambda: { + "name": "Demo", + "type": "电影", + "media_source": "themoviedb", + "media_id": "123", + "username": "private", + }) + service = _service( + subscribe_provider=Mock(return_value=subscribe), + subscribe_sender=sender, + subscribe_cache_clearer=clear, + ) + + result = service.share_subscribe( + enabled=True, + subscribe_id=1, + share_title="Title", + share_comment="Comment", + share_user="User", + ) + + assert result == (True, "") + payload = sender.call_args.args[0] + assert payload["share_uid"] == "user-1" + assert payload["media_source"] == "themoviedb" + assert "username" not in payload + clear.assert_called_once_with() + + +def test_workflow_validation_stops_before_transport(): + """缺少动作或流程的工作流不会进入中心服务传输。""" + sender = Mock() + workflow = SimpleNamespace(actions=[], flows=[{"id": 1}]) + service = _service( + workflow_provider=Mock(return_value=workflow), + workflow_sender=sender, + ) + + result = service.share_workflow( + enabled=True, + workflow_id=1, + share_title="Title", + share_comment="Comment", + share_user="User", + ) + + assert result == (False, "请分享有动作和流程的工作流") + sender.assert_not_called() + + +def test_async_subscribe_share_uses_async_reader_and_transport(): + """异步分享路径不会回退到同步数据库或网络端口。""" + subscribe = SimpleNamespace(to_dict=lambda: { + "name": "Demo", + "type": "电影", + "media_source": "themoviedb", + "media_id": "123", + }) + reader = AsyncMock(return_value=subscribe) + sender = AsyncMock(return_value=SimpleNamespace(status_code=200)) + service = _service( + async_subscribe_provider=reader, + async_subscribe_sender=sender, + ) + + result = asyncio.run(service.async_share_subscribe( + enabled=True, + subscribe_id=1, + share_title="Title", + share_comment="Comment", + share_user="User", + )) + + assert result == (True, "") + reader.assert_awaited_once_with(1) + sender.assert_awaited_once() diff --git a/tests/test_site_mutation_command.py b/tests/test_site_mutation_command.py new file mode 100644 index 000000000..bad75ec91 --- /dev/null +++ b/tests/test_site_mutation_command.py @@ -0,0 +1,97 @@ +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.application.site.mutation import SiteMutationCommand + + +def _command(**overrides): + """构造可观察站点写用例及其依赖。""" + repository = Mock() + repository.get_by_id = AsyncMock(return_value=object()) + repository.get_by_domain = AsyncMock(return_value=None) + repository.stage_create = AsyncMock() + repository.stage_update = AsyncMock(return_value=True) + repository.stage_delete = AsyncMock() + repository.stage_priorities = AsyncMock() + unit_of_work = Mock() + unit_of_work.commit = AsyncMock() + unit_of_work.rollback = AsyncMock() + dependencies = { + "repository": repository, + "unit_of_work": unit_of_work, + "auth_level_provider": Mock(return_value=2), + "indexer_loader": AsyncMock(return_value={"name": "Demo", "public": True}), + "domain_extractor": lambda value: "demo.example", + "url_normalizer": lambda value: "https://demo.example/", + "publish_updated": AsyncMock(), + "publish_deleted": AsyncMock(), + } + dependencies.update(overrides) + return SiteMutationCommand(**dependencies), dependencies + + +@pytest.mark.asyncio +async def test_create_site_commits_before_updated_event(): + """新增站点必须先提交,再发布站点更新事件。""" + calls = [] + command, dependencies = _command( + unit_of_work=Mock( + commit=AsyncMock(side_effect=lambda: calls.append("commit")), + rollback=AsyncMock(), + ), + publish_updated=AsyncMock(side_effect=lambda _payload: calls.append("event")), + ) + + result = await command.create({"url": "https://demo.example/path"}) + + assert result.success is True + assert calls == ["commit", "event"] + payload = dependencies["repository"].stage_create.await_args.args[0] + assert payload["domain"] == "demo.example" + assert payload["url"] == "https://demo.example/" + assert payload["name"] == "Demo" + assert payload["public"] == 1 + + +@pytest.mark.asyncio +async def test_update_site_returns_legacy_not_found_without_writes(): + """更新不存在站点时保持失败响应且不产生事务或事件。""" + repository = Mock() + repository.get_by_id = AsyncMock(return_value=None) + command, dependencies = _command(repository=repository) + + result = await command.update({"id": 7, "url": "https://demo.example"}) + + assert result.success is False + assert result.message == "站点不存在" + dependencies["unit_of_work"].commit.assert_not_awaited() + dependencies["publish_updated"].assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delete_site_commit_failure_rolls_back_without_event(): + """删除提交失败时必须回滚且不得发送 SiteDeleted。""" + unit_of_work = Mock() + unit_of_work.commit = AsyncMock(side_effect=RuntimeError("commit failed")) + unit_of_work.rollback = AsyncMock() + command, dependencies = _command(unit_of_work=unit_of_work) + + with pytest.raises(RuntimeError, match="commit failed"): + await command.delete(7) + + unit_of_work.rollback.assert_awaited_once_with() + dependencies["publish_deleted"].assert_not_awaited() + + +@pytest.mark.asyncio +async def test_update_priorities_uses_one_transaction(): + """批量站点优先级必须由一个请求级事务统一提交。""" + command, dependencies = _command() + priorities = [{"id": 1, "pri": 2}, {"id": 2, "pri": 1}] + + result = await command.update_priorities(priorities) + + assert result.success is True + dependencies["repository"].stage_priorities.assert_awaited_once_with(priorities) + dependencies["unit_of_work"].commit.assert_awaited_once_with() diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index a817ed3f3..9c0d7b510 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -8,6 +8,7 @@ from unittest import TestCase from unittest.mock import patch from app import schemas +from app.schemas.mediaserver import NotExistMediaInfo from app.schemas.types import MediaType from app.testing import stub_modules @@ -177,8 +178,6 @@ def _load_subscribe_chain_class(): words_module.WordsMatcher = _WordsMatcher - schemas_module = ensure_module("app.schemas", types.ModuleType("app.schemas")) - class _Notification: def __init__(self, *args, **kwargs): self.args = args @@ -192,7 +191,10 @@ def _load_subscribe_chain_class(): "tmdbid", "doubanid", "bangumiid", + "media_source", + "media_id", "season", + "episode_group", "best_version", "save_path", "search_imdbid", @@ -255,16 +257,42 @@ def _load_subscribe_chain_class(): def __init__(self, **kwargs): self.__dict__.update(kwargs) - schemas_module.Message = _Notification - schemas_module.Subscribe = _SubscribeSchema - schemas_module.NotExistMediaInfo = _NotExistMediaInfo - schemas_module.SubscribeEpisodeInfo = _SubscribeEpisodeInfo - schemas_module.SubscrbieInfo = _SubscrbieInfo - schemas_module.SubscribeDownloadFileInfo = _SubscribeDownloadFileInfo - schemas_module.SubscribeLibraryFileInfo = _SubscribeLibraryFileInfo - schemas_module.MediaRecognizeConvertEventData = _MediaRecognizeConvertEventData - schemas_module.SubscribeEpisodesRefreshEventData = _SubscribeEpisodesRefreshEventData - schemas_module.SubscribeCompletionCheckEventData = _SubscribeCompletionCheckEventData + mediaserver_schema_module = ensure_module( + "app.schemas.mediaserver", + types.ModuleType("app.schemas.mediaserver"), + ) + mediaserver_schema_module.NotExistMediaInfo = _NotExistMediaInfo + message_schema_module = ensure_module( + "app.schemas.message", + types.ModuleType("app.schemas.message"), + ) + message_schema_module.Message = _Notification + subscribe_schema_module = ensure_module( + "app.schemas.subscribe", + types.ModuleType("app.schemas.subscribe"), + ) + subscribe_schema_module.SubscribeEpisodeInfo = _SubscribeEpisodeInfo + subscribe_schema_module.SubscrbieInfo = _SubscrbieInfo + subscribe_schema_module.SubscribeDownloadFileInfo = _SubscribeDownloadFileInfo + subscribe_schema_module.SubscribeLibraryFileInfo = _SubscribeLibraryFileInfo + workflow_schema_module = ensure_module( + "app.schemas.workflow", + types.ModuleType("app.schemas.workflow"), + ) + workflow_schema_module.Subscribe = _SubscribeSchema + event_schema_module = ensure_module( + "app.schemas.event", + types.ModuleType("app.schemas.event"), + ) + event_schema_module.MediaRecognizeConvertEventData = ( + _MediaRecognizeConvertEventData + ) + event_schema_module.SubscribeEpisodesRefreshEventData = ( + _SubscribeEpisodesRefreshEventData + ) + event_schema_module.SubscribeCompletionCheckEventData = ( + _SubscribeCompletionCheckEventData + ) logger_module = ensure_module("app.runtime.log", types.ModuleType("app.runtime.log")) @@ -2196,14 +2224,14 @@ class SubscribeProgressEntrypointTest(TestCase): subscribe = self._build_subscribe(best_version=0, note=[1]) missing_all = { "tmdb:10001": { - 1: self.module.schemas.NotExistMediaInfo( + 1: NotExistMediaInfo( season=1, episodes=[], total_episode=5, start_episode=1 ) } } missing_some = { "tmdb:10001": { - 1: self.module.schemas.NotExistMediaInfo( + 1: NotExistMediaInfo( season=1, episodes=[2, 4], total_episode=5, start_episode=1 ) } @@ -2508,7 +2536,7 @@ class SubscribeProgressEntrypointTest(TestCase): ) no_exists = { "tmdb:10001": { - 1: self.module.schemas.NotExistMediaInfo( + 1: NotExistMediaInfo( season=1, episodes=[2, 4], total_episode=5, start_episode=1 ) } diff --git a/tests/test_subscribe_delete_by_identity_command.py b/tests/test_subscribe_delete_by_identity_command.py new file mode 100644 index 000000000..7f71c5c0d --- /dev/null +++ b/tests/test_subscribe_delete_by_identity_command.py @@ -0,0 +1,172 @@ +"""按媒体身份批量删除订阅的应用用例测试。""" + +import pytest + +from app.application.subscription.delete import ( + SubscribeDeletionActor, + SubscribeDeletionCandidate, +) +from app.application.subscription.identity import ( + DeleteSubscriptionsByIdentityCommand, +) +from app.schemas.types import MediaSource + + +class _Repository: + """记录批量订阅删除顺序的仓储替身。""" + + def __init__(self, candidates, calls): + """保存候选订阅和共享调用序列。""" + self.candidates = candidates + self.calls = calls + + async def list_candidates_by_identity(self, *args): + """记录媒体身份查询参数并返回候选订阅。""" + self.calls.append(("list", *args)) + return self.candidates + + async def stage_delete(self, subscribe_id): + """记录待删除订阅。""" + self.calls.append(("delete", subscribe_id)) + + +class _UnitOfWork: + """可注入提交异常的批量事务替身。""" + + def __init__(self, calls, commit_error=None): + """保存共享调用序列与可选提交异常。""" + self.calls = calls + self.commit_error = commit_error + + async def commit(self): + """记录提交并按需失败。""" + self.calls.append(("commit",)) + if self.commit_error: + raise self.commit_error + + async def rollback(self): + """记录回滚。""" + self.calls.append(("rollback",)) + + +def _candidate(subscribe_id, username): + """构造批量删除候选订阅。""" + return SubscribeDeletionCandidate( + subscribe_id=subscribe_id, + username=username, + event_payload={ + "id": subscribe_id, + "username": username, + "media_source": "tmdb", + "media_id": "123", + }, + ) + + +def _command(candidates, calls, commit_error=None, failing_event_id=None): + """构造带可观察事件错误处理的批量删除用例。""" + async def publish(subscribe_id, payload): + """记录事件并按订阅编号注入失败。""" + calls.append(("event", subscribe_id, payload)) + if subscribe_id == failing_event_id: + raise RuntimeError("event failed") + + def handle_error(subscribe_id, error): + """记录被隔离的单条事件异常。""" + calls.append(("event_error", subscribe_id, str(error))) + + return DeleteSubscriptionsByIdentityCommand( + repository=_Repository(candidates, calls), + unit_of_work=_UnitOfWork(calls, commit_error), + publish_deleted=publish, + handle_event_error=handle_error, + ) + + +@pytest.mark.asyncio +async def test_bulk_delete_filters_owner_and_commits_before_events(): + """普通用户只删除自己的候选,并在提交后发送事件。""" + calls = [] + command = _command([_candidate(1, "bob"), _candidate(2, "alice")], calls) + + deleted = await command.execute( + MediaSource.TMDB, + "123", + 1, + None, + SubscribeDeletionActor(username="alice", is_superuser=False), + ) + + assert deleted == 1 + assert [call[0] for call in calls] == ["list", "delete", "commit", "event"] + assert calls[1] == ("delete", 2) + + +@pytest.mark.asyncio +async def test_bulk_delete_commits_even_when_nothing_matches(): + """无匹配订阅时仍保持历史上的空事务提交行为。""" + calls = [] + command = _command([], calls) + + deleted = await command.execute( + MediaSource.TMDB, + "123", + None, + None, + SubscribeDeletionActor(username="alice", is_superuser=False), + ) + + assert deleted == 0 + assert [call[0] for call in calls] == ["list", "commit"] + + +@pytest.mark.asyncio +async def test_bulk_delete_commit_failure_rolls_back_without_events(): + """批量提交失败必须回滚且不发送任何删除事件。""" + calls = [] + command = _command( + [_candidate(1, "alice")], + calls, + commit_error=RuntimeError("commit failed"), + ) + + with pytest.raises(RuntimeError, match="commit failed"): + await command.execute( + MediaSource.TMDB, + "123", + None, + None, + SubscribeDeletionActor(username="alice", is_superuser=False), + ) + + assert [call[0] for call in calls] == ["list", "delete", "commit", "rollback"] + + +@pytest.mark.asyncio +async def test_bulk_delete_isolates_one_event_failure_and_continues(): + """单条事件失败只记录错误,后续已提交订阅仍继续发事件。""" + calls = [] + command = _command( + [_candidate(1, "alice"), _candidate(2, "alice")], + calls, + failing_event_id=1, + ) + + deleted = await command.execute( + MediaSource.TMDB, + "123", + None, + None, + SubscribeDeletionActor(username="alice", is_superuser=False), + ) + + assert deleted == 2 + assert [call[0] for call in calls] == [ + "list", + "delete", + "delete", + "commit", + "event", + "event_error", + "event", + ] diff --git a/tests/test_subscribe_delete_command.py b/tests/test_subscribe_delete_command.py new file mode 100644 index 000000000..f91b40d56 --- /dev/null +++ b/tests/test_subscribe_delete_command.py @@ -0,0 +1,227 @@ +"""订阅删除应用用例的事务、权限与副作用时序测试。""" + +from unittest.mock import AsyncMock + +import pytest + +from app.application.subscription.delete import ( + DeleteSubscribeCommand, + SubscribeDeletionActor, + SubscribeDeletionCandidate, +) +from app.db.models.subscribe import Subscribe +from app.db.oper.subscribe import SubscribeOper + + +class _Repository: + """记录订阅删除用例数据访问顺序的仓储替身。""" + + def __init__(self, candidate, calls): + """保存候选订阅和共享调用序列。""" + self.candidate = candidate + self.calls = calls + + async def get_candidate(self, subscribe_id): + """返回预设候选订阅。""" + self.calls.append(("get", subscribe_id)) + return self.candidate + + async def stage_delete(self, subscribe_id): + """记录待删除的订阅编号。""" + self.calls.append(("delete", subscribe_id)) + + +class _UnitOfWork: + """可注入提交异常的事务替身。""" + + def __init__(self, calls, commit_error=None): + """保存共享调用序列与可选提交异常。""" + self.calls = calls + self.commit_error = commit_error + + async def commit(self): + """记录提交并按需抛出异常。""" + self.calls.append(("commit",)) + if self.commit_error: + raise self.commit_error + + async def rollback(self): + """记录回滚。""" + self.calls.append(("rollback",)) + + +def _candidate(username="alice"): + """构造带完整事件身份字段的订阅删除候选。""" + return SubscribeDeletionCandidate( + subscribe_id=7, + username=username, + event_payload={ + "id": 7, + "username": username, + "media_source": "tmdb", + "media_id": "123", + "season": 2, + "name": "测试订阅", + }, + ) + + +def _command(candidate, calls, commit_error=None, event_error=None, report_error=None): + """构造可观察事件与上报失败的订阅删除用例。""" + async def publish(subscribe_id, subscribe_info): + """记录删除事件并按需失败。""" + calls.append(("event", subscribe_id, subscribe_info)) + if event_error: + raise event_error + + def report(payload): + """记录删除统计并按需失败。""" + calls.append(("report", payload)) + if report_error: + raise report_error + + return DeleteSubscribeCommand( + repository=_Repository(candidate, calls), + unit_of_work=_UnitOfWork(calls, commit_error), + publish_deleted=publish, + report_deleted=report, + ) + + +@pytest.mark.asyncio +async def test_owner_delete_commits_before_event_and_report(): + """owner 删除成功时必须先提交,再按原顺序发送事件和上报。""" + calls = [] + command = _command(_candidate(), calls) + + deleted = await command.execute( + 7, + SubscribeDeletionActor(username="alice", is_superuser=False), + ) + + assert deleted is True + assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"] + assert calls[3][2] == _candidate().event_payload + assert calls[4][1] == { + "media_source": "tmdb", + "media_id": "123", + "season": 2, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("candidate", [None, _candidate("bob"), _candidate(None)]) +async def test_regular_user_cannot_delete_missing_other_or_legacy_subscribe(candidate): + """普通用户对不存在、他人和 legacy 订阅保持无痕成功语义。""" + calls = [] + command = _command(candidate, calls) + + deleted = await command.execute( + 7, + SubscribeDeletionActor(username="alice", is_superuser=False), + ) + + assert deleted is False + assert calls == [("get", 7)] + + +@pytest.mark.asyncio +async def test_superuser_can_delete_other_users_subscribe(): + """超级用户保留全局订阅删除权限。""" + calls = [] + command = _command(_candidate("bob"), calls) + + deleted = await command.execute( + 7, + SubscribeDeletionActor(username="admin", is_superuser=True), + ) + + assert deleted is True + assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"] + + +@pytest.mark.asyncio +async def test_commit_failure_rolls_back_without_event_or_report(): + """提交失败必须回滚,且不得发送成功事件或统计上报。""" + calls = [] + command = _command(_candidate(), calls, commit_error=RuntimeError("commit failed")) + + with pytest.raises(RuntimeError, match="commit failed"): + await command.execute( + 7, + SubscribeDeletionActor(username="alice", is_superuser=False), + ) + + assert [call[0] for call in calls] == ["get", "delete", "commit", "rollback"] + + +@pytest.mark.asyncio +async def test_event_failure_happens_after_commit_and_stops_report(): + """事件失败保持原有传播语义,但事务必须已经提交且不得继续上报。""" + calls = [] + command = _command(_candidate(), calls, event_error=RuntimeError("event failed")) + + with pytest.raises(RuntimeError, match="event failed"): + await command.execute( + 7, + SubscribeDeletionActor(username="alice", is_superuser=False), + ) + + assert [call[0] for call in calls] == ["get", "delete", "commit", "event"] + + +@pytest.mark.asyncio +async def test_report_failure_happens_after_commit_and_event(): + """上报失败保持原有传播语义,且不得改变已经提交和发出的事件。""" + calls = [] + command = _command(_candidate(), calls, report_error=RuntimeError("report failed")) + + with pytest.raises(RuntimeError, match="report failed"): + await command.execute( + 7, + SubscribeDeletionActor(username="alice", is_superuser=False), + ) + + assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"] + + +@pytest.mark.asyncio +async def test_repository_candidate_uses_loaded_orm_snapshot(monkeypatch): + """DB 适配器只向应用层暴露权限字段和完整列快照。""" + subscribe = Subscribe( + id=7, + username="alice", + name="测试订阅", + media_source="tmdb", + media_id="123", + season=2, + ) + + async def async_get(_self, subscribe_id): + """返回无需真实数据库的订阅模型。""" + assert subscribe_id == 7 + return subscribe + + monkeypatch.setattr(SubscribeOper, "async_get", async_get) + + candidate = await SubscribeOper(object()).get_candidate(7) + + assert candidate is not None + assert candidate.subscribe_id == 7 + assert candidate.username == "alice" + assert candidate.event_payload["id"] == 7 + assert candidate.event_payload["media_source"] == "tmdb" + assert candidate.event_payload["media_id"] == "123" + + +@pytest.mark.asyncio +async def test_repository_stage_delete_does_not_commit(): + """真实仓储只登记删除,提交必须由请求级 UnitOfWork 执行。""" + session = type("SessionStub", (), {})() + session.execute = AsyncMock() + session.commit = AsyncMock() + + await SubscribeOper(session).stage_delete(7) + + session.execute.assert_awaited_once() + session.commit.assert_not_awaited() diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 93bac6072..2227aad2a 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -81,6 +81,26 @@ class SubscribeEndpointTest(TestCase): self.assertEqual(getattr(result, "id", None), expected_id) + def test_delete_subscribe_delegates_identity_without_database_access(self): + """按 ID 删除端点只映射用户身份,并保持不存在时也返回成功。""" + from app.api.endpoints.subscribe import delete_subscribe + + command = SimpleNamespace(execute=AsyncMock(return_value=False)) + response = asyncio.run( + delete_subscribe( + subscribe_id=7, + command=command, + current_user=_EndpointUser(name="alice", is_superuser=False), + ) + ) + + self.assertTrue(response.success) + command.execute.assert_awaited_once() + subscribe_id, actor = command.execute.await_args.args + self.assertEqual(subscribe_id, 7) + self.assertEqual(actor.username, "alice") + self.assertFalse(actor.is_superuser) + def test_manage_permission_does_not_allow_cross_user_update(self): """ manage 权限不等于跨用户订阅管理权限,普通用户不能修改他人或 legacy 订阅。 @@ -512,99 +532,76 @@ class SubscribeEndpointTest(TestCase): def test_delete_subscribe_by_media_identity_deletes_owner_candidate(self): """ - 按媒体删除订阅时,应在候选集合中删除当前用户自己的订阅。 + 按媒体删除端点应把媒体身份和当前用户交给应用命令。 """ from app.api.endpoints.subscribe import delete_subscribe_by_media_identity - other = _EndpointSubscribe( - id=15, username="bob", media_source="douban", media_id="douban-1" - ) - own = _EndpointSubscribe( - id=16, username="alice", media_source="douban", media_id="douban-1" - ) - db = _EndpointAsyncDb() - - with patch( - "app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity", - new=AsyncMock(return_value=[other, own]), - ), patch( - "app.api.endpoints.subscribe.build_subscribe_event_payload", - return_value={"id": 16, "media_source": "douban", "media_id": "douban-1"}, - ), patch( - "app.api.endpoints.subscribe.eventmanager.async_send_event", - new=AsyncMock(), - ) as send_event: - response = asyncio.run( - delete_subscribe_by_media_identity( - media_id="douban-1", - media_source=MediaSource.Douban, - db=db, - current_user=_EndpointUser(name="alice", is_superuser=False), - ) + command = SimpleNamespace(execute=AsyncMock(return_value=1)) + response = asyncio.run( + delete_subscribe_by_media_identity( + media_id="douban-1", + media_source=MediaSource.Douban, + command=command, + current_user=_EndpointUser(name="alice", is_superuser=False), ) + ) self.assertTrue(response.success) - self.assertEqual(db.deleted, [own]) - send_event.assert_awaited_once() + command.execute.assert_awaited_once() + media_source, media_id, season, music_type, actor = command.execute.await_args.args + self.assertEqual(media_source, MediaSource.Douban) + self.assertEqual(media_id, "douban-1") + self.assertIsNone(season) + self.assertIsNone(music_type) + self.assertEqual(actor.username, "alice") + self.assertFalse(actor.is_superuser) def test_delete_subscribe_by_media_identity_forwards_music_entity(self): """取消专辑订阅时必须把实体类型传给统一身份查询。""" from app.api.endpoints.subscribe import delete_subscribe_by_media_identity - db = _EndpointAsyncDb() - with patch( - "app.api.endpoints.subscribe.list_subscribes_by_media_identity", - new=AsyncMock(return_value=[]), - ) as list_by_key: - response = asyncio.run( - delete_subscribe_by_media_identity( - media_id="release-group-1", - media_source=MediaSource.MusicBrainz, - music_type="album", - db=db, - current_user=_EndpointUser(name="alice", is_superuser=False), - ) + command = SimpleNamespace(execute=AsyncMock(return_value=0)) + response = asyncio.run( + delete_subscribe_by_media_identity( + media_id="release-group-1", + media_source=MediaSource.MusicBrainz, + music_type="album", + command=command, + current_user=_EndpointUser(name="alice", is_superuser=False), ) + ) self.assertTrue(response.success) - list_by_key.assert_awaited_once_with( - db, + command.execute.assert_awaited_once() + self.assertEqual( + command.execute.await_args.args[:4], + ( MediaSource.MusicBrainz, "release-group-1", None, "album", + ), ) def test_search_subscribes_regular_user_schedules_only_owned_rows(self): """ - 普通用户批量搜索只按自己的订阅 ID 入队。 + 普通用户批量搜索把用户身份交给应用命令。 """ from app.api.endpoints.subscribe import search_subscribes - background_tasks = _EndpointBackgroundTasks() - owned = [ - _EndpointSubscribe(id=17, username="alice", state="R"), - _EndpointSubscribe(id=18, username="alice", state="R"), - ] - - with patch( - "app.api.endpoints.subscribe.Subscribe.async_list_by_username", - new=AsyncMock(return_value=owned), - ), patch("app.api.endpoints.subscribe.Scheduler") as scheduler_cls: - response = asyncio.run( - search_subscribes( - background_tasks=background_tasks, - db=object(), - current_user=_EndpointUser(name="alice", is_superuser=False), - ) + command = SimpleNamespace(execute=AsyncMock(return_value=True)) + response = asyncio.run( + search_subscribes( + command=command, + current_user=_EndpointUser(name="alice", is_superuser=False), ) + ) self.assertTrue(response.success) - self.assertEqual( - [task["kwargs"]["sid"] for task in background_tasks.tasks], - [17, 18], - ) - self.assertEqual(scheduler_cls.return_value.start.call_count, 0) + command.execute.assert_awaited_once() + actor = command.execute.await_args.args[0] + self.assertEqual(actor.username, "alice") + self.assertFalse(actor.is_superuser) def test_subscribe_files_hides_other_user_row(self): """ diff --git a/tests/test_subscribe_search_command.py b/tests/test_subscribe_search_command.py new file mode 100644 index 000000000..c68ba80de --- /dev/null +++ b/tests/test_subscribe_search_command.py @@ -0,0 +1,97 @@ +import pytest + +from app.application.subscription.delete import SubscribeDeletionCandidate +from app.application.subscription.search import ( + SearchSubscriptionsCommand, + SubscribeSearchActor, +) + + +class _Repository: + """提供手工订阅搜索测试需要的归属和列表数据。""" + + def __init__(self, candidate=None, subscribe_ids=None): + """保存预设单条候选和批量编号。""" + self.candidate = candidate + self.subscribe_ids = subscribe_ids or [] + + async def get_candidate(self, _subscribe_id): + """返回预设订阅候选。""" + return self.candidate + + async def list_search_ids(self, username, state): + """校验普通用户搜索状态并返回预设编号。""" + assert username == "alice" + assert state == "R" + return self.subscribe_ids + + +def _candidate(username): + """构造只包含归属信息的订阅候选。""" + return SubscribeDeletionCandidate( + subscribe_id=7, + username=username, + event_payload={}, + ) + + +@pytest.mark.asyncio +async def test_superuser_search_all_uses_single_global_scheduler_request(): + """管理员搜索全部订阅时保持一次 state=R 的全局调度语义。""" + scheduled = [] + command = SearchSubscriptionsCommand( + repository=_Repository(), + schedule_search=lambda sid, state: scheduled.append((sid, state)), + ) + + assert await command.execute( + SubscribeSearchActor(username="admin", is_superuser=True) + ) is True + assert scheduled == [(None, "R")] + + +@pytest.mark.asyncio +async def test_regular_user_search_all_schedules_only_owned_subscriptions(): + """普通用户搜索全部时逐条提交仓储已按归属过滤的订阅。""" + scheduled = [] + command = SearchSubscriptionsCommand( + repository=_Repository(subscribe_ids=[2, 5]), + schedule_search=lambda sid, state: scheduled.append((sid, state)), + ) + + assert await command.execute( + SubscribeSearchActor(username="alice", is_superuser=False) + ) is True + assert scheduled == [(2, None), (5, None)] + + +@pytest.mark.asyncio +async def test_targeted_search_rejects_missing_or_other_users_subscription(): + """单条搜索不得泄漏订阅是否属于其他普通用户。""" + scheduled = [] + command = SearchSubscriptionsCommand( + repository=_Repository(candidate=_candidate("bob")), + schedule_search=lambda sid, state: scheduled.append((sid, state)), + ) + + assert await command.execute( + SubscribeSearchActor(username="alice", is_superuser=False), + subscribe_id=7, + ) is False + assert scheduled == [] + + +@pytest.mark.asyncio +async def test_targeted_search_schedules_accessible_subscription(): + """归属用户搜索单条订阅时提交历史兼容参数。""" + scheduled = [] + command = SearchSubscriptionsCommand( + repository=_Repository(candidate=_candidate("alice")), + schedule_search=lambda sid, state: scheduled.append((sid, state)), + ) + + assert await command.execute( + SubscribeSearchActor(username="alice", is_superuser=False), + subscribe_id=7, + ) is True + assert scheduled == [(7, None)] diff --git a/tests/test_subscription_query_service.py b/tests/test_subscription_query_service.py new file mode 100644 index 000000000..035d46055 --- /dev/null +++ b/tests/test_subscription_query_service.py @@ -0,0 +1,96 @@ +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from app.application.subscription.query import SubscriptionQueryService +from app.chain.subscribe import SubscribeChain +from app.domain.context import MediaInfo +from app.schemas.types import MediaSource, MediaType + + +def test_subscription_query_service_builds_complete_exists_identity() -> None: + """存在性查询必须保留媒体、音乐实体、季和剧集组全部身份维度。""" + repository = Mock() + repository.exists.return_value = True + service = SubscriptionQueryService(repository) + media = MediaInfo( + type=MediaType.TV, + title="Demo", + media_source=MediaSource.TMDB, + media_id="123", + episode_group="group-1", + ) + + assert service.exists(media, SimpleNamespace(begin_season=2)) is True + repository.exists.assert_called_once_with( + media_source=MediaSource.TMDB, + media_id="123", + music_type=None, + season=2, + episode_group="group-1", + ) + + +def test_subscription_query_service_filters_source_and_music_state() -> None: + """来源查询不透传展示字段,音乐状态查询保持 R/P 联合列表语义。""" + repository = Mock() + expected = SimpleNamespace(id=1) + repository.get_by.return_value = expected + repository.list.return_value = [ + SimpleNamespace(type=MediaType.MOVIE.value), + SimpleNamespace(type=MediaType.MUSIC.value), + ] + service = SubscriptionQueryService(repository) + + result = service.get_by_source({ + "id": 1, + "name": "Demo", + "type": MediaType.TV.value, + "season": 1, + "media_source": MediaSource.TMDB, + "media_id": "123", + "music_type": None, + }) + + assert result is expected + repository.get_by.assert_called_once_with( + type=MediaType.TV.value, + season=1, + media_source=MediaSource.TMDB, + media_id="123", + music_type=None, + ) + assert service.has_music("R,P") is True + repository.list.assert_called_once_with("R,P") + + +def test_subscribe_chain_facade_delegates_three_query_slices() -> None: + """SubscribeChain 保持三个公开方法签名并仅负责来源解析和结果转发。""" + service = Mock() + service.exists.return_value = True + service.get_by_source.return_value = SimpleNamespace(id=7) + service.has_music.return_value = True + media = MediaInfo( + type=MediaType.MOVIE, + title="Demo", + media_source=MediaSource.TMDB, + media_id="123", + ) + source = ( + 'Subscribe|{"type":"电影","season":null,' + '"media_source":"themoviedb","media_id":"123"}' + ) + + with patch.object(SubscribeChain, "_subscription_query", return_value=service): + chain = object.__new__(SubscribeChain) + assert chain.exists(media) is True + assert chain.get_subscribe_by_source(source).id == 7 + assert chain.has_music_subscribe() is True + + service.exists.assert_called_once_with(media, None) + service.get_by_source.assert_called_once_with({ + "type": "电影", + "season": None, + "media_source": "themoviedb", + "media_id": "123", + }) + service.has_music.assert_called_once_with("R,P") diff --git a/tests/test_transfer_failed_retry_budget.py b/tests/test_transfer_failed_retry_budget.py index 2a9538901..900fb6731 100644 --- a/tests/test_transfer_failed_retry_budget.py +++ b/tests/test_transfer_failed_retry_budget.py @@ -7,11 +7,13 @@ tests/test_transfer_history_gate.py 逐项覆盖,本文件换一个角度: 以及删除整理记录会让预算重新满额,贴近真实使用场景。 """ from types import SimpleNamespace +from unittest.mock import Mock from app import schemas from app.runtime.config import settings from app.application.history import ( HistoryGateAction, + TransferHistoryMutationCommand, clear_transfer_failures, evaluate_history_gate, failed_retry_count, @@ -92,10 +94,8 @@ def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch): app/api/endpoints/history.py::delete_transfer_history 是用户删除整理记录的入口, 删除时应连带清空失败重试计数,否则重整仍会受上一轮次数限制。 - 该端点依赖 SQLAlchemy Session 与鉴权依赖,这里按仓库内既有做法(参见 - tests/test_manual_transfer_history.py 对 app.api.endpoints.transfer 端点的用法) - 直接以关键字参数调用端点函数本身,绕开 FastAPI 的依赖注入,只替换端点内部 - 实际用到的 TransferHistory.get / TransferHistory.delete 两个类方法。 + 该端点依赖应用命令与鉴权依赖,这里直接注入可观察命令,验证 HTTP 映射与 + 应用层清理失败计数的协作,同时避免重新耦合已迁出的数据库会话参数。 """ from app.api.endpoints.history import delete_transfer_history @@ -109,10 +109,17 @@ def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch): src_fileitem=None, download_hash=None, ) - monkeypatch.setattr("app.api.endpoints.history.TransferHistory.get", - lambda db, history_id: history) - monkeypatch.setattr("app.api.endpoints.history.TransferHistory.delete", - lambda db, history_id: None) + repository = Mock() + repository.get.return_value = history + command = TransferHistoryMutationCommand( + repository=repository, + download_repository=Mock(), + unit_of_work=Mock(), + file_item_factory=lambda payload: SimpleNamespace(**payload), + delete_media_file=Mock(return_value=True), + publish_download_file_deleted=Mock(), + clear_failures=clear_transfer_failures, + ) _reset_failed_retries(src_path, storage) try: @@ -124,7 +131,7 @@ def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch): history_in=schemas.TransferHistory(id=101), deletesrc=False, deletedest=False, - db=object(), + command=command, _="token", ) diff --git a/tests/test_transfer_queue_service.py b/tests/test_transfer_queue_service.py new file mode 100644 index 000000000..d340d5b7e --- /dev/null +++ b/tests/test_transfer_queue_service.py @@ -0,0 +1,58 @@ +from unittest.mock import Mock + +from app.application.transfer import TransferQueueService +from app.schemas.file import FileItem + +from tests.test_transfer_job_manager import make_task + + +def _service(**overrides): + """构造可观测整理队列服务及其默认依赖。""" + dependencies = { + "register_task": Mock(return_value=True), + "enqueue": Mock(), + "before_enqueue": Mock(), + "after_enqueue": Mock(), + "remove_task": Mock(), + "list_tasks": Mock(return_value=["job"]), + "expire_tasks": Mock(), + } + dependencies.update(overrides) + return TransferQueueService(**dependencies), dependencies + + +def test_transfer_queue_service_put_preserves_registration_order(): + """入队必须先登记视图,再登记批次、写队列并落盘。""" + calls = [] + service, _ = _service( + register_task=lambda _task: calls.append("register") or True, + before_enqueue=lambda _task: calls.append("batch"), + enqueue=lambda _item: calls.append("queue"), + after_enqueue=lambda _task: calls.append("pending"), + ) + + assert service.put(make_task(1), Mock()) is True + assert calls == ["register", "batch", "queue", "pending"] + + +def test_transfer_queue_service_rejects_duplicate_without_side_effects(): + """作业视图拒绝重复任务后不得继续产生队列副作用。""" + service, dependencies = _service(register_task=Mock(return_value=False)) + + assert service.put(make_task(1), Mock()) is False + dependencies["before_enqueue"].assert_not_called() + dependencies["enqueue"].assert_not_called() + dependencies["after_enqueue"].assert_not_called() + + +def test_transfer_queue_service_lists_and_removes_through_ports(): + """队列查询先清理失活任务,移除操作只委托作业视图。""" + service, dependencies = _service() + fileitem = FileItem(storage="local", path="/tmp/demo.mkv", type="file") + + assert service.list() == ["job"] + service.remove(fileitem) + + dependencies["expire_tasks"].assert_called_once_with() + dependencies["list_tasks"].assert_called_once_with() + dependencies["remove_task"].assert_called_once_with(fileitem) diff --git a/tests/test_workflow_mutation_command.py b/tests/test_workflow_mutation_command.py new file mode 100644 index 000000000..63345f82a --- /dev/null +++ b/tests/test_workflow_mutation_command.py @@ -0,0 +1,231 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.application.workflow import ( + WorkflowDefinitionCommand, + WorkflowMutationCommand, +) + + +def _workflow(trigger_type="timer", timer="0 0 * * *", event_type="DownloadAdded"): + """构造工作流写用例使用的最小快照。""" + return SimpleNamespace( + id=7, + trigger_type=trigger_type, + timer=timer, + event_type=event_type, + ) + + +def _command(workflow=None, commit_error=None): + """构造可观察工作流事务与运行时副作用的命令。""" + repository = Mock() + repository.get = Mock(return_value=workflow) + repository.stage_state = Mock(return_value=True) + repository.stage_update = Mock(return_value=workflow) + repository.stage_delete = Mock() + unit_of_work = Mock() + unit_of_work.commit = Mock(side_effect=commit_error) + unit_of_work.rollback = Mock() + dependencies = { + "repository": repository, + "unit_of_work": unit_of_work, + "add_timer": Mock(), + "remove_timer": Mock(), + "load_event": Mock(), + "remove_event": Mock(), + "refresh_event": Mock(), + "stop_running": Mock(), + "delete_cache": Mock(), + } + return WorkflowMutationCommand(**dependencies), dependencies + + +def test_start_timer_workflow_commits_before_registering_job(): + """启用定时工作流必须先提交 W 状态,再登记定时任务。""" + calls = [] + command, dependencies = _command(_workflow()) + dependencies["unit_of_work"].commit.side_effect = lambda: calls.append("commit") + dependencies["add_timer"].side_effect = lambda _workflow: calls.append("timer") + + result = command.start(7) + + assert result.success is True + assert calls == ["commit", "timer"] + dependencies["repository"].stage_state.assert_called_once_with(7, "W") + + +def test_start_rejects_invalid_trigger_without_transaction(): + """未知触发类型不得更新数据库或注册运行时触发器。""" + command, dependencies = _command(_workflow(trigger_type="unknown")) + + result = command.start(7) + + assert result.success is False + assert result.message == "工作流触发类型不支持" + dependencies["unit_of_work"].commit.assert_not_called() + + +def test_pause_event_workflow_commits_before_runtime_cleanup(): + """停用事件工作流必须提交 P 状态后再移除事件和停止执行。""" + calls = [] + command, dependencies = _command(_workflow(trigger_type="event", timer=None)) + dependencies["unit_of_work"].commit.side_effect = lambda: calls.append("commit") + dependencies["remove_event"].side_effect = lambda *_args: calls.append("event") + dependencies["stop_running"].side_effect = lambda _id: calls.append("stop") + + result = command.pause(7) + + assert result.success is True + assert calls == ["commit", "event", "stop"] + + +def test_delete_commit_failure_rolls_back_without_runtime_side_effects(): + """删除提交失败必须回滚,且不得删除缓存或运行时触发器。""" + command, dependencies = _command( + _workflow(), + commit_error=RuntimeError("commit failed"), + ) + + with pytest.raises(RuntimeError, match="commit failed"): + command.delete(7) + + dependencies["unit_of_work"].rollback.assert_called_once_with() + dependencies["delete_cache"].assert_not_called() + dependencies["remove_timer"].assert_not_called() + + +def test_update_refreshes_timer_and_event_after_commit(): + """更新工作流提交后重建定时器并刷新事件注册。""" + workflow = _workflow() + command, dependencies = _command(workflow) + + result = command.update({"id": 7, "name": "updated"}) + + assert result.success is True + dependencies["repository"].stage_update.assert_called_once() + dependencies["unit_of_work"].commit.assert_called_once_with() + dependencies["remove_timer"].assert_called_once_with(workflow) + dependencies["add_timer"].assert_called_once_with(workflow) + dependencies["refresh_event"].assert_called_once_with(workflow) + + +def _definition_command(*, existing=None, commit_error=None, report_fork=None): + """构造可观察异步工作流定义事务的命令。""" + repository = Mock() + repository.async_get_by_name = AsyncMock(return_value=existing) + repository.async_get = AsyncMock(return_value=existing) + repository.stage_create = AsyncMock(return_value=SimpleNamespace(id=8)) + repository.stage_reset = AsyncMock(return_value=existing) + unit_of_work = Mock() + unit_of_work.commit = AsyncMock(side_effect=commit_error) + unit_of_work.rollback = AsyncMock() + dependencies = { + "repository": repository, + "unit_of_work": unit_of_work, + "stop_running": Mock(), + "delete_cache": Mock(), + "report_fork": report_fork or AsyncMock(), + } + return WorkflowDefinitionCommand(**dependencies), dependencies + + +@pytest.mark.asyncio +async def test_create_workflow_applies_defaults_and_commits_once(): + """创建工作流由应用用例补齐默认状态并统一提交。""" + command, dependencies = _definition_command() + + result = await command.create({"name": "Demo", "state": None}) + + assert result.success is True + payload = dependencies["repository"].stage_create.await_args.args[0] + assert payload["trigger_type"] == "timer" + assert payload["state"] == "P" + assert payload["add_time"] + dependencies["unit_of_work"].commit.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_create_duplicate_name_has_no_transaction(): + """名称重复时不得暂存或提交工作流。""" + command, dependencies = _definition_command(existing=SimpleNamespace(id=1)) + + result = await command.create({"name": "Demo"}) + + assert result.success is False + dependencies["repository"].stage_create.assert_not_awaited() + dependencies["unit_of_work"].commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_fork_commits_before_reporting_remote_count(): + """共享工作流必须先本地提交,随后才更新远程复用次数。""" + calls = [] + + async def commit(): + calls.append("commit") + + async def report(_share_id): + calls.append("report") + + command, dependencies = _definition_command(report_fork=report) + dependencies["unit_of_work"].commit.side_effect = commit + + result = await command.fork( + { + "name": "Forked", + "actions": "[]", + "flows": "[]", + "context": "{}", + "event_conditions": "{}", + }, + share_id=9, + ) + + assert result.success is True + assert calls == ["commit", "report"] + + +@pytest.mark.asyncio +async def test_fork_invalid_json_stops_before_database_write(): + """共享内容 JSON 无效时不得创建半成品工作流。""" + command, dependencies = _definition_command() + + result = await command.fork({"name": "Forked", "actions": "{"}) + + assert result.success is False + assert result.message == "actions字段JSON格式错误" + dependencies["repository"].stage_create.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reset_commit_failure_does_not_stop_runtime_or_delete_cache(): + """重置提交失败时只回滚数据库,不影响现有运行态。""" + command, dependencies = _definition_command( + existing=_workflow(), + commit_error=RuntimeError("commit failed"), + ) + + with pytest.raises(RuntimeError, match="commit failed"): + await command.reset(7) + + dependencies["unit_of_work"].rollback.assert_awaited_once_with() + dependencies["stop_running"].assert_not_called() + dependencies["delete_cache"].assert_not_called() + + +@pytest.mark.asyncio +async def test_reset_commits_before_runtime_cleanup(): + """工作流重置成功后再停止执行并删除缓存。""" + calls = [] + command, dependencies = _definition_command(existing=_workflow()) + dependencies["unit_of_work"].commit.side_effect = lambda: calls.append("commit") + dependencies["stop_running"].side_effect = lambda _id: calls.append("stop") + dependencies["delete_cache"].side_effect = lambda _id: calls.append("cache") + + result = await command.reset(7) + + assert result.success is True + assert calls == ["commit", "stop", "cache"]