mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
feat(plugin): add virtual plugin instances
This commit is contained in:
@@ -45,9 +45,10 @@ class PluginAccessPolicy:
|
||||
and hasattr(plugin, "plugin_public_key")
|
||||
):
|
||||
plugin_id = (
|
||||
getattr(plugin, "id", None)
|
||||
getattr(plugin, "plugin_source_id", None)
|
||||
or getattr(plugin, "id", None)
|
||||
if not isinstance(plugin, type)
|
||||
else plugin.__name__
|
||||
else getattr(plugin, "plugin_source_id", None) or plugin.__name__
|
||||
)
|
||||
public_key = plugin.plugin_public_key
|
||||
if public_key and plugin_id:
|
||||
|
||||
@@ -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, PluginRuntimeStatus
|
||||
from app.schemas.plugin import Plugin, PluginInstance, PluginRuntimeStatus
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ class PluginCatalogFacade:
|
||||
map_plugin: Callable[..., Optional[Plugin]],
|
||||
auth_checker: Callable[..., bool],
|
||||
plugin_attr: Callable[[str, str], Any],
|
||||
plugin_instance: Callable[[str], Optional[PluginInstance]],
|
||||
plugin_instances: Callable[[], dict[str, PluginInstance]],
|
||||
runtime_status: Callable[[str], Optional[PluginRuntimeStatus]],
|
||||
log: Any,
|
||||
) -> None:
|
||||
@@ -45,6 +47,8 @@ class PluginCatalogFacade:
|
||||
self._map_plugin = map_plugin
|
||||
self._auth_checker = auth_checker
|
||||
self._plugin_attr = plugin_attr
|
||||
self._plugin_instance = plugin_instance
|
||||
self._plugin_instances = plugin_instances
|
||||
self._runtime_status = runtime_status
|
||||
self._logger = log
|
||||
|
||||
@@ -64,10 +68,11 @@ class PluginCatalogFacade:
|
||||
|
||||
def local(self) -> list[Plugin]:
|
||||
"""把已加载插件投影为本地插件目录 DTO。"""
|
||||
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
installed = self._installed_ids()
|
||||
plugins: list[Plugin] = []
|
||||
for plugin_id, plugin_class in self._classes().items():
|
||||
plugin_instance = self._running().get(plugin_id)
|
||||
instance = self._plugin_instance(plugin_id)
|
||||
plugin = Plugin(
|
||||
id=plugin_id,
|
||||
installed=plugin_id in installed,
|
||||
@@ -84,6 +89,9 @@ class PluginCatalogFacade:
|
||||
plugin_order=getattr(plugin_class, "plugin_order", 0),
|
||||
has_update=False,
|
||||
is_local=True,
|
||||
source_plugin_id=getattr(plugin_class, "plugin_source_id", None),
|
||||
is_instance=instance is not None,
|
||||
instance_mode=instance.mode if instance else None,
|
||||
)
|
||||
if not self._auth_checker(plugin=plugin, source=plugin_class):
|
||||
continue
|
||||
@@ -93,7 +101,7 @@ class PluginCatalogFacade:
|
||||
|
||||
def installed(self) -> list[Plugin]:
|
||||
"""按安装清单投影插件,未加载项目仍返回可观察占位卡片。"""
|
||||
installed_ids = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
installed_ids = self._installed_ids()
|
||||
local_by_id = {
|
||||
plugin.id: plugin
|
||||
for plugin in self.local()
|
||||
@@ -105,6 +113,7 @@ class PluginCatalogFacade:
|
||||
if plugin:
|
||||
result.append(plugin)
|
||||
continue
|
||||
instance = self._plugin_instance(plugin_id)
|
||||
result.append(Plugin(
|
||||
id=plugin_id,
|
||||
plugin_name=plugin_id,
|
||||
@@ -112,6 +121,11 @@ class PluginCatalogFacade:
|
||||
state=False,
|
||||
runtime_status=self._runtime_status(plugin_id),
|
||||
is_local=True,
|
||||
source_plugin_id=(
|
||||
instance.source_plugin_id if instance else None
|
||||
),
|
||||
is_instance=instance is not None,
|
||||
instance_mode=instance.mode if instance else None,
|
||||
))
|
||||
# 展示顺序由持久化安装清单保留,避免后台恢复或占位卡片出现后改变用户看到的位置。
|
||||
# 前端可用用户级 PluginOrder 覆盖,plugin_order 只用于运行期插件发现顺序。
|
||||
@@ -119,7 +133,7 @@ class PluginCatalogFacade:
|
||||
|
||||
def local_version(self, plugin_id: str) -> Optional[str]:
|
||||
"""读取指定已安装插件版本,不触发全量目录投影。"""
|
||||
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
installed = self._installed_ids()
|
||||
if plugin_id not in installed:
|
||||
return None
|
||||
plugin_class = self._classes().get(plugin_id)
|
||||
@@ -156,11 +170,20 @@ class PluginCatalogFacade:
|
||||
if not plugin_id:
|
||||
return False
|
||||
try:
|
||||
package_name = f"app.plugins.{plugin_id.lower()}"
|
||||
instance = self._plugin_instance(plugin_id)
|
||||
source_plugin_id = (
|
||||
instance.source_plugin_id if instance else plugin_id
|
||||
)
|
||||
package_name = f"app.plugins.{source_plugin_id.lower()}"
|
||||
spec = importlib.util.find_spec(package_name)
|
||||
if spec is None or spec.origin is None:
|
||||
return False
|
||||
local_version = self._plugin_attr(plugin_id, "plugin_version")
|
||||
if not local_version and instance:
|
||||
local_version = self._plugin_attr(
|
||||
instance.source_plugin_id,
|
||||
"plugin_version",
|
||||
)
|
||||
if not local_version:
|
||||
return False
|
||||
if version and not compare_version(local_version, ">=", version):
|
||||
@@ -174,6 +197,16 @@ class PluginCatalogFacade:
|
||||
self._logger.debug(f"获取插件是否在本地包中存在失败,{error}")
|
||||
return False
|
||||
|
||||
def _installed_ids(self) -> list[str]:
|
||||
"""合并物理安装清单和虚拟实例清单并保持各自持久化顺序。"""
|
||||
installed = list(
|
||||
self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
)
|
||||
for instance_id in self._plugin_instances():
|
||||
if instance_id not in installed:
|
||||
installed.append(instance_id)
|
||||
return installed
|
||||
|
||||
def get_from_market(
|
||||
self,
|
||||
market: str,
|
||||
|
||||
@@ -5,36 +5,38 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
|
||||
|
||||
|
||||
class PluginCloneService:
|
||||
"""协调插件包复制、安装清单、配置复制和运行态刷新。"""
|
||||
"""创建共享源码的虚拟插件实例并协调失败回滚。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
plugin_class: Callable[[str], Optional[Any]],
|
||||
plugin_exists: Callable[[str], bool],
|
||||
package_clone: Callable[..., tuple[bool, str]],
|
||||
installed_plugins: Callable[[], list[str]],
|
||||
save_installed_plugins: Callable[[list[str]], Any],
|
||||
source_plugin_id: Callable[[str], str],
|
||||
save_instance: Callable[[PluginInstance], Any],
|
||||
delete_instance: Callable[[str], bool],
|
||||
read_config: Callable[[str], dict],
|
||||
save_config: Callable[[str, dict], bool],
|
||||
delete_config: Callable[[str], bool],
|
||||
reload_plugin: Callable[[str], Any],
|
||||
running_plugin: Callable[[str], Optional[Any]],
|
||||
initialize_plugin: Callable[[str, dict], Any],
|
||||
remove_plugin: Callable[[str], Any],
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存包、持久化和运行态端口。"""
|
||||
"""保存实例描述、持久化和运行态端口。"""
|
||||
self._plugin_class = plugin_class
|
||||
self._plugin_exists = plugin_exists
|
||||
self._package_clone = package_clone
|
||||
self._installed_plugins = installed_plugins
|
||||
self._save_installed_plugins = save_installed_plugins
|
||||
self._source_plugin_id = source_plugin_id
|
||||
self._save_instance = save_instance
|
||||
self._delete_instance = delete_instance
|
||||
self._read_config = read_config
|
||||
self._save_config = save_config
|
||||
self._delete_config = delete_config
|
||||
self._reload_plugin = reload_plugin
|
||||
self._running_plugin = running_plugin
|
||||
self._initialize_plugin = initialize_plugin
|
||||
self._remove_plugin = remove_plugin
|
||||
self._logger = log
|
||||
|
||||
def clone(
|
||||
@@ -47,11 +49,10 @@ class PluginCloneService:
|
||||
version: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""创建插件分身并保持原有默认禁用配置语义。"""
|
||||
"""创建虚拟分身,复制隔离配置并保持默认禁用语义。"""
|
||||
if not plugin_id or not suffix:
|
||||
return False, "插件ID和分身后缀不能为空"
|
||||
original_class = self._plugin_class(plugin_id)
|
||||
if original_class is None:
|
||||
if self._plugin_class(plugin_id) is None:
|
||||
return False, f"原插件 {plugin_id} 不存在"
|
||||
|
||||
clone_id = f"{plugin_id}{suffix.lower()}"
|
||||
@@ -59,38 +60,44 @@ class PluginCloneService:
|
||||
return False, f"分身插件 {clone_id} 已存在"
|
||||
|
||||
try:
|
||||
success, message = self._package_clone(
|
||||
plugin_id=plugin_id,
|
||||
clone_id=clone_id,
|
||||
original_class_name=original_class.__name__,
|
||||
suffix=suffix.lower(),
|
||||
name=name,
|
||||
description=description,
|
||||
version=version,
|
||||
icon=icon,
|
||||
instance = PluginInstance(
|
||||
instance_id=clone_id,
|
||||
source_plugin_id=self._source_plugin_id(plugin_id),
|
||||
plugin_name=name or None,
|
||||
plugin_desc=description or None,
|
||||
plugin_icon=icon or None,
|
||||
)
|
||||
if not success:
|
||||
return False, message
|
||||
|
||||
installed = list(self._installed_plugins())
|
||||
if clone_id not in installed:
|
||||
installed.append(clone_id)
|
||||
self._save_installed_plugins(installed)
|
||||
self._save_instance(instance)
|
||||
|
||||
original_config = self._read_config(plugin_id)
|
||||
if original_config:
|
||||
clone_config = dict(original_config)
|
||||
clone_config["enable"] = False
|
||||
clone_config["enabled"] = False
|
||||
self._save_config(clone_id, clone_config)
|
||||
if not self._save_config(clone_id, clone_config):
|
||||
raise RuntimeError("虚拟实例配置保存失败")
|
||||
|
||||
self._reload_plugin(clone_id)
|
||||
clone_instance = self._running_plugin(clone_id)
|
||||
clone_config = self._read_config(clone_id)
|
||||
if clone_instance and clone_config:
|
||||
self._initialize_plugin(clone_id, clone_config)
|
||||
status = self._reload_plugin(clone_id)
|
||||
if status is PluginRuntimeStatus.LOAD_FAILED:
|
||||
raise RuntimeError("虚拟实例加载失败")
|
||||
self._logger.info(f"插件分身 {clone_id} 创建成功")
|
||||
return True, clone_id
|
||||
except Exception as error: # noqa: BLE001
|
||||
self._rollback(clone_id)
|
||||
self._logger.error(f"创建插件分身失败:{error}")
|
||||
return False, f"创建插件分身失败:{error}"
|
||||
|
||||
def _rollback(self, clone_id: str) -> None:
|
||||
"""逐项清理失败实例,单个清理错误不得阻断其余回滚。"""
|
||||
rollback_steps = (
|
||||
("运行态", self._remove_plugin),
|
||||
("实例描述", self._delete_instance),
|
||||
("配置", self._delete_config),
|
||||
)
|
||||
for label, rollback in rollback_steps:
|
||||
try:
|
||||
rollback(clone_id)
|
||||
except Exception as rollback_error: # noqa: BLE001
|
||||
self._logger.warning(
|
||||
f"回滚插件分身 {clone_id} 的{label}失败:{rollback_error}"
|
||||
)
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.schemas.plugin import PluginInstance
|
||||
|
||||
|
||||
PluginImportPreparer = Callable[..., None]
|
||||
PluginImportScanner = Callable[..., None]
|
||||
@@ -18,6 +22,8 @@ PluginValidator = Callable[[Any], bool]
|
||||
class PluginLoader:
|
||||
"""只负责从运行目录发现插件类,并维护对应模块缓存。"""
|
||||
|
||||
_instance_import_lock = threading.RLock()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -96,6 +102,165 @@ class PluginLoader:
|
||||
)
|
||||
return plugins
|
||||
|
||||
def load_instance(
|
||||
self,
|
||||
instance: PluginInstance,
|
||||
validator: PluginValidator,
|
||||
) -> list[Any]:
|
||||
"""在实例专属模块命名空间中重新执行源插件代码并返回适配类。"""
|
||||
source_dir = self._plugins_root / instance.source_plugin_id.lower()
|
||||
source_file = source_dir / "__init__.py"
|
||||
if not source_file.exists():
|
||||
self._logger.warning(
|
||||
f"虚拟插件实例 {instance.instance_id} 的源码不存在:{source_dir}"
|
||||
)
|
||||
return []
|
||||
|
||||
module_name = f"app.plugins.{instance.instance_id.lower()}"
|
||||
self.clear_modules(instance.instance_id)
|
||||
try:
|
||||
self._import_preparer(
|
||||
plugin_id=instance.source_plugin_id.lower(),
|
||||
plugin_dir=source_dir,
|
||||
)
|
||||
self._import_scanner(
|
||||
plugin_id=instance.source_plugin_id.lower(),
|
||||
plugin_dir=source_dir,
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
source_file,
|
||||
submodule_search_locations=[str(source_dir)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"无法创建模块规格:{module_name}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
self._execute_instance_module(
|
||||
module=module,
|
||||
module_name=module_name,
|
||||
source_module_name=(
|
||||
f"app.plugins.{instance.source_plugin_id.lower()}"
|
||||
),
|
||||
loader=spec.loader,
|
||||
)
|
||||
for name, candidate in module.__dict__.items():
|
||||
if name.startswith("_") or not isinstance(candidate, type):
|
||||
continue
|
||||
if not validator(candidate):
|
||||
continue
|
||||
self._adapt_instance_class(candidate, instance)
|
||||
self._logger.debug(
|
||||
f"从 {instance.source_plugin_id} 加载虚拟插件实例:{instance.instance_id}"
|
||||
)
|
||||
return [candidate]
|
||||
except Exception as error: # noqa: BLE001
|
||||
self.clear_modules(instance.instance_id)
|
||||
self._logger.error(
|
||||
f"加载虚拟插件实例 {instance.instance_id} 失败:{error} - "
|
||||
f"{traceback.format_exc()}"
|
||||
)
|
||||
return []
|
||||
|
||||
def _execute_instance_module(
|
||||
self,
|
||||
*,
|
||||
module: Any,
|
||||
module_name: str,
|
||||
source_module_name: str,
|
||||
loader: Any,
|
||||
) -> None:
|
||||
"""执行实例模块,并把旧式自身绝对导入迁移到实例命名空间。"""
|
||||
source_prefix = f"{source_module_name}."
|
||||
parent_module = sys.modules.get("app.plugins")
|
||||
source_attribute = source_module_name.rsplit(".", 1)[-1]
|
||||
missing = object()
|
||||
with self._instance_import_lock:
|
||||
source_snapshot = {
|
||||
name: loaded_module
|
||||
for name, loaded_module in list(sys.modules.items())
|
||||
if name == source_module_name or name.startswith(source_prefix)
|
||||
}
|
||||
parent_snapshot = (
|
||||
getattr(parent_module, source_attribute, missing)
|
||||
if parent_module
|
||||
else missing
|
||||
)
|
||||
for name in source_snapshot:
|
||||
sys.modules.pop(name, None)
|
||||
sys.modules[module_name] = module
|
||||
# 兼容旧插件在包内仍写 app.plugins.<source> 的绝对导入。
|
||||
sys.modules[source_module_name] = module
|
||||
if parent_module:
|
||||
setattr(parent_module, source_attribute, module)
|
||||
captured: dict[str, Any] = {}
|
||||
try:
|
||||
loader.exec_module(module)
|
||||
captured = {
|
||||
name: loaded_module
|
||||
for name, loaded_module in list(sys.modules.items())
|
||||
if name == source_module_name or name.startswith(source_prefix)
|
||||
}
|
||||
finally:
|
||||
for name in list(sys.modules):
|
||||
if name == source_module_name or name.startswith(source_prefix):
|
||||
sys.modules.pop(name, None)
|
||||
sys.modules.update(source_snapshot)
|
||||
if parent_module:
|
||||
if parent_snapshot is missing:
|
||||
try:
|
||||
delattr(parent_module, source_attribute)
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
setattr(parent_module, source_attribute, parent_snapshot)
|
||||
|
||||
for source_name, loaded_module in captured.items():
|
||||
suffix = source_name[len(source_module_name):]
|
||||
instance_name = f"{module_name}{suffix}"
|
||||
sys.modules[instance_name] = loaded_module
|
||||
self._retarget_module_identity(
|
||||
loaded_module,
|
||||
source_name,
|
||||
instance_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _retarget_module_identity(
|
||||
module: Any,
|
||||
source_name: str,
|
||||
instance_name: str,
|
||||
) -> None:
|
||||
"""修正被旧绝对路径加载对象的模块身份,避免事件与诊断键冲突。"""
|
||||
if getattr(module, "__name__", None) == source_name:
|
||||
module.__name__ = instance_name
|
||||
package_name = getattr(module, "__package__", None)
|
||||
if isinstance(package_name, str) and package_name.startswith(source_name):
|
||||
module.__package__ = instance_name + package_name[len(source_name):]
|
||||
spec = getattr(module, "__spec__", None)
|
||||
if spec and getattr(spec, "name", None) == source_name:
|
||||
spec.name = instance_name
|
||||
for value in vars(module).values():
|
||||
if getattr(value, "__module__", None) == source_name:
|
||||
try:
|
||||
value.__module__ = instance_name
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def _adapt_instance_class(candidate: Any, instance: PluginInstance) -> None:
|
||||
"""只改运行身份与展示元数据,不改源码、限定名和联邦产物。"""
|
||||
candidate.__name__ = instance.instance_id
|
||||
candidate.plugin_instance_id = instance.instance_id
|
||||
candidate.plugin_source_id = instance.source_plugin_id
|
||||
candidate.is_clone = True
|
||||
candidate.plugin_config_prefix = f"{instance.instance_id.lower()}_"
|
||||
if instance.plugin_name:
|
||||
candidate.plugin_name = instance.plugin_name
|
||||
if instance.plugin_desc:
|
||||
candidate.plugin_desc = instance.plugin_desc
|
||||
if instance.plugin_icon:
|
||||
candidate.plugin_icon = instance.plugin_icon
|
||||
|
||||
def clear_modules(self, plugin_id: Optional[str] = None) -> list[str]:
|
||||
"""清除指定插件或全部插件的 Python 模块缓存。"""
|
||||
prefix = (
|
||||
|
||||
@@ -150,11 +150,15 @@ class PluginProjection:
|
||||
continue
|
||||
if not self._remote_entry_factory:
|
||||
raise RuntimeError("插件联邦入口生成器尚未配置")
|
||||
remotes.append({
|
||||
remote = {
|
||||
"id": plugin_id,
|
||||
"url": self._remote_entry_factory(plugin_id, dist_path),
|
||||
"name": plugin.plugin_name,
|
||||
})
|
||||
}
|
||||
source_plugin_id = getattr(plugin, "plugin_source_id", None)
|
||||
if source_plugin_id:
|
||||
remote["source_plugin_id"] = source_plugin_id
|
||||
remotes.append(remote)
|
||||
return remotes
|
||||
|
||||
def auth_providers(self) -> List[Dict[str, Any]]:
|
||||
@@ -189,11 +193,15 @@ class PluginProjection:
|
||||
if not self._remote_entry_factory:
|
||||
raise RuntimeError("插件联邦入口生成器尚未配置")
|
||||
provider.setdefault("component", "AuthPage")
|
||||
provider["remote"] = {
|
||||
remote = {
|
||||
"id": plugin_id,
|
||||
"url": self._remote_entry_factory(plugin_id, dist_path),
|
||||
"name": plugin.plugin_name,
|
||||
}
|
||||
source_plugin_id = getattr(plugin, "plugin_source_id", None)
|
||||
if source_plugin_id:
|
||||
remote["source_plugin_id"] = source_plugin_id
|
||||
provider["remote"] = remote
|
||||
providers.append(provider)
|
||||
return providers
|
||||
|
||||
@@ -335,4 +343,9 @@ class PluginProjection:
|
||||
cols=cols or {},
|
||||
attrs=attrs or {},
|
||||
elements=elements,
|
||||
source_plugin_id=getattr(plugin, "plugin_source_id", None),
|
||||
is_instance=bool(getattr(plugin, "plugin_source_id", None)),
|
||||
instance_mode=(
|
||||
"virtual" if getattr(plugin, "plugin_source_id", None) else None
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,11 @@ from __future__ import annotations
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.plugin import PluginInstance
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
ConfigReader = Callable[[Any], Any]
|
||||
ConfigWriter = Callable[[Any, Any], Any]
|
||||
@@ -139,6 +144,74 @@ class PluginConfigStore:
|
||||
return True
|
||||
|
||||
|
||||
class PluginInstanceStore:
|
||||
"""管理虚拟插件实例描述,并隔离兼容清单与新实例清单。"""
|
||||
|
||||
def __init__(self, *, storage: Callable[[], "PluginStorage"]) -> None:
|
||||
"""保存延迟解析的持久化端口,便于启动组合根后装配。"""
|
||||
self._storage = storage
|
||||
|
||||
def all(self) -> dict[str, PluginInstance]:
|
||||
"""读取全部有效实例,忽略损坏项以免阻断存量插件启动。"""
|
||||
raw_instances = self._storage().read(SystemConfigKey.PluginInstances) or {}
|
||||
if isinstance(raw_instances, list):
|
||||
entries = {
|
||||
item.get("instance_id"): item
|
||||
for item in raw_instances
|
||||
if isinstance(item, dict) and item.get("instance_id")
|
||||
}
|
||||
elif isinstance(raw_instances, dict):
|
||||
entries = raw_instances
|
||||
else:
|
||||
return {}
|
||||
|
||||
instances: dict[str, PluginInstance] = {}
|
||||
for instance_id, raw_instance in entries.items():
|
||||
try:
|
||||
payload = dict(raw_instance) if isinstance(raw_instance, dict) else {}
|
||||
payload.setdefault("instance_id", instance_id)
|
||||
instance = PluginInstance.model_validate(payload)
|
||||
instances[instance.instance_id] = instance
|
||||
except (TypeError, ValidationError):
|
||||
continue
|
||||
return instances
|
||||
|
||||
def get(self, instance_id: str) -> PluginInstance | None:
|
||||
"""读取指定实例描述。"""
|
||||
return self.all().get(instance_id)
|
||||
|
||||
def save(self, instance: PluginInstance) -> None:
|
||||
"""新增或更新实例描述,并以实例 ID 作为稳定持久化键。"""
|
||||
instances = self.all()
|
||||
instances[instance.instance_id] = instance
|
||||
self._write(instances)
|
||||
|
||||
def delete(self, instance_id: str) -> bool:
|
||||
"""删除指定实例描述,返回删除前是否存在。"""
|
||||
instances = self.all()
|
||||
removed = instances.pop(instance_id, None)
|
||||
if removed is None:
|
||||
return False
|
||||
self._write(instances)
|
||||
return True
|
||||
|
||||
def for_source(self, source_plugin_id: str) -> list[PluginInstance]:
|
||||
"""按持久化顺序返回引用同一源码插件的全部实例。"""
|
||||
return [
|
||||
instance
|
||||
for instance in self.all().values()
|
||||
if instance.source_plugin_id == source_plugin_id
|
||||
]
|
||||
|
||||
def _write(self, instances: dict[str, PluginInstance]) -> None:
|
||||
"""把模型映射序列化为普通字典,避免存储层依赖 Pydantic。"""
|
||||
payload = {
|
||||
instance_id: instance.model_dump(mode="json")
|
||||
for instance_id, instance in instances.items()
|
||||
}
|
||||
self._storage().write(SystemConfigKey.PluginInstances, payload)
|
||||
|
||||
|
||||
_plugin_storage = PluginStorage()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user