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