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

This commit is contained in:
root
2026-05-09 21:24:50 +02:00
commit f56b088643
721 changed files with 177870 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
default_app_config = "apps.plugins.apps.PluginsConfig"
+41
View File
@@ -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
+104
View File
@@ -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)"
)
+11
View File
@@ -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-----
+805
View File
@@ -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
+29
View File
@@ -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),
),
]
+1
View File
@@ -0,0 +1 @@
# This file marks the migrations package for the plugins app.
+63
View File
@@ -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
+86
View File
@@ -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
+33
View File
@@ -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)