Files
MoviePilot/app/application/plugin/install.py
T

385 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""插件安装应用用例。"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, Optional
InstalledPluginsReader = Callable[[], list[str]]
InstalledPluginsWriter = Callable[[list[str]], Awaitable[object]]
PluginIdsProvider = Callable[[], list[str]]
CompatibilityChecker = Callable[[str, str], Awaitable[Optional[str]]]
PackageInstaller = Callable[
[str, str, Optional[str], bool],
Awaitable[tuple[bool, str]],
]
PackageCheckpointer = Callable[[str], Awaitable[Any]]
PackageCheckpointAction = Callable[[Any], Awaitable[object]]
InstallReporter = Callable[[str, Optional[str]], Awaitable[object]]
PluginReloader = Callable[[str], Awaitable[object]]
PluginRegistrationRefresher = Callable[[str], Awaitable[object]]
@dataclass(frozen=True, slots=True)
class PluginInstallRollback:
"""描述失败安装中各类可补偿副作用的恢复结果。"""
file_attempted: bool = False
file_restored: bool = False
installed_list_attempted: bool = False
installed_list_restored: bool = False
runtime_attempted: bool = False
runtime_restored: bool = False
registrations_attempted: bool = False
registrations_restored: bool = False
dependency_supported: bool = False
errors: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class PluginInstallResult:
"""描述插件安装结果、失败阶段和可观察补偿状态。"""
success: bool
message: str = ""
refreshed_only: bool = False
package_installed: bool = False
installed_list_persisted: bool = False
runtime_reloaded: bool = False
registrations_refreshed: bool = False
reported: bool = False
report_error: str = ""
failure_stage: Optional[str] = None
checkpoint_cleanup_error: str = ""
rollback: PluginInstallRollback = field(default_factory=PluginInstallRollback)
class PluginInstallCommand:
"""协调插件检查、包事务、持久化、运行态刷新和安装上报。"""
def __init__(
self,
*,
installed_plugins_reader: InstalledPluginsReader,
installed_plugins_writer: InstalledPluginsWriter,
plugin_ids_provider: PluginIdsProvider,
compatibility_checker: CompatibilityChecker,
package_installer: PackageInstaller,
package_checkpointer: PackageCheckpointer,
package_committer: PackageCheckpointAction,
package_rollback: PackageCheckpointAction,
install_reporter: InstallReporter,
plugin_reloader: PluginReloader,
registration_refresher: PluginRegistrationRefresher,
) -> None:
"""保存安装用例所需端口,不绑定数据库、网络或运行时实现。"""
self._installed_plugins_reader = installed_plugins_reader
self._installed_plugins_writer = installed_plugins_writer
self._plugin_ids_provider = plugin_ids_provider
self._compatibility_checker = compatibility_checker
self._package_installer = package_installer
self._package_checkpointer = package_checkpointer
self._package_committer = package_committer
self._package_rollback = package_rollback
self._install_reporter = install_reporter
self._plugin_reloader = plugin_reloader
self._registration_refresher = registration_refresher
async def execute(
self,
*,
plugin_id: str,
repo_url: Optional[str],
release_version: Optional[str] = None,
force: bool = False,
) -> PluginInstallResult:
"""执行插件安装,并在关键阶段失败时恢复可补偿状态。"""
installed_plugins = list(self._installed_plugins_reader() or [])
refreshed_only = not force and plugin_id in self._plugin_ids_provider()
if refreshed_only:
return await self._refresh_existing(
plugin_id=plugin_id,
repo_url=repo_url,
)
if not repo_url:
return PluginInstallResult(
success=False,
message="没有传入仓库地址,无法正确安装插件,请检查配置",
failure_stage="validation",
)
try:
checkpoint = await self._package_checkpointer(plugin_id)
except Exception as err:
return PluginInstallResult(
success=False,
message=f"创建插件安装快照失败:{err}",
failure_stage="package_checkpoint",
)
try:
state, message = await self._package_installer(
plugin_id,
repo_url,
release_version,
force,
)
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="package_install",
message=str(err),
package_installed=False,
)
if not state:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="package_install",
message=message,
package_installed=False,
)
installed_list_persisted = False
if plugin_id not in installed_plugins:
updated_plugins = [*installed_plugins, plugin_id]
try:
await self._installed_plugins_writer(updated_plugins)
installed_list_persisted = True
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="installed_list_persistence",
message=str(err),
package_installed=True,
)
try:
await self._plugin_reloader(plugin_id)
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="runtime_reload",
message=str(err),
package_installed=True,
installed_list_persisted=installed_list_persisted,
runtime_touched=True,
)
try:
await self._registration_refresher(plugin_id)
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="registration_refresh",
message=str(err),
package_installed=True,
installed_list_persisted=installed_list_persisted,
runtime_touched=True,
registrations_touched=True,
)
checkpoint_cleanup_error = ""
try:
await self._package_committer(checkpoint)
except Exception as err:
checkpoint_cleanup_error = str(err)
reported = False
report_error = ""
try:
report_result = await self._install_reporter(plugin_id, repo_url)
reported = report_result is not False
if not reported:
report_error = "安装上报未确认"
except Exception as err:
report_error = str(err)
result_message = message or "插件安装成功"
if checkpoint_cleanup_error:
result_message = f"{result_message};临时安装快照清理失败"
if report_error:
result_message = f"{result_message};安装上报失败,不影响本地安装"
return PluginInstallResult(
success=True,
message=result_message,
package_installed=True,
installed_list_persisted=installed_list_persisted,
runtime_reloaded=True,
registrations_refreshed=True,
reported=reported,
report_error=report_error,
checkpoint_cleanup_error=checkpoint_cleanup_error,
)
async def _refresh_existing(
self,
*,
plugin_id: str,
repo_url: Optional[str],
) -> PluginInstallResult:
"""刷新已存在插件,不触碰包文件和已安装列表。"""
if repo_url:
compatible_message = await self._compatibility_checker(
plugin_id,
repo_url,
)
if compatible_message:
return PluginInstallResult(
success=False,
message=compatible_message,
refreshed_only=True,
failure_stage="compatibility",
)
failure_stage = "runtime_reload"
try:
await self._plugin_reloader(plugin_id)
failure_stage = "registration_refresh"
await self._registration_refresher(plugin_id)
except Exception as err:
rollback_errors = []
runtime_restored = False
registrations_restored = False
try:
await self._plugin_reloader(plugin_id)
runtime_restored = True
except Exception as rollback_err:
rollback_errors.append(f"运行态恢复失败:{rollback_err}")
if runtime_restored:
try:
await self._registration_refresher(plugin_id)
registrations_restored = True
except Exception as rollback_err:
rollback_errors.append(f"路由和服务注册恢复失败:{rollback_err}")
return PluginInstallResult(
success=False,
message=f"刷新插件运行态失败:{err}",
refreshed_only=True,
failure_stage=failure_stage,
rollback=PluginInstallRollback(
runtime_attempted=True,
runtime_restored=runtime_restored,
registrations_attempted=True,
registrations_restored=registrations_restored,
errors=tuple(rollback_errors),
),
)
reported = False
report_error = ""
try:
report_result = await self._install_reporter(plugin_id, repo_url)
reported = report_result is not False
if not reported:
report_error = "安装上报未确认"
except Exception as err:
report_error = str(err)
return PluginInstallResult(
success=True,
message=(
"插件已存在,已刷新加载"
if not report_error
else "插件已存在,已刷新加载;安装上报失败,不影响本地刷新"
),
refreshed_only=True,
runtime_reloaded=True,
registrations_refreshed=True,
reported=reported,
report_error=report_error,
)
async def _failure(
self,
*,
plugin_id: str,
original_plugins: list[str],
checkpoint: Any,
stage: str,
message: str,
package_installed: bool,
installed_list_persisted: bool = False,
runtime_touched: bool = False,
registrations_touched: bool = False,
) -> PluginInstallResult:
"""按持久化、文件、运行态顺序补偿失败安装并记录结果。"""
errors = []
installed_list_restored = False
if installed_list_persisted:
try:
await self._installed_plugins_writer(list(original_plugins))
installed_list_restored = True
except Exception as err:
errors.append(f"已安装列表恢复失败:{err}")
file_restored = False
try:
await self._package_rollback(checkpoint)
file_restored = True
except Exception as err:
errors.append(f"插件文件恢复失败:{err}")
runtime_restored = False
registrations_restored = False
if runtime_touched:
try:
await self._plugin_reloader(plugin_id)
runtime_restored = True
except Exception as err:
errors.append(f"插件运行态恢复失败:{err}")
if runtime_restored:
try:
await self._registration_refresher(plugin_id)
registrations_restored = True
except Exception as err:
errors.append(f"插件路由和服务注册恢复失败:{err}")
rollback = PluginInstallRollback(
file_attempted=True,
file_restored=file_restored,
installed_list_attempted=installed_list_persisted,
installed_list_restored=installed_list_restored,
runtime_attempted=runtime_touched,
runtime_restored=runtime_restored,
registrations_attempted=runtime_touched or registrations_touched,
registrations_restored=registrations_restored,
dependency_supported=False,
errors=tuple(errors),
)
rollback_message = []
rollback_message.append("插件文件已恢复" if file_restored else "插件文件恢复失败")
if installed_list_persisted:
rollback_message.append(
"已安装列表已恢复"
if installed_list_restored
else "已安装列表恢复失败"
)
if runtime_touched:
rollback_message.append(
"旧运行态已恢复" if runtime_restored else "旧运行态恢复失败"
)
rollback_message.append(
"旧路由和服务注册已恢复"
if registrations_restored
else "旧路由和服务注册恢复失败"
)
rollback_message.append("Python依赖变更不支持自动回滚")
return PluginInstallResult(
success=False,
message=f"{message}{''.join(rollback_message)}",
package_installed=package_installed,
installed_list_persisted=installed_list_persisted,
failure_stage=stage,
rollback=rollback,
)