refactor: 推进后端分层架构治理

This commit is contained in:
jxxghp
2026-08-18 00:29:14 +08:00
parent 5d0bacabd5
commit 5128ae9e1e
363 changed files with 34179 additions and 6019 deletions
+57 -135
View File
@@ -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,
+1
View File
@@ -0,0 +1 @@
"""插件市场外部适配器。"""
+95
View File
@@ -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,
)
+85 -171
View File
@@ -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]:
+9 -7
View File
@@ -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,
+1
View File
@@ -0,0 +1 @@
"""插件包和依赖系统适配器。"""
+206
View File
@@ -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)
+374
View File
@@ -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}")
+1
View File
@@ -0,0 +1 @@
"""Web 框架适配器。"""
+1
View File
@@ -0,0 +1 @@
"""插件 Web 适配器。"""
+106
View File
@@ -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)}"
)