mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat(plugin): add virtual plugin instances
This commit is contained in:
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user