mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
fix: 完善插件恢复与运行态收敛 (#6376)
This commit is contained in:
@@ -11,7 +11,7 @@ from app.runtime.config import settings
|
||||
from app.runtime.extensions.plugin.contracts import supports_plugin_hook
|
||||
from app.runtime.extensions.plugin.storage import PluginStorage
|
||||
from app.runtime.extensions.plugin.system import PluginSystemServices
|
||||
from app.schemas.plugin import Plugin
|
||||
from app.schemas.plugin import Plugin, PluginRuntimeStatus
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class PluginCatalogFacade:
|
||||
map_plugin: Callable[..., Optional[Plugin]],
|
||||
auth_checker: Callable[..., bool],
|
||||
plugin_attr: Callable[[str, str], Any],
|
||||
runtime_status: Callable[[str], Optional[PluginRuntimeStatus]],
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存注册表、目录服务和插件外部系统端口。"""
|
||||
@@ -44,6 +45,7 @@ class PluginCatalogFacade:
|
||||
self._map_plugin = map_plugin
|
||||
self._auth_checker = auth_checker
|
||||
self._plugin_attr = plugin_attr
|
||||
self._runtime_status = runtime_status
|
||||
self._logger = log
|
||||
|
||||
def online(self, force: bool = False) -> list[Plugin]:
|
||||
@@ -70,6 +72,7 @@ class PluginCatalogFacade:
|
||||
id=plugin_id,
|
||||
installed=plugin_id in installed,
|
||||
state=self._safe_state(plugin_id, plugin_instance),
|
||||
runtime_status=self._runtime_status(plugin_id),
|
||||
has_page=supports_plugin_hook(plugin_class, "get_page"),
|
||||
plugin_public_key=getattr(plugin_class, "plugin_public_key", None),
|
||||
plugin_name=getattr(plugin_class, "plugin_name", None),
|
||||
@@ -88,6 +91,32 @@ class PluginCatalogFacade:
|
||||
plugins.sort(key=lambda item: getattr(item, "plugin_order", 0))
|
||||
return plugins
|
||||
|
||||
def installed(self) -> list[Plugin]:
|
||||
"""按安装清单投影插件,未加载项目仍返回可观察占位卡片。"""
|
||||
installed_ids = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
local_by_id = {
|
||||
plugin.id: plugin
|
||||
for plugin in self.local()
|
||||
if plugin.installed and plugin.id
|
||||
}
|
||||
result = []
|
||||
for plugin_id in installed_ids:
|
||||
plugin = local_by_id.get(plugin_id)
|
||||
if plugin:
|
||||
result.append(plugin)
|
||||
continue
|
||||
result.append(Plugin(
|
||||
id=plugin_id,
|
||||
plugin_name=plugin_id,
|
||||
installed=True,
|
||||
state=False,
|
||||
runtime_status=self._runtime_status(plugin_id),
|
||||
is_local=True,
|
||||
))
|
||||
# 展示顺序由持久化安装清单保留,避免后台恢复或占位卡片出现后改变用户看到的位置。
|
||||
# 前端可用用户级 PluginOrder 覆盖,plugin_order 只用于运行期插件发现顺序。
|
||||
return result
|
||||
|
||||
def local_version(self, plugin_id: str) -> Optional[str]:
|
||||
"""读取指定已安装插件版本,不触发全量目录投影。"""
|
||||
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
|
||||
@@ -2,11 +2,29 @@
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.runtime.extensions.plugin.system import PluginSystemServices
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginDependencyInstallResult:
|
||||
"""记录插件依赖检查结果,区分无缺失、安装成功和安装失败。"""
|
||||
|
||||
missing: list[str]
|
||||
success: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginDependencyClassification:
|
||||
"""按当前源码和 Python 环境划分已安装插件。"""
|
||||
|
||||
ready: tuple[str, ...]
|
||||
missing_dependencies: tuple[str, ...]
|
||||
missing_source: tuple[str, ...]
|
||||
|
||||
|
||||
class PluginDependencyService:
|
||||
"""执行缺失插件依赖的发现和安装,不参与插件生命周期。"""
|
||||
|
||||
@@ -20,12 +38,12 @@ class PluginDependencyService:
|
||||
self._system = system
|
||||
self._logger = log
|
||||
|
||||
def install_missing(self) -> list[str]:
|
||||
"""安装当前环境缺失的插件依赖并返回检查到的依赖名。"""
|
||||
def install_missing_with_status(self) -> PluginDependencyInstallResult:
|
||||
"""安装缺失依赖并返回安装器的明确结果。"""
|
||||
installer = self._system().dependency
|
||||
missing = installer.find_missing()
|
||||
if not missing:
|
||||
return missing
|
||||
return PluginDependencyInstallResult(missing=[], success=True)
|
||||
self._logger.debug(f"检测到缺失的依赖项: {missing}")
|
||||
self._logger.info(f"开始安装缺失的依赖项,共 {len(missing)} 个...")
|
||||
started = time.time()
|
||||
@@ -39,4 +57,19 @@ class PluginDependencyService:
|
||||
self._logger.warning(
|
||||
f"存在缺失依赖项安装失败,请尝试手动安装,总耗时:{elapsed:.2f} 秒"
|
||||
)
|
||||
return missing
|
||||
return PluginDependencyInstallResult(missing=missing, success=success)
|
||||
|
||||
def install_missing(self) -> list[str]:
|
||||
"""安装当前环境缺失的插件依赖并保持历史列表返回合同。"""
|
||||
return self.install_missing_with_status().missing
|
||||
|
||||
def classify_plugins(self) -> PluginDependencyClassification:
|
||||
"""返回启动编排使用的轻量插件分类。"""
|
||||
ready, missing_dependencies, missing_source = (
|
||||
self._system().dependency.classify_plugins()
|
||||
)
|
||||
return PluginDependencyClassification(
|
||||
ready=tuple(ready),
|
||||
missing_dependencies=tuple(missing_dependencies),
|
||||
missing_source=tuple(missing_source),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ import traceback
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.schemas.plugin import PluginRuntimeStatus
|
||||
|
||||
|
||||
class PluginLifecycle:
|
||||
"""管理插件发现、初始化、启停和热重载,不持有市场或 HTTP 路由职责。"""
|
||||
@@ -23,6 +25,7 @@ class PluginLifecycle:
|
||||
clear_tools: Callable[[], None],
|
||||
enable_events: Callable[[Any], None],
|
||||
disable_events: Callable[[Any], None],
|
||||
runtime_status_writer: Callable[[str, PluginRuntimeStatus], None],
|
||||
log: Any,
|
||||
event_sender: Callable[..., Any],
|
||||
) -> None:
|
||||
@@ -37,12 +40,19 @@ class PluginLifecycle:
|
||||
self._clear_tools = clear_tools
|
||||
self._enable_events = enable_events
|
||||
self._disable_events = disable_events
|
||||
self._runtime_status_writer = runtime_status_writer
|
||||
self._logger = log
|
||||
self._event_sender = event_sender
|
||||
|
||||
def start(self, plugin_id: Optional[str] = None) -> None:
|
||||
"""加载并初始化指定插件或全部已安装插件。"""
|
||||
def start(
|
||||
self,
|
||||
plugin_id: Optional[str] = None,
|
||||
) -> dict[str, PluginRuntimeStatus]:
|
||||
"""加载并初始化插件,返回每个目标的明确运行结果。"""
|
||||
installed_plugins = self._installed_plugins()
|
||||
results: dict[str, PluginRuntimeStatus] = {}
|
||||
if plugin_id:
|
||||
self._runtime_status_writer(plugin_id, PluginRuntimeStatus.READY)
|
||||
|
||||
def check_module(module: Any) -> bool:
|
||||
"""判断模块是否具备宿主插件最小生命周期钩子。"""
|
||||
@@ -58,6 +68,9 @@ class PluginLifecycle:
|
||||
if not self._auth_checker(plugin):
|
||||
if current_id in self._classes:
|
||||
self._classes[current_id] = plugin
|
||||
status = PluginRuntimeStatus.BLOCKED_BY_POLICY
|
||||
self._runtime_status_writer(current_id, status)
|
||||
results[current_id] = status
|
||||
continue
|
||||
self._classes[current_id] = plugin
|
||||
instance = plugin()
|
||||
@@ -70,11 +83,22 @@ class PluginLifecycle:
|
||||
self._enable_events(plugin)
|
||||
else:
|
||||
self._disable_events(plugin)
|
||||
status = PluginRuntimeStatus.ACTIVE
|
||||
self._runtime_status_writer(current_id, status)
|
||||
results[current_id] = status
|
||||
except Exception as error: # noqa: BLE001
|
||||
status = PluginRuntimeStatus.LOAD_FAILED
|
||||
self._runtime_status_writer(current_id, status)
|
||||
results[current_id] = status
|
||||
self._logger.error(
|
||||
f"加载插件 {current_id} 出错:{error} - {traceback.format_exc()}"
|
||||
)
|
||||
if plugin_id and plugin_id not in results:
|
||||
status = PluginRuntimeStatus.LOAD_FAILED
|
||||
self._runtime_status_writer(plugin_id, status)
|
||||
results[plugin_id] = status
|
||||
self._clear_tools()
|
||||
return results
|
||||
|
||||
def initialize(self, plugin_id: str, config: dict) -> None:
|
||||
"""重新应用指定插件配置并刷新事件注册状态。"""
|
||||
@@ -115,11 +139,17 @@ class PluginLifecycle:
|
||||
self._clear_tools()
|
||||
self._logger.info("插件停止完成")
|
||||
|
||||
def reload(self, plugin_id: str, reload_event: Any) -> None:
|
||||
"""重启指定插件并广播插件重载事件。"""
|
||||
def reload(
|
||||
self,
|
||||
plugin_id: str,
|
||||
reload_event: Any,
|
||||
) -> PluginRuntimeStatus:
|
||||
"""重启指定插件并返回本次加载结果。"""
|
||||
self._runtime_status_writer(plugin_id, PluginRuntimeStatus.READY)
|
||||
self.stop(plugin_id)
|
||||
self.start(plugin_id)
|
||||
status = self.start(plugin_id)[plugin_id]
|
||||
self._event_sender(reload_event, data={"plugin_id": plugin_id})
|
||||
return status
|
||||
|
||||
def _stop_plugin(self, plugin: Any) -> None:
|
||||
"""按插件旧 ABI 顺序关闭资源和服务。"""
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, Optional
|
||||
|
||||
FederatedChangeResolver = Callable[[Path], Optional[tuple[str, Optional[dict], bool]]]
|
||||
RuntimePluginResolver = Callable[[Path], Optional[str]]
|
||||
MonitorSuppression = Callable[[str], bool]
|
||||
LocalCandidateResolver = Callable[[Path], Optional[dict]]
|
||||
LocalPluginSync = Callable[[str, Optional[dict]], bool]
|
||||
PluginReloader = Callable[[str], Any]
|
||||
@@ -80,6 +81,7 @@ class PluginChangeMonitor:
|
||||
dependency_manifest_status: DependencyManifestStatus,
|
||||
watch: WatchFunction,
|
||||
log: Any,
|
||||
monitor_suppressed: Optional[MonitorSuppression] = None,
|
||||
) -> None:
|
||||
"""保存监控路径、变化解析器和副作用回调。"""
|
||||
self._runtime_root = runtime_root
|
||||
@@ -88,6 +90,7 @@ class PluginChangeMonitor:
|
||||
self._recent_sync = recent_sync
|
||||
self._federated_change = federated_change
|
||||
self._runtime_plugin = runtime_plugin
|
||||
self._monitor_suppressed = monitor_suppressed or (lambda _plugin_id: False)
|
||||
self._local_candidate = local_candidate
|
||||
self._sync_local = sync_local
|
||||
self._reload_plugin = reload_plugin
|
||||
@@ -150,6 +153,11 @@ class PluginChangeMonitor:
|
||||
if event_path.suffix != ".py":
|
||||
continue
|
||||
runtime_plugin_id = self._runtime_plugin(event_path)
|
||||
if runtime_plugin_id and self._monitor_suppressed(runtime_plugin_id):
|
||||
self._logger.debug(
|
||||
f"插件 {runtime_plugin_id} 正在写入,跳过本批文件监控重载"
|
||||
)
|
||||
continue
|
||||
candidate = (
|
||||
self._local_candidate(event_path)
|
||||
if not runtime_plugin_id
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.schemas.plugin import PluginRuntimeStatus
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
"""集中持有插件类和运行实例,并为读取方提供稳定快照。"""
|
||||
@@ -10,6 +12,9 @@ class PluginRegistry:
|
||||
"""创建彼此独立但生命周期一致的类表和实例表。"""
|
||||
self._classes: Dict[str, Any] = {}
|
||||
self._running: Dict[str, Any] = {}
|
||||
self._runtime_statuses: Dict[str, PluginRuntimeStatus] = {}
|
||||
self._settling = False
|
||||
self._generation = 0
|
||||
|
||||
@property
|
||||
def classes(self) -> Dict[str, Any]:
|
||||
@@ -45,12 +50,53 @@ class PluginRegistry:
|
||||
"""复制运行实例表,避免插件重载期间迭代失效。"""
|
||||
return dict(self._running)
|
||||
|
||||
def set_runtime_status(
|
||||
self,
|
||||
plugin_id: str,
|
||||
status: PluginRuntimeStatus,
|
||||
) -> None:
|
||||
"""记录插件当前状态,并在实际变化时推进前端刷新代次。"""
|
||||
if self._runtime_statuses.get(plugin_id) == status:
|
||||
return
|
||||
self._runtime_statuses[plugin_id] = status
|
||||
self._generation += 1
|
||||
|
||||
def runtime_status(self, plugin_id: str) -> Optional[PluginRuntimeStatus]:
|
||||
"""读取指定插件状态。"""
|
||||
return self._runtime_statuses.get(plugin_id)
|
||||
|
||||
def runtime_status_snapshot(self) -> Dict[str, PluginRuntimeStatus]:
|
||||
"""复制插件状态表,避免后台加载期间迭代失效。"""
|
||||
return dict(self._runtime_statuses)
|
||||
|
||||
def set_settling(self, settling: bool) -> None:
|
||||
"""标记启动后的插件源码与依赖收敛任务是否仍在执行。"""
|
||||
if self._settling == settling:
|
||||
return
|
||||
self._settling = settling
|
||||
self._generation += 1
|
||||
|
||||
@property
|
||||
def settling(self) -> bool:
|
||||
"""返回插件后台收敛任务是否仍在执行。"""
|
||||
return self._settling
|
||||
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
"""返回状态变化代次,供读取方识别刷新边界。"""
|
||||
return self._generation
|
||||
|
||||
def remove(self, plugin_id: str) -> None:
|
||||
"""同时移除指定插件类和运行实例。"""
|
||||
"""同时移除指定插件类、运行实例和状态。"""
|
||||
self._classes.pop(plugin_id, None)
|
||||
self._running.pop(plugin_id, None)
|
||||
if self._runtime_statuses.pop(plugin_id, None) is not None:
|
||||
self._generation += 1
|
||||
|
||||
def clear(self) -> None:
|
||||
"""原地清空注册表,保持外部持有的兼容字典引用有效。"""
|
||||
self._classes.clear()
|
||||
self._running.clear()
|
||||
if self._runtime_statuses:
|
||||
self._runtime_statuses.clear()
|
||||
self._generation += 1
|
||||
|
||||
Reference in New Issue
Block a user