mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor: 推进后端分层架构治理
This commit is contained in:
Vendored
+57
-135
@@ -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
@@ -0,0 +1 @@
|
||||
"""插件市场外部适配器。"""
|
||||
+95
@@ -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,
|
||||
)
|
||||
Vendored
+85
-171
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""插件包和依赖系统适配器。"""
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -0,0 +1 @@
|
||||
"""Web 框架适配器。"""
|
||||
@@ -0,0 +1 @@
|
||||
"""插件 Web 适配器。"""
|
||||
@@ -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)}"
|
||||
)
|
||||
Reference in New Issue
Block a user