Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
default_app_config = "apps.plugins.apps.PluginsConfig"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from django.urls import path
|
||||
from .api_views import (
|
||||
PluginsListAPIView,
|
||||
PluginReloadAPIView,
|
||||
PluginSettingsAPIView,
|
||||
PluginRunAPIView,
|
||||
PluginEnabledAPIView,
|
||||
PluginImportAPIView,
|
||||
PluginDeleteAPIView,
|
||||
PluginLogoAPIView,
|
||||
PluginRepoListCreateAPIView,
|
||||
PluginRepoPreviewAPIView,
|
||||
PluginRepoDetailAPIView,
|
||||
PluginRepoRefreshAPIView,
|
||||
AvailablePluginsAPIView,
|
||||
PluginDetailManifestAPIView,
|
||||
PluginInstallFromRepoAPIView,
|
||||
PluginRepoSettingsAPIView,
|
||||
)
|
||||
|
||||
app_name = "plugins"
|
||||
|
||||
urlpatterns = [
|
||||
path("plugins/", PluginsListAPIView.as_view(), name="list"),
|
||||
path("plugins/reload/", PluginReloadAPIView.as_view(), name="reload"),
|
||||
path("plugins/import/", PluginImportAPIView.as_view(), name="import"),
|
||||
path("plugins/<str:key>/delete/", PluginDeleteAPIView.as_view(), name="delete"),
|
||||
path("plugins/<str:key>/settings/", PluginSettingsAPIView.as_view(), name="settings"),
|
||||
path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"),
|
||||
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
|
||||
path("plugins/<str:key>/logo/", PluginLogoAPIView.as_view(), name="logo"),
|
||||
# Plugin repos (hub / store) - static paths first, then parametric
|
||||
path("repos/", PluginRepoListCreateAPIView.as_view(), name="repo-list"),
|
||||
path("repos/available/", AvailablePluginsAPIView.as_view(), name="available-plugins"),
|
||||
path("repos/plugin-detail/", PluginDetailManifestAPIView.as_view(), name="plugin-detail-manifest"),
|
||||
path("repos/install/", PluginInstallFromRepoAPIView.as_view(), name="repo-install"),
|
||||
path("repos/settings/", PluginRepoSettingsAPIView.as_view(), name="repo-settings"),
|
||||
path("repos/preview/", PluginRepoPreviewAPIView.as_view(), name="repo-preview"),
|
||||
path("repos/<int:pk>/", PluginRepoDetailAPIView.as_view(), name="repo-detail"),
|
||||
path("repos/<int:pk>/refresh/", PluginRepoRefreshAPIView.as_view(), name="repo-refresh"),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
from django.apps import AppConfig
|
||||
import os
|
||||
import sys
|
||||
from django.db.models.signals import post_migrate
|
||||
|
||||
|
||||
class PluginsConfig(AppConfig):
|
||||
name = "apps.plugins"
|
||||
verbose_name = "Plugins"
|
||||
|
||||
def ready(self):
|
||||
"""Wire up plugin discovery without hitting the DB during app init.
|
||||
|
||||
- Skip during common management commands that don't need discovery.
|
||||
- Register post_migrate handler to sync plugin registry to DB after migrations.
|
||||
- Do an in-memory discovery (no DB) so registry is available early.
|
||||
"""
|
||||
try:
|
||||
# Allow explicit opt-out via env var
|
||||
if os.environ.get("DISPATCHARR_SKIP_PLUGIN_AUTODISCOVERY", "").lower() in ("1", "true", "yes"):
|
||||
return
|
||||
|
||||
argv = sys.argv[1:] if len(sys.argv) > 1 else []
|
||||
mgmt_cmds_to_skip = {
|
||||
# Skip immediate discovery for these commands
|
||||
"makemigrations", "collectstatic", "check", "test", "shell", "showmigrations",
|
||||
}
|
||||
if argv and argv[0] in mgmt_cmds_to_skip:
|
||||
return
|
||||
|
||||
# Run discovery with DB sync after the plugins app has been migrated
|
||||
def _post_migrate_discover(sender=None, app_config=None, **kwargs):
|
||||
try:
|
||||
if app_config and getattr(app_config, 'label', None) != 'plugins':
|
||||
return
|
||||
from .loader import PluginManager
|
||||
PluginManager.get().discover_plugins(sync_db=True)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Plugin discovery failed in post_migrate")
|
||||
|
||||
post_migrate.connect(
|
||||
_post_migrate_discover,
|
||||
dispatch_uid="apps.plugins.post_migrate_discover",
|
||||
)
|
||||
|
||||
# Perform non-DB discovery now to populate in-memory registry.
|
||||
from .loader import PluginManager
|
||||
PluginManager.get().discover_plugins(sync_db=False)
|
||||
except Exception:
|
||||
# Avoid breaking startup due to plugin errors
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception("Plugin discovery wiring failed during app ready")
|
||||
|
||||
# Register periodic task for refreshing plugin repo manifests
|
||||
self._setup_repo_refresh_schedule()
|
||||
|
||||
# Refresh repo manifests once at startup so the UI always has current data
|
||||
self._enqueue_startup_refresh()
|
||||
|
||||
def _enqueue_startup_refresh(self):
|
||||
from dispatcharr.app_initialization import should_skip_initialization
|
||||
if should_skip_initialization():
|
||||
return
|
||||
try:
|
||||
from .tasks import refresh_plugin_repos
|
||||
refresh_plugin_repos.apply_async(countdown=10)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(
|
||||
"Could not enqueue startup plugin repo refresh (Celery may not be ready yet)"
|
||||
)
|
||||
|
||||
def _setup_repo_refresh_schedule(self):
|
||||
from dispatcharr.app_initialization import should_skip_initialization
|
||||
if should_skip_initialization():
|
||||
return
|
||||
try:
|
||||
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
|
||||
from core.models import CoreSettings
|
||||
from .tasks import PLUGIN_REPO_REFRESH_TASK_NAME
|
||||
|
||||
interval = 6
|
||||
try:
|
||||
obj = CoreSettings.objects.get(key="plugin_repo_settings")
|
||||
interval = obj.value.get("refresh_interval_hours", 6)
|
||||
except CoreSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
if interval == 0:
|
||||
delete_periodic_task(PLUGIN_REPO_REFRESH_TASK_NAME)
|
||||
else:
|
||||
create_or_update_periodic_task(
|
||||
task_name=PLUGIN_REPO_REFRESH_TASK_NAME,
|
||||
celery_task_path="apps.plugins.tasks.refresh_plugin_repos",
|
||||
interval_hours=interval,
|
||||
enabled=True,
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(
|
||||
"Could not set up plugin repo refresh schedule (migrations may not have run yet)"
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEacgfABYJKwYBBAHaRw8BAQdAh1MuVNBxk+CExQPjOVDvAGvIk6BdGS2ce9/h
|
||||
zB7lYtW0TERpc3BhdGNoYXJyIFBsdWdpbiBSZXBvIChkaXNwYXRjaGFyci1hdXRv
|
||||
Z2VuZXJhdGVkKSA8cGx1Z2luc0BkaXNwYXRjaGFyci50dj6IrwQTFgoAVxYhBEap
|
||||
MFaOD7nKg0zX+H7AOmtMIjTOBQJpyB8AGxSAAAAAAAQADm1hbnUyLDIuNSsxLjEy
|
||||
LDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRB+wDprTCI0zvNZ
|
||||
AP9r3TpMpiI8BCNo9B5M9lJ+QLRo9ihPWIcqBzJ9eFCoSQEAgguiZsNy6aJzKjIb
|
||||
yDvGuoZi3I2/GNM/f2qVzFtgPQk=
|
||||
=Zf/y
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
@@ -0,0 +1,805 @@
|
||||
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, List, Optional
|
||||
|
||||
from django.db import 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._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._registry 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:
|
||||
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
|
||||
|
||||
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 _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]:
|
||||
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 either via Celery if plugin provides a delayed method, or inline
|
||||
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
|
||||
|
||||
# Normalize return
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
return {"status": "ok", "result": result}
|
||||
|
||||
def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool:
|
||||
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
|
||||
|
||||
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
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-13 13:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PluginConfig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=128, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('version', models.CharField(blank=True, default='', max_length=64)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('enabled', models.BooleanField(default=False)),
|
||||
('ever_enabled', models.BooleanField(default=False)),
|
||||
('settings', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def seed_official_repo(apps, schema_editor):
|
||||
PluginRepo = apps.get_model("plugins", "PluginRepo")
|
||||
PluginRepo.objects.get_or_create(
|
||||
url="https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json",
|
||||
defaults={
|
||||
"name": "Dispatcharr Official",
|
||||
"is_official": True,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def unseed_official_repo(apps, schema_editor):
|
||||
PluginRepo = apps.get_model("plugins", "PluginRepo")
|
||||
PluginRepo.objects.filter(is_official=True).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("plugins", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PluginRepo",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("url", models.URLField(unique=True)),
|
||||
("is_official", models.BooleanField(default=False)),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
("cached_manifest", models.JSONField(blank=True, default=dict)),
|
||||
("last_fetched", models.DateTimeField(blank=True, null=True)),
|
||||
("public_key", models.TextField(blank=True, default="")),
|
||||
("signature_verified", models.BooleanField(blank=True, default=None, null=True)),
|
||||
("last_fetch_status", models.CharField(blank=True, default="", max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-is_official", "name"],
|
||||
},
|
||||
),
|
||||
migrations.RunPython(seed_official_repo, unseed_official_repo),
|
||||
migrations.AddField(
|
||||
model_name="pluginconfig",
|
||||
name="source_repo",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="installed_plugins",
|
||||
to="plugins.pluginrepo",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pluginconfig",
|
||||
name="slug",
|
||||
field=models.CharField(blank=True, default="", max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pluginconfig",
|
||||
name="installed_version_is_prerelease",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pluginconfig",
|
||||
name="deprecated",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
# This file marks the migrations package for the plugins app.
|
||||
@@ -0,0 +1,63 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class PluginConfig(models.Model):
|
||||
"""Stores discovered plugins and their persisted settings."""
|
||||
|
||||
key = models.CharField(max_length=128, unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
version = models.CharField(max_length=64, blank=True, default="")
|
||||
description = models.TextField(blank=True, default="")
|
||||
enabled = models.BooleanField(default=False)
|
||||
# Tracks whether this plugin has ever been enabled at least once
|
||||
ever_enabled = models.BooleanField(default=False)
|
||||
settings = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Managed plugin fields (populated when installed from a repo)
|
||||
source_repo = models.ForeignKey(
|
||||
"PluginRepo",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="installed_plugins",
|
||||
)
|
||||
slug = models.CharField(max_length=128, blank=True, default="")
|
||||
installed_version_is_prerelease = models.BooleanField(default=False)
|
||||
deprecated = models.BooleanField(default=False)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def is_managed(self):
|
||||
return bool(self.source_repo_id)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.key})"
|
||||
|
||||
|
||||
OFFICIAL_REPO_URL = (
|
||||
"https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json"
|
||||
)
|
||||
|
||||
|
||||
class PluginRepo(models.Model):
|
||||
"""A remote plugin repository manifest URL."""
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
url = models.URLField(unique=True)
|
||||
is_official = models.BooleanField(default=False)
|
||||
enabled = models.BooleanField(default=True)
|
||||
cached_manifest = models.JSONField(default=dict, blank=True)
|
||||
public_key = models.TextField(blank=True, default="")
|
||||
signature_verified = models.BooleanField(null=True, blank=True, default=None)
|
||||
last_fetched = models.DateTimeField(null=True, blank=True)
|
||||
last_fetch_status = models.CharField(max_length=255, blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-is_official", "name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
@@ -0,0 +1,86 @@
|
||||
from rest_framework import serializers
|
||||
from .models import PluginRepo
|
||||
|
||||
|
||||
class PluginActionSerializer(serializers.Serializer):
|
||||
id = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
confirm = serializers.JSONField(required=False)
|
||||
button_label = serializers.CharField(required=False, allow_blank=True)
|
||||
button_variant = serializers.CharField(required=False, allow_blank=True)
|
||||
button_color = serializers.CharField(required=False, allow_blank=True)
|
||||
events = serializers.ListField(
|
||||
child=serializers.CharField(), required=False, allow_empty=True
|
||||
)
|
||||
|
||||
|
||||
class PluginFieldOptionSerializer(serializers.Serializer):
|
||||
value = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
|
||||
|
||||
class PluginFieldSerializer(serializers.Serializer):
|
||||
id = serializers.CharField()
|
||||
label = serializers.CharField(required=False, allow_blank=True)
|
||||
type = serializers.ChoiceField(choices=["string", "number", "boolean", "select", "text", "info"])
|
||||
default = serializers.JSONField(required=False)
|
||||
help_text = serializers.CharField(required=False, allow_blank=True)
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
placeholder = serializers.CharField(required=False, allow_blank=True)
|
||||
input_type = serializers.CharField(required=False, allow_blank=True)
|
||||
min = serializers.FloatField(required=False)
|
||||
max = serializers.FloatField(required=False)
|
||||
step = serializers.FloatField(required=False)
|
||||
value = serializers.CharField(required=False, allow_blank=True)
|
||||
options = PluginFieldOptionSerializer(many=True, required=False)
|
||||
|
||||
|
||||
class PluginSerializer(serializers.Serializer):
|
||||
key = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
version = serializers.CharField(allow_blank=True)
|
||||
description = serializers.CharField(allow_blank=True)
|
||||
author = serializers.CharField(required=False, allow_blank=True)
|
||||
help_url = serializers.CharField(required=False, allow_blank=True)
|
||||
enabled = serializers.BooleanField()
|
||||
fields = PluginFieldSerializer(many=True)
|
||||
settings = serializers.JSONField()
|
||||
actions = PluginActionSerializer(many=True)
|
||||
source_repo = serializers.IntegerField(required=False, allow_null=True)
|
||||
slug = serializers.CharField(required=False, allow_blank=True)
|
||||
is_managed = serializers.BooleanField(required=False)
|
||||
deprecated = serializers.BooleanField(required=False)
|
||||
|
||||
|
||||
class PluginRepoSerializer(serializers.ModelSerializer):
|
||||
registry_url = serializers.SerializerMethodField()
|
||||
plugin_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PluginRepo
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"url",
|
||||
"is_official",
|
||||
"enabled",
|
||||
"public_key",
|
||||
"signature_verified",
|
||||
"registry_url",
|
||||
"plugin_count",
|
||||
"last_fetched",
|
||||
"last_fetch_status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "name", "is_official", "signature_verified", "registry_url", "plugin_count", "last_fetched", "last_fetch_status", "created_at", "updated_at"]
|
||||
|
||||
def get_registry_url(self, obj):
|
||||
manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {})
|
||||
return manifest.get("registry_url", "") or ""
|
||||
|
||||
def get_plugin_count(self, obj):
|
||||
manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {})
|
||||
plugins = manifest.get("plugins", [])
|
||||
return len(plugins) if isinstance(plugins, list) else 0
|
||||
@@ -0,0 +1,33 @@
|
||||
import logging
|
||||
from celery import shared_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLUGIN_REPO_REFRESH_TASK_NAME = "plugin-repo-refresh-task"
|
||||
|
||||
|
||||
@shared_task
|
||||
def refresh_plugin_repos():
|
||||
"""Refresh cached manifests for all enabled plugin repos."""
|
||||
from .models import PluginRepo
|
||||
from .api_views import _fetch_manifest, _save_fetched_manifest_to_repo, _unmanage_dropped_slugs
|
||||
from django.utils import timezone
|
||||
|
||||
repos = PluginRepo.objects.filter(enabled=True)
|
||||
for repo in repos:
|
||||
try:
|
||||
key_text = repo.public_key if not repo.is_official else None
|
||||
data, verified = _fetch_manifest(repo.url, public_key_text=key_text)
|
||||
err = _save_fetched_manifest_to_repo(repo, data, verified)
|
||||
if err:
|
||||
logger.warning("Skipping repo '%s': %s", repo.name, err)
|
||||
continue
|
||||
_unmanage_dropped_slugs(repo, data)
|
||||
logger.info("Refreshed plugin repo '%s'", repo.name)
|
||||
except Exception as e:
|
||||
resp = getattr(e, 'response', None)
|
||||
status_str = str(resp.status_code) if resp is not None and hasattr(resp, 'status_code') else type(e).__name__
|
||||
repo.last_fetch_status = status_str[:255]
|
||||
repo.last_fetched = timezone.now()
|
||||
repo.save(update_fields=["last_fetch_status", "last_fetched", "updated_at"])
|
||||
logger.warning("Failed to refresh plugin repo '%s': %s", repo.name, e)
|
||||
Reference in New Issue
Block a user