mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
- Replaced the plugin listing mechanism with a more efficient handler iteration for event actions. - Enhanced logging to provide clearer insights into the dispatching process and plugin action execution. - Added error handling to ensure that failures in one plugin action do not block subsequent actions. - Updated tests to cover new behavior and ensure proper handling of enabled and disabled plugins.
853 lines
34 KiB
Python
853 lines
34 KiB
Python
import importlib
|
|
import importlib.util
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
import threading
|
|
import types
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
|
|
|
from django.db import close_old_connections, transaction
|
|
|
|
from .models import PluginConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class LoadedPlugin:
|
|
key: str
|
|
name: str
|
|
version: str = ""
|
|
description: str = ""
|
|
author: str = ""
|
|
help_url: str = ""
|
|
module: Any = None
|
|
instance: Any = None
|
|
fields: List[Dict[str, Any]] = field(default_factory=list)
|
|
actions: List[Dict[str, Any]] = field(default_factory=list)
|
|
trusted: bool = False
|
|
loaded: bool = False
|
|
path: Optional[str] = None
|
|
folder_name: Optional[str] = None
|
|
legacy: bool = False
|
|
|
|
|
|
class PluginManager:
|
|
"""Singleton manager that discovers and runs plugins from /data/plugins."""
|
|
|
|
_instance: Optional["PluginManager"] = None
|
|
|
|
@classmethod
|
|
def get(cls) -> "PluginManager":
|
|
if not cls._instance:
|
|
cls._instance = PluginManager()
|
|
return cls._instance
|
|
|
|
def __init__(self) -> None:
|
|
self.plugins_dir = os.environ.get("DISPATCHARR_PLUGINS_DIR", "/data/plugins")
|
|
self._registry: Dict[str, LoadedPlugin] = {}
|
|
self._package_names: Dict[str, str] = {}
|
|
self._alias_names: Dict[str, str] = {}
|
|
self._reload_token_path = os.path.join(self.plugins_dir, ".reload_token")
|
|
self._last_reload_token = 0.0
|
|
self._discovery_completed = False
|
|
self._lock = threading.RLock()
|
|
|
|
# Ensure plugins directory exists
|
|
os.makedirs(self.plugins_dir, exist_ok=True)
|
|
if self.plugins_dir not in sys.path:
|
|
sys.path.append(self.plugins_dir)
|
|
|
|
def discover_plugins(
|
|
self,
|
|
*,
|
|
sync_db: bool = True,
|
|
force_reload: bool = False,
|
|
use_cache: bool = False,
|
|
) -> Dict[str, LoadedPlugin]:
|
|
token = self._get_reload_token()
|
|
if use_cache and not force_reload:
|
|
with self._lock:
|
|
if self._discovery_completed and token <= self._last_reload_token:
|
|
return self._registry
|
|
if token > self._last_reload_token:
|
|
force_reload = True
|
|
if force_reload:
|
|
self._touch_reload_token()
|
|
token = self._get_reload_token()
|
|
|
|
if sync_db:
|
|
logger.info(f"Discovering plugins in {self.plugins_dir}")
|
|
else:
|
|
logger.debug(f"Discovering plugins (no DB sync) in {self.plugins_dir}")
|
|
|
|
with self._lock:
|
|
previous_packages = dict(self._package_names)
|
|
previous_aliases = dict(self._alias_names)
|
|
previous_paths = {
|
|
key: lp.path for key, lp in self._registry.items() if lp and lp.path
|
|
}
|
|
|
|
try:
|
|
return self._discover_plugins_impl(
|
|
sync_db=sync_db,
|
|
force_reload=force_reload,
|
|
previous_packages=previous_packages,
|
|
previous_aliases=previous_aliases,
|
|
previous_paths=previous_paths,
|
|
token=token,
|
|
)
|
|
finally:
|
|
# Discovery runs outside Django's request/task cycle (boot, worker_ready).
|
|
close_old_connections()
|
|
|
|
def _discover_plugins_impl(
|
|
self,
|
|
*,
|
|
sync_db: bool,
|
|
force_reload: bool,
|
|
previous_packages: Dict[str, str],
|
|
previous_aliases: Dict[str, str],
|
|
previous_paths: Dict[str, str],
|
|
token: int,
|
|
) -> Dict[str, LoadedPlugin]:
|
|
try:
|
|
configs: Optional[Dict[str, PluginConfig]] = None
|
|
try:
|
|
configs = {c.key: c for c in PluginConfig.objects.all()}
|
|
except Exception:
|
|
# DB might not be ready; treat all plugins as untrusted
|
|
configs = None
|
|
|
|
new_registry: Dict[str, LoadedPlugin] = {}
|
|
new_packages: Dict[str, str] = {}
|
|
new_aliases: Dict[str, str] = {}
|
|
for entry in sorted(os.listdir(self.plugins_dir)):
|
|
path = os.path.join(self.plugins_dir, entry)
|
|
if not os.path.isdir(path):
|
|
continue
|
|
|
|
has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
|
|
has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
|
|
if not (has_pkg or has_pluginpy):
|
|
continue
|
|
|
|
plugin_key = entry.replace(" ", "_").lower()
|
|
alias_name = self._resolve_alias_name(entry, path)
|
|
|
|
if force_reload:
|
|
prev_alias = previous_aliases.get(plugin_key)
|
|
if prev_alias:
|
|
self._unload_alias(prev_alias)
|
|
prev_path = previous_paths.get(plugin_key)
|
|
if prev_path:
|
|
self._unload_path_modules(prev_path)
|
|
|
|
cfg = configs.get(plugin_key) if configs else None
|
|
enabled = bool(cfg and cfg.enabled)
|
|
trusted = bool(cfg and (cfg.ever_enabled or cfg.enabled))
|
|
|
|
manifest, has_manifest = self._read_manifest(path)
|
|
legacy = not has_manifest
|
|
manifest_name = None
|
|
manifest_version = None
|
|
manifest_description = None
|
|
manifest_author = None
|
|
manifest_help_url = None
|
|
manifest_fields: List[Dict[str, Any]] = []
|
|
manifest_actions: List[Dict[str, Any]] = []
|
|
if has_manifest and isinstance(manifest, dict):
|
|
manifest_name = manifest.get("name") if isinstance(manifest.get("name"), str) else None
|
|
manifest_version = manifest.get("version") if isinstance(manifest.get("version"), str) else None
|
|
manifest_description = manifest.get("description") if isinstance(manifest.get("description"), str) else None
|
|
manifest_author = manifest.get("author") if isinstance(manifest.get("author"), str) else None
|
|
manifest_help_url = manifest.get("help_url") if isinstance(manifest.get("help_url"), str) else None
|
|
manifest_fields = self._normalize_fields(manifest.get("fields", []))
|
|
manifest_actions = self._normalize_actions(manifest.get("actions", []))
|
|
|
|
display_name = manifest_name or entry
|
|
display_version = (
|
|
manifest_version if manifest_version is not None else (cfg.version if cfg else "")
|
|
)
|
|
display_description = (
|
|
manifest_description if manifest_description is not None else (cfg.description if cfg else "")
|
|
)
|
|
|
|
def _make_placeholder() -> LoadedPlugin:
|
|
return LoadedPlugin(
|
|
key=plugin_key,
|
|
name=display_name,
|
|
version=display_version,
|
|
description=display_description,
|
|
author=manifest_author or "",
|
|
help_url=manifest_help_url or "",
|
|
fields=manifest_fields if has_manifest else [],
|
|
actions=manifest_actions if has_manifest else [],
|
|
trusted=trusted,
|
|
loaded=False,
|
|
path=path,
|
|
folder_name=entry,
|
|
legacy=legacy,
|
|
)
|
|
|
|
if not enabled:
|
|
new_registry[plugin_key] = _make_placeholder()
|
|
continue
|
|
|
|
try:
|
|
lp, package_name = self._load_plugin(
|
|
plugin_key,
|
|
path,
|
|
folder_name=entry,
|
|
force_reload=force_reload,
|
|
previous_package=previous_packages.get(plugin_key),
|
|
)
|
|
if lp:
|
|
if manifest_name and (not lp.name or lp.name == plugin_key):
|
|
lp.name = manifest_name
|
|
if manifest_version is not None and not lp.version:
|
|
lp.version = manifest_version
|
|
if manifest_description is not None and not lp.description:
|
|
lp.description = manifest_description
|
|
if manifest_author is not None and not lp.author:
|
|
lp.author = manifest_author
|
|
if manifest_help_url is not None and not lp.help_url:
|
|
lp.help_url = manifest_help_url
|
|
if manifest_fields and not lp.fields:
|
|
lp.fields = manifest_fields
|
|
if manifest_actions and not lp.actions:
|
|
lp.actions = manifest_actions
|
|
lp.trusted = trusted
|
|
lp.loaded = True
|
|
lp.path = path
|
|
lp.folder_name = entry
|
|
lp.legacy = legacy
|
|
new_registry[plugin_key] = lp
|
|
if package_name:
|
|
new_packages[plugin_key] = package_name
|
|
if alias_name:
|
|
new_aliases[plugin_key] = alias_name
|
|
else:
|
|
new_registry[plugin_key] = _make_placeholder()
|
|
except Exception:
|
|
logger.exception(f"Failed to load plugin '{plugin_key}' from {path}")
|
|
new_registry[plugin_key] = _make_placeholder()
|
|
|
|
if force_reload:
|
|
# Remove stale modules for plugins that no longer exist
|
|
removed_keys = set(previous_packages.keys()) - set(new_packages.keys())
|
|
for key in removed_keys:
|
|
self._unload_package(previous_packages[key])
|
|
prev_alias = previous_aliases.get(key)
|
|
if prev_alias:
|
|
self._unload_alias(prev_alias)
|
|
prev_path = previous_paths.get(key)
|
|
if prev_path:
|
|
self._unload_path_modules(prev_path)
|
|
|
|
with self._lock:
|
|
self._registry = new_registry
|
|
self._package_names = new_packages
|
|
self._alias_names = new_aliases
|
|
if token > self._last_reload_token:
|
|
self._last_reload_token = token
|
|
self._discovery_completed = True
|
|
|
|
logger.info(f"Discovered {len(new_registry)} plugin(s)")
|
|
except FileNotFoundError:
|
|
logger.warning(f"Plugins directory not found: {self.plugins_dir}")
|
|
|
|
# Sync DB records (optional)
|
|
if sync_db:
|
|
try:
|
|
self._sync_db_with_registry(new_registry if 'new_registry' in locals() else None)
|
|
except Exception:
|
|
# Defer sync if database is not ready (e.g., first startup before migrate)
|
|
logger.exception("Deferring plugin DB sync; database not ready yet")
|
|
return self._registry
|
|
|
|
def iter_actions_for_event(self, event_name: str) -> Iterator[Tuple[str, str]]:
|
|
"""Yield (plugin_key, action_id) pairs from the in-memory registry."""
|
|
with self._lock:
|
|
registry = list(self._registry.items())
|
|
for key, lp in registry:
|
|
for action in lp.actions or []:
|
|
if not isinstance(action, dict):
|
|
continue
|
|
action_id = action.get("id")
|
|
events = action.get("events")
|
|
if (
|
|
action_id
|
|
and isinstance(events, (list, tuple))
|
|
and event_name in events
|
|
):
|
|
yield key, action_id
|
|
|
|
def _load_plugin(
|
|
self,
|
|
key: str,
|
|
path: str,
|
|
*,
|
|
folder_name: str,
|
|
force_reload: bool,
|
|
previous_package: Optional[str],
|
|
) -> tuple[Optional[LoadedPlugin], Optional[str]]:
|
|
# Plugin can be a package and/or contain plugin.py. Prefer plugin.py when present.
|
|
has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
|
|
has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
|
|
if not (has_pkg or has_pluginpy):
|
|
logger.debug(f"Skipping {path}: no plugin.py or package")
|
|
return None, None
|
|
|
|
package_name = self._resolve_package_name(key)
|
|
alias_name = self._resolve_alias_name(folder_name, path)
|
|
|
|
if force_reload and previous_package:
|
|
self._unload_package(previous_package)
|
|
|
|
module = None
|
|
plugin_cls = None
|
|
last_error = None
|
|
|
|
# Ensure a package context exists for plugin.py (even without __init__.py)
|
|
if has_pluginpy:
|
|
self._ensure_namespace_package(package_name, path, alias=alias_name)
|
|
|
|
module_name = f"{package_name}.plugin"
|
|
plugin_path = os.path.join(path, "plugin.py")
|
|
try:
|
|
logger.debug(f"Importing plugin module {module_name} from {plugin_path}")
|
|
module = self._load_module_from_path(module_name, plugin_path, is_package=False)
|
|
if alias_name:
|
|
self._register_alias_module(f"{alias_name}.plugin", module, path)
|
|
plugin_cls = getattr(module, "Plugin", None)
|
|
if plugin_cls is None:
|
|
logger.warning(f"Module {module_name} has no Plugin class")
|
|
except Exception as e:
|
|
last_error = e
|
|
logger.exception(f"Error importing module {module_name}")
|
|
|
|
if plugin_cls is None and has_pkg:
|
|
module_name = package_name
|
|
init_path = os.path.join(path, "__init__.py")
|
|
try:
|
|
logger.debug(f"Importing plugin package {module_name} from {init_path}")
|
|
module = self._load_module_from_path(module_name, init_path, is_package=True)
|
|
self._register_alias_module(alias_name, module, path)
|
|
plugin_cls = getattr(module, "Plugin", None)
|
|
if plugin_cls is None:
|
|
logger.warning(f"Module {module_name} has no Plugin class")
|
|
except Exception as e:
|
|
last_error = e
|
|
logger.exception(f"Error importing module {module_name}")
|
|
|
|
if plugin_cls is None:
|
|
if last_error:
|
|
raise last_error
|
|
logger.warning(f"No Plugin class found for {key}; skipping")
|
|
return None, package_name
|
|
|
|
instance = plugin_cls()
|
|
|
|
name = getattr(instance, "name", key)
|
|
version = getattr(instance, "version", "")
|
|
description = getattr(instance, "description", "")
|
|
author = getattr(instance, "author", "")
|
|
help_url = getattr(instance, "help_url", "")
|
|
fields = getattr(instance, "fields", [])
|
|
actions = getattr(instance, "actions", [])
|
|
fields = self._normalize_fields(fields)
|
|
actions = self._normalize_actions(actions)
|
|
|
|
lp = LoadedPlugin(
|
|
key=key,
|
|
name=name,
|
|
version=version,
|
|
description=description,
|
|
author=author or "",
|
|
help_url=help_url or "",
|
|
module=module,
|
|
instance=instance,
|
|
fields=fields,
|
|
actions=actions,
|
|
path=path,
|
|
folder_name=folder_name,
|
|
)
|
|
return lp, package_name
|
|
|
|
def _sync_db_with_registry(self, registry: Optional[Dict[str, LoadedPlugin]] = None):
|
|
if registry is None:
|
|
with self._lock:
|
|
registry = dict(self._registry)
|
|
with transaction.atomic():
|
|
for key, lp in registry.items():
|
|
obj, _ = PluginConfig.objects.get_or_create(
|
|
key=key,
|
|
defaults={
|
|
"name": lp.name,
|
|
"version": lp.version,
|
|
"description": lp.description,
|
|
"settings": {},
|
|
},
|
|
)
|
|
# Update meta if changed
|
|
changed = False
|
|
if obj.name != lp.name:
|
|
obj.name = lp.name
|
|
changed = True
|
|
if obj.version != lp.version:
|
|
obj.version = lp.version
|
|
changed = True
|
|
if obj.description != lp.description:
|
|
obj.description = lp.description
|
|
changed = True
|
|
if changed:
|
|
obj.save()
|
|
|
|
def list_plugins(self) -> List[Dict[str, Any]]:
|
|
from .models import PluginConfig, PluginRepo
|
|
|
|
plugins: List[Dict[str, Any]] = []
|
|
with self._lock:
|
|
registry_snapshot = dict(self._registry)
|
|
try:
|
|
configs = {c.key: c for c in PluginConfig.objects.select_related("source_repo").all()}
|
|
except Exception as e:
|
|
# Database might not be migrated yet; fall back to registry only
|
|
logger.warning("PluginConfig table unavailable; listing registry only: %s", e)
|
|
configs = {}
|
|
|
|
# Build repo latest-version lookup from cached manifests
|
|
repo_latest = {} # slug -> latest_version
|
|
try:
|
|
for repo in PluginRepo.objects.filter(enabled=True):
|
|
manifest_data = repo.cached_manifest or {}
|
|
manifest = manifest_data.get("manifest", manifest_data)
|
|
for rp in manifest.get("plugins", []):
|
|
s = rp.get("slug", "")
|
|
if s:
|
|
repo_latest[s] = rp.get("latest_version", "")
|
|
except Exception:
|
|
pass
|
|
|
|
# First, include all discovered plugins
|
|
for key, lp in registry_snapshot.items():
|
|
conf = configs.get(key)
|
|
conf_slug = conf.slug if conf else ""
|
|
trusted = bool(conf and (conf.ever_enabled or conf.enabled))
|
|
logo_url = self._get_logo_url(key, path=lp.path)
|
|
plugins.append(
|
|
{
|
|
"key": key,
|
|
"name": lp.name,
|
|
"version": lp.version,
|
|
"description": lp.description,
|
|
"author": getattr(lp, "author", "") or "",
|
|
"help_url": getattr(lp, "help_url", "") or "",
|
|
"enabled": conf.enabled if conf else False,
|
|
"ever_enabled": conf.ever_enabled if conf else False,
|
|
"fields": lp.fields or [],
|
|
"settings": (conf.settings if conf else {}),
|
|
"actions": lp.actions or [],
|
|
"missing": False,
|
|
"trusted": trusted,
|
|
"loaded": bool(lp.loaded),
|
|
"legacy": bool(getattr(lp, "legacy", False)),
|
|
"logo_url": logo_url,
|
|
"source_repo": conf.source_repo_id if conf else None,
|
|
"source_repo_name": conf.source_repo.name if conf and conf.source_repo else None,
|
|
"is_official_repo": bool(conf and conf.source_repo and conf.source_repo.is_official),
|
|
"slug": conf_slug,
|
|
"is_managed": bool(conf and conf.source_repo_id),
|
|
"installed_version_is_prerelease": bool(
|
|
conf and conf.installed_version_is_prerelease
|
|
),
|
|
"update_available": bool(
|
|
conf_slug and conf and conf.source_repo_id
|
|
and not (conf and conf.installed_version_is_prerelease)
|
|
and repo_latest.get(conf_slug)
|
|
and lp.version != repo_latest.get(conf_slug)
|
|
),
|
|
"latest_version": repo_latest.get(conf_slug, ""),
|
|
"deprecated": conf.deprecated if conf else False,
|
|
}
|
|
)
|
|
|
|
# Then, include any DB-only configs (files missing or failed to load)
|
|
discovered_keys = set(registry_snapshot.keys())
|
|
for key, conf in configs.items():
|
|
if key in discovered_keys:
|
|
continue
|
|
plugins.append(
|
|
{
|
|
"key": key,
|
|
"name": conf.name,
|
|
"version": conf.version,
|
|
"description": conf.description,
|
|
"author": "",
|
|
"help_url": "",
|
|
"enabled": conf.enabled,
|
|
"ever_enabled": getattr(conf, "ever_enabled", False),
|
|
"fields": [],
|
|
"settings": conf.settings or {},
|
|
"actions": [],
|
|
"missing": True,
|
|
"trusted": bool(conf.ever_enabled or conf.enabled),
|
|
"loaded": False,
|
|
"legacy": False,
|
|
"logo_url": self._get_logo_url(key),
|
|
"source_repo": conf.source_repo_id,
|
|
"source_repo_name": conf.source_repo.name if conf.source_repo else None,
|
|
"is_official_repo": bool(conf.source_repo and conf.source_repo.is_official),
|
|
"slug": conf.slug,
|
|
"is_managed": bool(conf.source_repo_id),
|
|
"installed_version_is_prerelease": bool(
|
|
conf.installed_version_is_prerelease
|
|
),
|
|
"update_available": bool(
|
|
conf.slug and conf.source_repo_id
|
|
and not conf.installed_version_is_prerelease
|
|
and repo_latest.get(conf.slug)
|
|
and conf.version != repo_latest.get(conf.slug)
|
|
),
|
|
"latest_version": repo_latest.get(conf.slug or "", ""),
|
|
"deprecated": conf.deprecated,
|
|
}
|
|
)
|
|
|
|
return plugins
|
|
|
|
def get_plugin(self, key: str) -> Optional[LoadedPlugin]:
|
|
with self._lock:
|
|
return self._registry.get(key)
|
|
|
|
def update_settings(self, key: str, settings: Dict[str, Any]) -> Dict[str, Any]:
|
|
cfg = PluginConfig.objects.get(key=key)
|
|
cfg.settings = settings or {}
|
|
cfg.save(update_fields=["settings", "updated_at"])
|
|
return cfg.settings
|
|
|
|
def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
try:
|
|
lp = self.get_plugin(key)
|
|
if not lp or not lp.instance:
|
|
# Attempt a lightweight re-discovery in case the registry was rebuilt
|
|
self.discover_plugins(sync_db=False, force_reload=False, use_cache=False)
|
|
lp = self.get_plugin(key)
|
|
if not lp or not lp.instance:
|
|
raise ValueError(f"Plugin '{key}' not found")
|
|
|
|
cfg = PluginConfig.objects.get(key=key)
|
|
if not cfg.enabled:
|
|
raise PermissionError(f"Plugin '{key}' is disabled")
|
|
params = params or {}
|
|
|
|
context = self._build_context(lp, cfg)
|
|
|
|
run_method = getattr(lp.instance, "run", None)
|
|
if not callable(run_method):
|
|
raise ValueError(f"Plugin '{key}' has no runnable 'run' method")
|
|
|
|
try:
|
|
result = run_method(action_id, params, context)
|
|
except Exception:
|
|
logger.exception(f"Plugin '{key}' action '{action_id}' failed")
|
|
raise
|
|
|
|
if isinstance(result, dict):
|
|
return result
|
|
return {"status": "ok", "result": result}
|
|
finally:
|
|
# Return geventpool checkouts for this greenlet/thread after every action,
|
|
# including Connect event hooks and manual UI runs.
|
|
close_old_connections()
|
|
|
|
def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool:
|
|
try:
|
|
lp = self.get_plugin(key)
|
|
if not lp or not lp.instance:
|
|
return False
|
|
try:
|
|
cfg = PluginConfig.objects.get(key=key)
|
|
except PluginConfig.DoesNotExist:
|
|
return False
|
|
if not cfg.enabled:
|
|
return False
|
|
|
|
context = self._build_context(lp, cfg)
|
|
if reason:
|
|
context["reason"] = reason
|
|
|
|
stop_method = getattr(lp.instance, "stop", None)
|
|
if callable(stop_method):
|
|
try:
|
|
stop_method(context)
|
|
return True
|
|
except TypeError:
|
|
try:
|
|
stop_method()
|
|
return True
|
|
except Exception:
|
|
logger.exception("Plugin '%s' stop() failed", key)
|
|
return False
|
|
except Exception:
|
|
logger.exception("Plugin '%s' stop() failed", key)
|
|
return False
|
|
|
|
run_method = getattr(lp.instance, "run", None)
|
|
if callable(run_method):
|
|
actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)}
|
|
if "stop" in actions:
|
|
try:
|
|
run_method("stop", {}, context)
|
|
return True
|
|
except Exception:
|
|
logger.exception("Plugin '%s' stop action failed", key)
|
|
return False
|
|
return False
|
|
finally:
|
|
close_old_connections()
|
|
|
|
def stop_all_plugins(self, reason: Optional[str] = None) -> int:
|
|
stopped = 0
|
|
with self._lock:
|
|
registry_snapshot = dict(self._registry)
|
|
for key in registry_snapshot.keys():
|
|
if self.stop_plugin(key, reason=reason):
|
|
stopped += 1
|
|
return stopped
|
|
|
|
def _resolve_package_name(self, key: str) -> str:
|
|
safe_key = self._safe_module_name(key)
|
|
return f"_dispatcharr_plugin_{safe_key}"
|
|
|
|
def _resolve_alias_name(self, folder_name: str, path: str) -> Optional[str]:
|
|
if not self._is_valid_identifier(folder_name):
|
|
return None
|
|
if self._is_reserved_module_name(folder_name, path):
|
|
return None
|
|
return folder_name
|
|
|
|
def _is_valid_identifier(self, name: str) -> bool:
|
|
return re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) is not None
|
|
|
|
def _safe_module_name(self, value: str) -> str:
|
|
safe = re.sub(r"[^0-9A-Za-z_]", "_", value)
|
|
if not safe or safe[0].isdigit():
|
|
safe = f"p_{safe}"
|
|
return safe
|
|
|
|
def _normalize_fields(self, fields: Any) -> List[Dict[str, Any]]:
|
|
try:
|
|
from .serializers import PluginFieldSerializer
|
|
except Exception:
|
|
return fields if isinstance(fields, list) else []
|
|
if not isinstance(fields, list):
|
|
return []
|
|
serializer = PluginFieldSerializer(data=fields, many=True)
|
|
if serializer.is_valid():
|
|
return serializer.validated_data
|
|
normalized: List[Dict[str, Any]] = []
|
|
for item in fields:
|
|
item_ser = PluginFieldSerializer(data=item)
|
|
if item_ser.is_valid():
|
|
normalized.append(item_ser.validated_data)
|
|
else:
|
|
logger.warning("Invalid plugin field entry ignored: %s", item_ser.errors)
|
|
return normalized
|
|
|
|
def _normalize_actions(self, actions: Any) -> List[Dict[str, Any]]:
|
|
try:
|
|
from .serializers import PluginActionSerializer
|
|
except Exception:
|
|
return actions if isinstance(actions, list) else []
|
|
if not isinstance(actions, list):
|
|
return []
|
|
serializer = PluginActionSerializer(data=actions, many=True)
|
|
if serializer.is_valid():
|
|
return serializer.validated_data
|
|
normalized: List[Dict[str, Any]] = []
|
|
for item in actions:
|
|
item_ser = PluginActionSerializer(data=item)
|
|
if item_ser.is_valid():
|
|
normalized.append(item_ser.validated_data)
|
|
else:
|
|
logger.warning("Invalid plugin action entry ignored: %s", item_ser.errors)
|
|
return normalized
|
|
|
|
def _merge_settings_with_defaults(self, settings: Dict[str, Any], fields: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
merged = dict(settings or {})
|
|
for field_def in fields or []:
|
|
field_id = field_def.get("id")
|
|
if not field_id:
|
|
continue
|
|
if field_id not in merged and "default" in field_def:
|
|
merged[field_id] = field_def.get("default")
|
|
return merged
|
|
|
|
def _build_context(self, lp: LoadedPlugin, cfg: PluginConfig) -> Dict[str, Any]:
|
|
settings = self._merge_settings_with_defaults(cfg.settings or {}, lp.fields or [])
|
|
return {
|
|
"settings": settings,
|
|
"logger": logger,
|
|
"actions": {a.get("id"): a for a in (lp.actions or [])},
|
|
}
|
|
|
|
def _read_manifest(self, path: str) -> tuple[Optional[Dict[str, Any]], bool]:
|
|
manifest_path = os.path.join(path, "plugin.json")
|
|
if not os.path.isfile(manifest_path):
|
|
return None, False
|
|
try:
|
|
with open(manifest_path, "r", encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
except Exception:
|
|
logger.warning("Invalid plugin.json for plugin at %s", path)
|
|
return None, False
|
|
if not isinstance(data, dict):
|
|
logger.warning("plugin.json must be an object for plugin at %s", path)
|
|
return None, False
|
|
return data, True
|
|
|
|
def _get_logo_url(self, key: str, *, path: Optional[str] = None) -> Optional[str]:
|
|
logo_path = os.path.join(self.plugins_dir, key, "logo.png")
|
|
if path:
|
|
logo_path = os.path.join(path, "logo.png")
|
|
try:
|
|
if os.path.isfile(logo_path):
|
|
return f"/api/plugins/plugins/{key}/logo/"
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
def _ensure_namespace_package(self, package_name: str, path: str, *, alias: Optional[str] = None) -> None:
|
|
existing = sys.modules.get(package_name)
|
|
if existing and getattr(existing, "__path__", None):
|
|
return
|
|
pkg = types.ModuleType(package_name)
|
|
pkg.__path__ = [path]
|
|
pkg.__package__ = package_name
|
|
sys.modules[package_name] = pkg
|
|
self._register_alias_module(alias, pkg, path)
|
|
|
|
def _register_alias_module(
|
|
self,
|
|
alias_name: Optional[str],
|
|
module: Any,
|
|
path: str,
|
|
*,
|
|
force: bool = False,
|
|
) -> None:
|
|
if not alias_name:
|
|
return
|
|
if self._is_reserved_module_name(alias_name, path):
|
|
return
|
|
if alias_name in sys.modules:
|
|
if not force:
|
|
return
|
|
self._unload_alias(alias_name)
|
|
sys.modules[alias_name] = module
|
|
|
|
def _is_reserved_module_name(self, name: str, path: str) -> bool:
|
|
if name in sys.builtin_module_names:
|
|
return True
|
|
if hasattr(sys, "stdlib_module_names") and name in sys.stdlib_module_names:
|
|
return True
|
|
existing = sys.modules.get(name)
|
|
if existing:
|
|
origin = getattr(existing, "__file__", None)
|
|
if origin is None:
|
|
return True
|
|
try:
|
|
if not os.path.abspath(origin).startswith(os.path.abspath(path)):
|
|
return True
|
|
except Exception:
|
|
return True
|
|
try:
|
|
spec = importlib.util.find_spec(name)
|
|
except Exception:
|
|
spec = None
|
|
if spec:
|
|
if spec.origin is None:
|
|
return True
|
|
try:
|
|
if not os.path.abspath(spec.origin).startswith(os.path.abspath(path)):
|
|
return True
|
|
except Exception:
|
|
return True
|
|
return False
|
|
|
|
def _load_module_from_path(self, module_name: str, path: str, *, is_package: bool) -> Any:
|
|
importlib.invalidate_caches()
|
|
spec = importlib.util.spec_from_file_location(
|
|
module_name,
|
|
path,
|
|
submodule_search_locations=[os.path.dirname(path)] if is_package else None,
|
|
)
|
|
if spec is None or spec.loader is None:
|
|
raise ImportError(f"Could not load spec for {module_name} from {path}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[module_name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
def _get_reload_token(self) -> float:
|
|
try:
|
|
return os.path.getmtime(self._reload_token_path)
|
|
except FileNotFoundError:
|
|
return 0.0
|
|
except Exception:
|
|
return 0.0
|
|
|
|
def _touch_reload_token(self) -> None:
|
|
try:
|
|
os.makedirs(self.plugins_dir, exist_ok=True)
|
|
with open(self._reload_token_path, "a", encoding="utf-8"):
|
|
pass
|
|
os.utime(self._reload_token_path, None)
|
|
except Exception:
|
|
logger.debug("Failed to update plugin reload token", exc_info=True)
|
|
|
|
def _unload_package(self, package_name: str) -> None:
|
|
if not package_name:
|
|
return
|
|
for name in list(sys.modules.keys()):
|
|
if name == package_name or name.startswith(f"{package_name}."):
|
|
sys.modules.pop(name, None)
|
|
|
|
def _unload_alias(self, alias_name: str) -> None:
|
|
if not alias_name:
|
|
return
|
|
for name in list(sys.modules.keys()):
|
|
if name == alias_name or name.startswith(f"{alias_name}."):
|
|
sys.modules.pop(name, None)
|
|
|
|
def _unload_path_modules(self, path: str) -> None:
|
|
if not path:
|
|
return
|
|
root = os.path.abspath(path)
|
|
for name, module in list(sys.modules.items()):
|
|
if not module:
|
|
continue
|
|
mod_path = getattr(module, "__file__", None)
|
|
if mod_path:
|
|
try:
|
|
abs_path = os.path.abspath(mod_path)
|
|
if abs_path == root or abs_path.startswith(f"{root}{os.sep}"):
|
|
sys.modules.pop(name, None)
|
|
continue
|
|
except Exception:
|
|
pass
|
|
mod_paths = getattr(module, "__path__", None)
|
|
if mod_paths:
|
|
try:
|
|
for pkg_path in mod_paths:
|
|
abs_pkg = os.path.abspath(pkg_path)
|
|
if abs_pkg == root or abs_pkg.startswith(f"{root}{os.sep}"):
|
|
sys.modules.pop(name, None)
|
|
break
|
|
except Exception:
|
|
continue
|