Merge pull request #363 from Dispatcharr/Plugins

Plugins
This commit is contained in:
OkinawaBoss 2025-09-08 09:11:19 -05:00 committed by GitHub
commit 3fb8e0ebd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1552 additions and 1 deletions

286
Plugins.md Normal file
View file

@ -0,0 +1,286 @@
# Dispatcharr Plugins
This document explains how to build, install, and use Python plugins in Dispatcharr. It covers discovery, the plugin interface, settings, actions, how to access application APIs, and examples.
---
## Quick Start
1) Create a folder under `/app/data/plugins/my_plugin/` (host path `data/plugins/my_plugin/` in the repo).
2) Add a `plugin.py` file exporting a `Plugin` class:
```
# /app/data/plugins/my_plugin/plugin.py
class Plugin:
name = "My Plugin"
version = "0.1.0"
description = "Does something useful"
# Settings fields rendered by the UI and persisted by the backend
fields = [
{"id": "enabled", "label": "Enabled", "type": "boolean", "default": True},
{"id": "limit", "label": "Item limit", "type": "number", "default": 5},
{"id": "mode", "label": "Mode", "type": "select", "default": "safe",
"options": [
{"value": "safe", "label": "Safe"},
{"value": "fast", "label": "Fast"},
]},
{"id": "note", "label": "Note", "type": "string", "default": ""},
]
# Actions appear as buttons. Clicking one calls run(action, params, context)
actions = [
{"id": "do_work", "label": "Do Work", "description": "Process items"},
]
def run(self, action: str, params: dict, context: dict):
settings = context.get("settings", {})
logger = context.get("logger")
if action == "do_work":
limit = int(settings.get("limit", 5))
mode = settings.get("mode", "safe")
logger.info(f"My Plugin running with limit={limit}, mode={mode}")
# Do a small amount of work here. Schedule Celery tasks for heavy work.
return {"status": "ok", "processed": limit, "mode": mode}
return {"status": "error", "message": f"Unknown action {action}"}
```
3) Open the Plugins page in the UI, click the refresh icon to reload discovery, then configure and run your plugin.
---
## Where Plugins Live
- Default directory: `/app/data/plugins` inside the container.
- Override with env var: `DISPATCHARR_PLUGINS_DIR`.
- Each plugin is a directory containing either:
- `plugin.py` exporting a `Plugin` class, or
- a Python package (`__init__.py`) exporting a `Plugin` class.
The directory name (lowercased, spaces as `_`) is used as the registry key and module import path (e.g. `my_plugin.plugin`).
---
## Discovery & Lifecycle
- Discovery runs at server startup and on-demand when:
- Fetching the plugins list from the UI
- Hitting `POST /api/plugins/plugins/reload/`
- The loader imports each plugin module and instantiates `Plugin()`.
- Metadata (name, version, description) and a per-plugin settings JSON are stored in the DB.
Backend code:
- Loader: `apps/plugins/loader.py`
- API Views: `apps/plugins/api_views.py`
- API URLs: `apps/plugins/api_urls.py`
- Model: `apps/plugins/models.py` (stores `enabled` flag and `settings` per plugin)
---
## Plugin Interface
Export a `Plugin` class. Supported attributes and behavior:
- `name` (str): Human-readable name.
- `version` (str): Semantic version string.
- `description` (str): Short description.
- `fields` (list): Settings schema used by the UI to render controls.
- `actions` (list): Available actions; the UI renders a Run button for each.
- `run(action, params, context)` (callable): Invoked when a user clicks an action.
### Settings Schema
Supported field `type`s:
- `boolean`
- `number`
- `string`
- `select` (requires `options`: `[{"value": ..., "label": ...}, ...]`)
Common field keys:
- `id` (str): Settings key.
- `label` (str): Label shown in the UI.
- `type` (str): One of above.
- `default` (any): Default value used until saved.
- `help_text` (str, optional): Shown under the control.
- `options` (list, for select): List of `{value, label}`.
The UI automatically renders settings and persists them. The backend stores settings in `PluginConfig.settings`.
Read settings in `run` via `context["settings"]`.
### Actions
Each action is a dict:
- `id` (str): Unique action id.
- `label` (str): Button label.
- `description` (str, optional): Helper text.
Clicking an action calls your plugins `run(action, params, context)` and shows a notification with the result or error.
### Action Confirmation (Modal)
Developers can request a confirmation modal per action using the `confirm` key on the action. Options:
- Boolean: `confirm: true` will show a default confirmation modal.
- Object: `confirm: { required: true, title: '...', message: '...' }` to customize the modal title and message.
Example:
```
actions = [
{
"id": "danger_run",
"label": "Do Something Risky",
"description": "Runs a job that affects many records.",
"confirm": { "required": true, "title": "Proceed?", "message": "This will modify many records." },
}
]
```
---
## Accessing Dispatcharr APIs from Plugins
Plugins are server-side Python code running within the Django application. You can:
- Import models and run queries/updates:
```
from apps.m3u.models import M3UAccount
from apps.epg.models import EPGSource
from apps.channels.models import Channel
from core.models import CoreSettings
```
- Dispatch Celery tasks for heavy work (recommended):
```
from apps.m3u.tasks import refresh_m3u_accounts # apps/m3u/tasks.py
from apps.epg.tasks import refresh_all_epg_data # apps/epg/tasks.py
refresh_m3u_accounts.delay()
refresh_all_epg_data.delay()
```
- Send WebSocket updates:
```
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {"type": "plugin", "plugin": "my_plugin", "message": "Done"})
```
- Use transactions:
```
from django.db import transaction
with transaction.atomic():
# bulk updates here
...
```
- Log via provided context or standard logging:
```
def run(self, action, params, context):
logger = context.get("logger") # already configured
logger.info("running action %s", action)
```
Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking.
---
## REST Endpoints (for UI and tooling)
- List plugins: `GET /api/plugins/plugins/`
- Response: `{ "plugins": [{ key, name, version, description, enabled, fields, settings, actions }, ...] }`
- Reload discovery: `POST /api/plugins/plugins/reload/`
- Import plugin: `POST /api/plugins/plugins/import/` with form-data file field `file`
- Update settings: `POST /api/plugins/plugins/<key>/settings/` with `{"settings": {...}}`
- Run action: `POST /api/plugins/plugins/<key>/run/` with `{"action": "id", "params": {...}}`
- Enable/disable: `POST /api/plugins/plugins/<key>/enabled/` with `{"enabled": true|false}`
Notes:
- When disabled, a plugin cannot run actions; backend returns HTTP 403.
---
## Importing Plugins
- In the UI, click the Import button on the Plugins page and upload a `.zip` containing a plugin folder.
- The archive should contain either `plugin.py` or a Python package (`__init__.py`).
- On success, the UI shows the plugin name/description and lets you enable it immediately (plugins are disabled by default).
---
## Enabling / Disabling Plugins
- Each plugin has a persisted `enabled` flag (default: disabled) and `ever_enabled` flag in the DB (`apps/plugins/models.py`).
- New plugins are disabled by default and require an explicit enable.
- The first time a plugin is enabled, the UI shows a trust warning modal explaining that plugins can run arbitrary server-side code.
- The Plugins page shows a toggle in the card header. Turning it off dims the card and disables the Run button.
- Backend enforcement: Attempts to run an action for a disabled plugin return HTTP 403.
---
## Example: Refresh All Sources Plugin
Path: `data/plugins/refresh_all/plugin.py`
```
class Plugin:
name = "Refresh All Sources"
version = "1.0.0"
description = "Force refresh all M3U accounts and EPG sources."
fields = [
{"id": "confirm", "label": "Require confirmation", "type": "boolean", "default": True,
"help_text": "If enabled, the UI should ask before running."}
]
actions = [
{"id": "refresh_all", "label": "Refresh All M3Us and EPGs",
"description": "Queues background refresh for all active M3U accounts and EPG sources."}
]
def run(self, action: str, params: dict, context: dict):
if action == "refresh_all":
from apps.m3u.tasks import refresh_m3u_accounts
from apps.epg.tasks import refresh_all_epg_data
refresh_m3u_accounts.delay()
refresh_all_epg_data.delay()
return {"status": "queued", "message": "Refresh jobs queued"}
return {"status": "error", "message": f"Unknown action: {action}"}
```
---
## Best Practices
- Keep `run` short and schedule heavy operations via Celery tasks.
- Validate and sanitize `params` received from the UI.
- Use database transactions for bulk or related updates.
- Log actionable messages for troubleshooting.
- Only write files under `/data` or `/app/data` paths.
- Treat plugins as trusted code: they run with full app permissions.
---
## Troubleshooting
- Plugin not listed: ensure the folder exists and contains `plugin.py` with a `Plugin` class.
- Import errors: the folder name is the import name; avoid spaces or exotic characters.
- No confirmation: include a boolean field with `id: "confirm"` and set it to true or default true.
- HTTP 403 on run: the plugin is disabled; enable it from the toggle or via the `enabled/` endpoint.
---
## Contributing
- Keep dependencies minimal. Vendoring small helpers into the plugin folder is acceptable.
- Use the existing task and model APIs where possible; propose extensions if you need new capabilities.
---
## Internals Reference
- Loader: `apps/plugins/loader.py`
- API Views: `apps/plugins/api_views.py`
- API URLs: `apps/plugins/api_urls.py`
- Model: `apps/plugins/models.py`
- Frontend page: `frontend/src/pages/Plugins.jsx`
- Sidebar entry: `frontend/src/components/Sidebar.jsx`

View file

@ -25,6 +25,7 @@ urlpatterns = [
path('hdhr/', include(('apps.hdhr.api_urls', 'hdhr'), namespace='hdhr')),
path('m3u/', include(('apps.m3u.api_urls', 'm3u'), namespace='m3u')),
path('core/', include(('core.api_urls', 'core'), namespace='core')),
path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')),
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
#path('player/', include(('apps.player.api_urls', 'player'), namespace='player')),

2
apps/plugins/__init__.py Normal file
View file

@ -0,0 +1,2 @@
default_app_config = "apps.plugins.apps.PluginsConfig"

22
apps/plugins/api_urls.py Normal file
View file

@ -0,0 +1,22 @@
from django.urls import path
from .api_views import (
PluginsListAPIView,
PluginReloadAPIView,
PluginSettingsAPIView,
PluginRunAPIView,
PluginEnabledAPIView,
PluginImportAPIView,
PluginDeleteAPIView,
)
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"),
]

306
apps/plugins/api_views.py Normal file
View file

@ -0,0 +1,306 @@
import logging
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.decorators import api_view
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
import io
import os
import zipfile
import shutil
import tempfile
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_method,
)
from .loader import PluginManager
from .models import PluginConfig
logger = logging.getLogger(__name__)
class PluginsListAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def get(self, request):
pm = PluginManager.get()
# Ensure registry is up-to-date on each request
pm.discover_plugins()
return Response({"plugins": pm.list_plugins()})
class PluginReloadAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def post(self, request):
pm = PluginManager.get()
pm.discover_plugins()
return Response({"success": True, "count": len(pm._registry)})
class PluginImportAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def post(self, request):
file: UploadedFile = request.FILES.get("file")
if not file:
return Response({"success": False, "error": "Missing 'file' upload"}, status=status.HTTP_400_BAD_REQUEST)
pm = PluginManager.get()
plugins_dir = pm.plugins_dir
try:
zf = zipfile.ZipFile(file)
except zipfile.BadZipFile:
return Response({"success": False, "error": "Invalid zip file"}, status=status.HTTP_400_BAD_REQUEST)
# Extract to a temporary directory first to avoid server reload thrash
tmp_root = tempfile.mkdtemp(prefix="plugin_import_")
try:
file_members = [m for m in zf.infolist() if not m.is_dir()]
if not file_members:
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": "Archive is empty"}, status=status.HTTP_400_BAD_REQUEST)
for member in file_members:
name = member.filename
if not name or name.endswith("/"):
continue
# Normalize and prevent path traversal
norm = os.path.normpath(name)
if norm.startswith("..") or os.path.isabs(norm):
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": "Unsafe path in archive"}, status=status.HTTP_400_BAD_REQUEST)
dest_path = os.path.join(tmp_root, norm)
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
with zf.open(member, 'r') as src, open(dest_path, 'wb') as dst:
shutil.copyfileobj(src, dst)
# Find candidate directory containing plugin.py or __init__.py
candidates = []
for dirpath, dirnames, filenames in os.walk(tmp_root):
has_pluginpy = "plugin.py" in filenames
has_init = "__init__.py" in filenames
if has_pluginpy or has_init:
depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep))
candidates.append((0 if has_pluginpy else 1, depth, dirpath))
if not candidates:
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": "Invalid plugin: missing plugin.py or package __init__.py"}, status=status.HTTP_400_BAD_REQUEST)
candidates.sort()
chosen = candidates[0][2]
# Determine plugin key: prefer chosen folder name; if chosen is tmp_root, use zip base name
base_name = os.path.splitext(getattr(file, "name", "plugin"))[0]
plugin_key = os.path.basename(chosen.rstrip(os.sep))
if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep):
plugin_key = base_name
plugin_key = plugin_key.replace(" ", "_").lower()
final_dir = os.path.join(plugins_dir, plugin_key)
if os.path.exists(final_dir):
# If final dir exists but contains a valid plugin, refuse; otherwise clear it
if os.path.exists(os.path.join(final_dir, "plugin.py")) or os.path.exists(os.path.join(final_dir, "__init__.py")):
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": f"Plugin '{plugin_key}' already exists"}, status=status.HTTP_400_BAD_REQUEST)
try:
shutil.rmtree(final_dir)
except Exception:
pass
# Move chosen directory into final location
if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep):
# Move all contents into final_dir
os.makedirs(final_dir, exist_ok=True)
for item in os.listdir(tmp_root):
shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item))
else:
shutil.move(chosen, final_dir)
# Cleanup temp
shutil.rmtree(tmp_root, ignore_errors=True)
target_dir = final_dir
finally:
try:
shutil.rmtree(tmp_root, ignore_errors=True)
except Exception:
pass
# Reload discovery and validate plugin entry
pm.discover_plugins()
plugin = pm._registry.get(plugin_key)
if not plugin:
# Cleanup the copied folder to avoid leaving invalid plugin behind
try:
shutil.rmtree(target_dir, ignore_errors=True)
except Exception:
pass
return Response({"success": False, "error": "Invalid plugin: missing Plugin class in plugin.py or __init__.py"}, status=status.HTTP_400_BAD_REQUEST)
# Extra validation: ensure Plugin.run exists
instance = getattr(plugin, "instance", None)
run_method = getattr(instance, "run", None)
if not callable(run_method):
try:
shutil.rmtree(target_dir, ignore_errors=True)
except Exception:
pass
return Response({"success": False, "error": "Invalid plugin: Plugin class must define a callable run(action, params, context)"}, status=status.HTTP_400_BAD_REQUEST)
# Find DB config to return enabled/ever_enabled
try:
cfg = PluginConfig.objects.get(key=plugin_key)
enabled = cfg.enabled
ever_enabled = getattr(cfg, "ever_enabled", False)
except PluginConfig.DoesNotExist:
enabled = False
ever_enabled = False
return Response({
"success": True,
"plugin": {
"key": plugin.key,
"name": plugin.name,
"version": plugin.version,
"description": plugin.description,
"enabled": enabled,
"ever_enabled": ever_enabled,
"fields": plugin.fields or [],
"actions": plugin.actions or [],
}
})
class PluginSettingsAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def post(self, request, key):
pm = PluginManager.get()
data = request.data or {}
settings = data.get("settings", {})
try:
updated = pm.update_settings(key, settings)
return Response({"success": True, "settings": updated})
except Exception as e:
return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
class PluginRunAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def post(self, request, key):
pm = PluginManager.get()
action = request.data.get("action")
params = request.data.get("params", {})
if not action:
return Response({"success": False, "error": "Missing 'action'"}, status=status.HTTP_400_BAD_REQUEST)
# Respect plugin enabled flag
try:
cfg = PluginConfig.objects.get(key=key)
if not cfg.enabled:
return Response({"success": False, "error": "Plugin is disabled"}, status=status.HTTP_403_FORBIDDEN)
except PluginConfig.DoesNotExist:
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
try:
result = pm.run_action(key, action, params)
return Response({"success": True, "result": result})
except PermissionError as e:
return Response({"success": False, "error": str(e)}, status=status.HTTP_403_FORBIDDEN)
except Exception as e:
logger.exception("Plugin action failed")
return Response({"success": False, "error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class PluginEnabledAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def post(self, request, key):
enabled = request.data.get("enabled")
if enabled is None:
return Response({"success": False, "error": "Missing 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST)
try:
cfg = PluginConfig.objects.get(key=key)
cfg.enabled = bool(enabled)
# Mark that this plugin has been enabled at least once
if cfg.enabled and not cfg.ever_enabled:
cfg.ever_enabled = True
cfg.save(update_fields=["enabled", "ever_enabled", "updated_at"])
return Response({"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled})
except PluginConfig.DoesNotExist:
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
class PluginDeleteAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def delete(self, request, key):
pm = PluginManager.get()
plugins_dir = pm.plugins_dir
target_dir = os.path.join(plugins_dir, key)
# Safety: ensure path inside plugins_dir
abs_plugins = os.path.abspath(plugins_dir) + os.sep
abs_target = os.path.abspath(target_dir)
if not abs_target.startswith(abs_plugins):
return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST)
# Remove files
if os.path.isdir(target_dir):
try:
shutil.rmtree(target_dir)
except Exception as e:
return Response({"success": False, "error": f"Failed to delete plugin files: {e}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Remove DB record
try:
PluginConfig.objects.filter(key=key).delete()
except Exception:
pass
# Reload registry
pm.discover_plugins()
return Response({"success": True})

54
apps/plugins/apps.py Normal file
View file

@ -0,0 +1,54 @@
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")

254
apps/plugins/loader.py Normal file
View file

@ -0,0 +1,254 @@
import importlib
import json
import logging
import os
import sys
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 = ""
module: Any = None
instance: Any = None
fields: List[Dict[str, Any]] = field(default_factory=list)
actions: List[Dict[str, Any]] = field(default_factory=list)
class PluginManager:
"""Singleton manager that discovers and runs plugins from /app/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", "/app/data/plugins")
self._registry: Dict[str, LoadedPlugin] = {}
# 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) -> Dict[str, LoadedPlugin]:
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}")
self._registry.clear()
try:
for entry in sorted(os.listdir(self.plugins_dir)):
path = os.path.join(self.plugins_dir, entry)
if not os.path.isdir(path):
continue
plugin_key = entry.replace(" ", "_").lower()
try:
self._load_plugin(plugin_key, path)
except Exception:
logger.exception(f"Failed to load plugin '{plugin_key}' from {path}")
logger.info(f"Discovered {len(self._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()
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):
# 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
candidate_modules = []
if has_pluginpy:
candidate_modules.append(f"{key}.plugin")
if has_pkg:
candidate_modules.append(key)
module = None
plugin_cls = None
last_error = None
for module_name in candidate_modules:
try:
logger.debug(f"Importing plugin module {module_name}")
module = importlib.import_module(module_name)
plugin_cls = getattr(module, "Plugin", None)
if plugin_cls is not None:
break
else:
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
else:
logger.warning(f"No Plugin class found for {key}; skipping")
return
instance = plugin_cls()
name = getattr(instance, "name", key)
version = getattr(instance, "version", "")
description = getattr(instance, "description", "")
fields = getattr(instance, "fields", [])
actions = getattr(instance, "actions", [])
self._registry[key] = LoadedPlugin(
key=key,
name=name,
version=version,
description=description,
module=module,
instance=instance,
fields=fields,
actions=actions,
)
def _sync_db_with_registry(self):
with transaction.atomic():
for key, lp in self._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
plugins: List[Dict[str, Any]] = []
try:
configs = {c.key: c for c in PluginConfig.objects.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 = {}
# First, include all discovered plugins
for key, lp in self._registry.items():
conf = configs.get(key)
plugins.append(
{
"key": key,
"name": lp.name,
"version": lp.version,
"description": lp.description,
"enabled": conf.enabled if conf else False,
"ever_enabled": getattr(conf, "ever_enabled", False) if conf else False,
"fields": lp.fields or [],
"settings": (conf.settings if conf else {}),
"actions": lp.actions or [],
"missing": False,
}
)
# Then, include any DB-only configs (files missing or failed to load)
discovered_keys = set(self._registry.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,
"enabled": conf.enabled,
"ever_enabled": getattr(conf, "ever_enabled", False),
"fields": [],
"settings": conf.settings or {},
"actions": [],
"missing": True,
}
)
return plugins
def get_plugin(self, key: str) -> Optional[LoadedPlugin]:
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:
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 {}
# Provide a context object to the plugin
context = {
"settings": cfg.settings or {},
"logger": logger,
"actions": {a.get("id"): a for a in (lp.actions or [])},
}
# 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}

View file

@ -0,0 +1,25 @@
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)), # merged change
("ever_enabled", models.BooleanField(default=False)), # merged addition
("settings", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
)
]

19
apps/plugins/models.py Normal file
View file

@ -0,0 +1,19 @@
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)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
return f"{self.name} ({self.key})"

View file

@ -0,0 +1,28 @@
from rest_framework import serializers
class PluginActionSerializer(serializers.Serializer):
id = serializers.CharField()
label = serializers.CharField()
description = serializers.CharField(required=False, allow_blank=True)
class PluginFieldSerializer(serializers.Serializer):
id = serializers.CharField()
label = serializers.CharField()
type = serializers.ChoiceField(choices=["string", "number", "boolean", "select"]) # simple types
default = serializers.JSONField(required=False)
help_text = serializers.CharField(required=False, allow_blank=True)
options = serializers.ListField(child=serializers.DictField(), required=False)
class PluginSerializer(serializers.Serializer):
key = serializers.CharField()
name = serializers.CharField()
version = serializers.CharField(allow_blank=True)
description = serializers.CharField(allow_blank=True)
enabled = serializers.BooleanField()
fields = PluginFieldSerializer(many=True)
settings = serializers.JSONField()
actions = PluginActionSerializer(many=True)

View file

@ -43,6 +43,7 @@ INSTALLED_APPS = [
"corsheaders",
"django_filters",
"django_celery_beat",
"apps.plugins",
]
# EPG Processing optimization settings

View file

@ -14,6 +14,7 @@ import Guide from './pages/Guide';
import Stats from './pages/Stats';
import DVR from './pages/DVR';
import Settings from './pages/Settings';
import PluginsPage from './pages/Plugins';
import Users from './pages/Users';
import LogosPage from './pages/Logos';
import VODsPage from './pages/VODs';
@ -141,6 +142,7 @@ const App = () => {
<Route path="/guide" element={<Guide />} />
<Route path="/dvr" element={<DVR />} />
<Route path="/stats" element={<Stats />} />
<Route path="/plugins" element={<PluginsPage />} />
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} />
<Route path="/logos" element={<LogosPage />} />

View file

@ -460,6 +460,15 @@ export const WebsocketProvider = ({ children }) => {
}
break;
case 'epg_sources_changed':
// A plugin or backend process signaled that the EPG sources changed
try {
await fetchEPGs();
} catch (e) {
console.warn('Failed to refresh EPG sources after change notification:', e);
}
break;
case 'stream_rehash':
// Handle stream rehash progress updates
if (parsedEvent.data.action === 'starting') {

View file

@ -1219,6 +1219,94 @@ export default class API {
}
}
// Plugins API
static async getPlugins() {
try {
const response = await request(`${host}/api/plugins/plugins/`);
return response.plugins || [];
} catch (e) {
errorNotification('Failed to retrieve plugins', e);
}
}
static async reloadPlugins() {
try {
const response = await request(`${host}/api/plugins/plugins/reload/`, {
method: 'POST',
});
return response;
} catch (e) {
errorNotification('Failed to reload plugins', e);
}
}
static async importPlugin(file) {
try {
const form = new FormData();
form.append('file', file);
const response = await request(`${host}/api/plugins/plugins/import/`, {
method: 'POST',
body: form,
});
return response;
} catch (e) {
// Show only the concise error message for plugin import
const msg = (e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed to import plugin';
notifications.show({ title: 'Import failed', message: msg, color: 'red' });
throw e;
}
}
static async deletePlugin(key) {
try {
const response = await request(`${host}/api/plugins/plugins/${key}/delete/`, {
method: 'DELETE',
});
return response;
} catch (e) {
errorNotification('Failed to delete plugin', e);
}
}
static async updatePluginSettings(key, settings) {
try {
const response = await request(
`${host}/api/plugins/plugins/${key}/settings/`,
{
method: 'POST',
body: { settings },
}
);
return response?.settings || {};
} catch (e) {
errorNotification('Failed to update plugin settings', e);
}
}
static async runPluginAction(key, action, params = {}) {
try {
const response = await request(`${host}/api/plugins/plugins/${key}/run/`, {
method: 'POST',
body: { action, params },
});
return response;
} catch (e) {
errorNotification('Failed to run plugin action', e);
}
}
static async setPluginEnabled(key, enabled) {
try {
const response = await request(`${host}/api/plugins/plugins/${key}/enabled/`, {
method: 'POST',
body: { enabled },
});
return response;
} catch (e) {
errorNotification('Failed to update plugin enabled state', e);
}
}
static async checkSetting(values) {
const { id, ...payload } = values;

View file

@ -11,7 +11,7 @@ import {
Copy,
ChartLine,
Video,
Ellipsis,
PlugZap,
LogOut,
User,
FileImage,
@ -112,6 +112,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{ label: 'DVR', icon: <Database size={20} />, path: '/dvr' },
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
{ label: 'Plugins', icon: <PlugZap size={20} />, path: '/plugins' },
{
label: 'Users',
icon: <User size={20} />,

View file

@ -0,0 +1,453 @@
import React, { useEffect, useState } from 'react';
import {
AppShell,
Box,
Alert,
Button,
Card,
Group,
Loader,
Stack,
Switch,
Text,
TextInput,
NumberInput,
Select,
Divider,
ActionIcon,
SimpleGrid,
Modal,
FileInput,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { RefreshCcw, Trash2 } from 'lucide-react';
import API from '../api';
import { notifications } from '@mantine/notifications';
const Field = ({ field, value, onChange }) => {
const common = { label: field.label, description: field.help_text };
const effective = value ?? field.default;
switch (field.type) {
case 'boolean':
return (
<Switch
checked={!!effective}
onChange={(e) => onChange(field.id, e.currentTarget.checked)}
label={field.label}
description={field.help_text}
/>
);
case 'number':
return (
<NumberInput
value={value ?? field.default ?? 0}
onChange={(v) => onChange(field.id, v)}
{...common}
/>
);
case 'select':
return (
<Select
value={(value ?? field.default ?? '') + ''}
data={(field.options || []).map((o) => ({ value: o.value + '', label: o.label }))}
onChange={(v) => onChange(field.id, v)}
{...common}
/>
);
case 'string':
default:
return (
<TextInput
value={value ?? field.default ?? ''}
onChange={(e) => onChange(field.id, e.currentTarget.value)}
{...common}
/>
);
}
};
const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled, onRequireTrust, onRequestDelete }) => {
const [settings, setSettings] = useState(plugin.settings || {});
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [enabled, setEnabled] = useState(!!plugin.enabled);
const [lastResult, setLastResult] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmConfig, setConfirmConfig] = useState({ title: '', message: '', onConfirm: null });
// Keep local enabled state in sync with props (e.g., after import + enable)
React.useEffect(() => {
setEnabled(!!plugin.enabled);
}, [plugin.enabled]);
// Sync settings if plugin changes identity
React.useEffect(() => {
setSettings(plugin.settings || {});
}, [plugin.key]);
const updateField = (id, val) => {
setSettings((prev) => ({ ...prev, [id]: val }));
};
const save = async () => {
setSaving(true);
try {
await onSaveSettings(plugin.key, settings);
notifications.show({ title: 'Saved', message: `${plugin.name} settings updated`, color: 'green' });
} finally {
setSaving(false);
}
};
const missing = plugin.missing;
return (
<Card shadow="sm" radius="md" withBorder style={{ opacity: !missing && enabled ? 1 : 0.6 }}>
<Group justify="space-between" mb="xs" align="center">
<div>
<Text fw={600}>{plugin.name}</Text>
<Text size="sm" c="dimmed">{plugin.description}</Text>
</div>
<Group gap="xs" align="center">
<ActionIcon variant="subtle" color="red" title="Delete plugin" onClick={() => onRequestDelete && onRequestDelete(plugin)}>
<Trash2 size={16} />
</ActionIcon>
<Text size="xs" c="dimmed">v{plugin.version || '1.0.0'}</Text>
<Switch
checked={!missing && enabled}
onChange={async (e) => {
const next = e.currentTarget.checked;
if (next && !plugin.ever_enabled && onRequireTrust) {
const ok = await onRequireTrust(plugin);
if (!ok) {
// Revert
setEnabled(false);
return;
}
}
setEnabled(next);
const resp = await onToggleEnabled(plugin.key, next);
if (next && resp?.ever_enabled) {
plugin.ever_enabled = true;
}
}}
size="xs"
onLabel="On"
offLabel="Off"
disabled={missing}
/>
</Group>
</Group>
{missing && (
<Text size="sm" c="red">
Missing plugin files. Re-import or delete this entry.
</Text>
)}
{!missing && plugin.fields && plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm">
{plugin.fields.map((f) => (
<Field key={f.id} field={f} value={settings?.[f.id]} onChange={updateField} />
))}
<Group>
<Button loading={saving} onClick={save} variant="default" size="xs">Save Settings</Button>
</Group>
</Stack>
)}
{!missing && plugin.actions && plugin.actions.length > 0 && (
<>
<Divider my="sm" />
<Stack gap="xs">
{plugin.actions.map((a) => (
<Group key={a.id} justify="space-between">
<div>
<Text>{a.label}</Text>
{a.description && (
<Text size="sm" c="dimmed">{a.description}</Text>
)}
</div>
<Button
loading={running}
disabled={!enabled}
onClick={async () => {
setRunning(true);
setLastResult(null);
try {
// Determine if confirmation is required from action metadata or fallback field
const actionConfirm = a.confirm;
const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm');
let requireConfirm = false;
let confirmTitle = `Run ${a.label}?`;
let confirmMessage = `You're about to run "${a.label}" from "${plugin.name}".`;
if (actionConfirm) {
if (typeof actionConfirm === 'boolean') {
requireConfirm = actionConfirm;
} else if (typeof actionConfirm === 'object') {
requireConfirm = actionConfirm.required !== false;
if (actionConfirm.title) confirmTitle = actionConfirm.title;
if (actionConfirm.message) confirmMessage = actionConfirm.message;
}
} else if (confirmField) {
const settingVal = settings?.confirm;
const effectiveConfirm = (settingVal !== undefined ? settingVal : confirmField.default) ?? false;
requireConfirm = !!effectiveConfirm;
}
if (requireConfirm) {
await new Promise((resolve) => {
setConfirmConfig({ title: confirmTitle, message: confirmMessage, onConfirm: resolve });
setConfirmOpen(true);
});
}
// Save settings before running to ensure backend uses latest values
try { await onSaveSettings(plugin.key, settings); } catch (e) { /* ignore, run anyway */ }
const resp = await onRunAction(plugin.key, a.id);
if (resp?.success) {
setLastResult(resp.result || {});
const msg = resp.result?.message || 'Plugin action completed';
notifications.show({ title: plugin.name, message: msg, color: 'green' });
} else {
const err = resp?.error || 'Unknown error';
setLastResult({ error: err });
notifications.show({ title: `${plugin.name} error`, message: String(err), color: 'red' });
}
} finally {
setRunning(false);
}
}}
size="xs"
>
{running ? 'Running…' : 'Run'}
</Button>
</Group>
))}
{running && (
<Text size="sm" c="dimmed">Running action please wait</Text>
)}
{!running && lastResult?.file && (
<Text size="sm" c="dimmed">Output: {lastResult.file}</Text>
)}
{!running && lastResult?.error && (
<Text size="sm" c="red">Error: {String(lastResult.error)}</Text>
)}
</Stack>
</>
)}
<Modal opened={confirmOpen} onClose={() => { setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); }} title={confirmConfig.title} centered>
<Stack>
<Text size="sm">{confirmConfig.message}</Text>
<Group justify="flex-end">
<Button variant="default" size="xs" onClick={() => { setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); }}>
Cancel
</Button>
<Button size="xs" onClick={() => { const cb = confirmConfig.onConfirm; setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); cb && cb(true); }}>
Confirm
</Button>
</Group>
</Stack>
</Modal>
</Card>
);
};
export default function PluginsPage() {
const [loading, setLoading] = useState(true);
const [plugins, setPlugins] = useState([]);
const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState(null);
const [importing, setImporting] = useState(false);
const [imported, setImported] = useState(null);
const [enableAfterImport, setEnableAfterImport] = useState(false);
const [trustOpen, setTrustOpen] = useState(false);
const [trustResolve, setTrustResolve] = useState(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
const [uploadNoticeId, setUploadNoticeId] = useState(null);
const load = async () => {
setLoading(true);
try {
const list = await API.getPlugins();
setPlugins(list);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const requireTrust = (plugin) => {
return new Promise((resolve) => {
setTrustResolve(() => resolve);
setTrustOpen(true);
});
};
return (
<AppShell.Main style={{ padding: 16 }}>
<Group justify="space-between" mb="md">
<Text fw={700} size="lg">Plugins</Text>
<Group>
<Button size="xs" variant="light" onClick={() => { setImportOpen(true); setImported(null); setImportFile(null); setEnableAfterImport(false); }}>
Import Plugin
</Button>
<ActionIcon variant="light" onClick={async () => { await API.reloadPlugins(); await load(); }} title="Reload">
<RefreshCcw size={18} />
</ActionIcon>
</Group>
</Group>
{loading ? (
<Loader />
) : (
<>
<SimpleGrid cols={2} spacing="md" verticalSpacing="md" breakpoints={[{ maxWidth: '48em', cols: 1 }]}>
{plugins.map((p) => (
<PluginCard
key={p.key}
plugin={p}
onSaveSettings={API.updatePluginSettings}
onRunAction={API.runPluginAction}
onToggleEnabled={async (key, next) => {
const resp = await API.setPluginEnabled(key, next);
if (resp?.ever_enabled !== undefined) {
setPlugins((prev) => prev.map((pl) => pl.key === key ? { ...pl, ever_enabled: resp.ever_enabled, enabled: resp.enabled } : pl));
} else {
setPlugins((prev) => prev.map((pl) => pl.key === key ? { ...pl, enabled: next } : pl));
}
return resp;
}}
onRequireTrust={requireTrust}
onRequestDelete={(plugin) => { setDeleteTarget(plugin); setDeleteOpen(true); }}
/>
))}
</SimpleGrid>
{plugins.length === 0 && (
<Box>
<Text c="dimmed">No plugins found. Drop a plugin into <code>/app/data/plugins</code> and reload.</Text>
</Box>
)}
</>
)}
{/* Import Plugin Modal */}
<Modal opened={importOpen} onClose={() => setImportOpen(false)} title="Import Plugin" centered>
<Stack>
<Text size="sm" c="dimmed">Upload a ZIP containing your plugin folder or package.</Text>
<Alert color="yellow" variant="light" title="Heads up">
Importing a plugin may briefly restart the backend (you might see a temporary disconnect). Please wait a few seconds and the app will reconnect automatically.
</Alert>
<Dropzone onDrop={(files) => files[0] && setImportFile(files[0])} onReject={() => {}}
maxFiles={1}
accept={['application/zip', 'application/x-zip-compressed', 'application/octet-stream']}
multiple={false}
>
<Group justify="center" mih={80}>
<Text size="sm">Drag and drop plugin .zip here</Text>
</Group>
</Dropzone>
<FileInput placeholder="Select plugin .zip" value={importFile} onChange={setImportFile} accept=".zip" clearable />
<Group justify="flex-end">
<Button variant="default" onClick={() => setImportOpen(false)} size="xs">Close</Button>
<Button size="xs" loading={importing} disabled={!importFile} onClick={async () => {
setImporting(true);
const id = notifications.show({ title: 'Uploading plugin', message: 'Backend may restart; please wait…', loading: true, autoClose: false, withCloseButton: false });
setUploadNoticeId(id);
try {
const resp = await API.importPlugin(importFile);
if (resp?.success && resp.plugin) {
setImported(resp.plugin);
setPlugins((prev) => [resp.plugin, ...prev.filter((p) => p.key !== resp.plugin.key)]);
notifications.update({ id, loading: false, color: 'green', title: 'Imported', message: 'Plugin imported. If the app briefly disconnected, it should be back now.', autoClose: 3000 });
} else {
notifications.update({ id, loading: false, color: 'red', title: 'Import failed', message: (resp?.error || 'Unknown error'), autoClose: 5000 });
}
} catch (e) {
// API.importPlugin already showed a concise error; just update the loading notice
notifications.update({ id, loading: false, color: 'red', title: 'Import failed', message: ((e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed'), autoClose: 5000 });
} finally {
setImporting(false);
setUploadNoticeId(null);
}
}}>Upload</Button>
</Group>
{imported && (
<Box>
<Divider my="sm" />
<Text fw={600}>{imported.name}</Text>
<Text size="sm" c="dimmed">{imported.description}</Text>
<Group justify="space-between" mt="sm" align="center">
<Text size="sm">Enable now</Text>
<Switch size="sm" checked={enableAfterImport} onChange={(e) => setEnableAfterImport(e.currentTarget.checked)} />
</Group>
<Group justify="flex-end" mt="md">
<Button variant="default" size="xs" onClick={() => { setImportOpen(false); setImported(null); setImportFile(null); setEnableAfterImport(false); }}>Done</Button>
<Button size="xs" disabled={!enableAfterImport} onClick={async () => {
if (!imported) return;
let proceed = true;
if (!imported.ever_enabled) {
proceed = await requireTrust(imported);
}
if (proceed) {
const resp = await API.setPluginEnabled(imported.key, true);
if (resp?.success) {
setPlugins((prev) => prev.map((p) => p.key === imported.key ? { ...p, enabled: true, ever_enabled: true } : p));
notifications.show({ title: imported.name, message: 'Plugin enabled', color: 'green' });
}
setImportOpen(false);
setImported(null);
setEnableAfterImport(false);
}
}}>Enable</Button>
</Group>
</Box>
)}
</Stack>
</Modal>
{/* Trust Warning Modal */}
<Modal opened={trustOpen} onClose={() => { setTrustOpen(false); trustResolve && trustResolve(false); }} title="Enable third-party plugins?" centered>
<Stack>
<Text size="sm">
Plugins run server-side code with full access to your Dispatcharr instance and its data. Only enable plugins from developers you trust.
</Text>
<Text size="sm" c="dimmed">
Why: Malicious plugins could read or modify data, call internal APIs, or perform unwanted actions. Review the source or trust the author before enabling.
</Text>
<Group justify="flex-end">
<Button variant="default" size="xs" onClick={() => { setTrustOpen(false); trustResolve && trustResolve(false); }}>Cancel</Button>
<Button size="xs" color="red" onClick={() => { setTrustOpen(false); trustResolve && trustResolve(true); }}>I understand, enable</Button>
</Group>
</Stack>
</Modal>
{/* Delete Plugin Modal */}
<Modal opened={deleteOpen} onClose={() => { setDeleteOpen(false); setDeleteTarget(null); }} title={deleteTarget ? `Delete ${deleteTarget.name}?` : 'Delete Plugin'} centered>
<Stack>
<Text size="sm">This will remove the plugin files and its configuration. This action cannot be undone.</Text>
<Group justify="flex-end">
<Button variant="default" size="xs" onClick={() => { setDeleteOpen(false); setDeleteTarget(null); }}>Cancel</Button>
<Button size="xs" color="red" loading={deleting} onClick={async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const resp = await API.deletePlugin(deleteTarget.key);
if (resp?.success) {
setPlugins((prev) => prev.filter((p) => p.key !== deleteTarget.key));
notifications.show({ title: deleteTarget.name, message: 'Plugin deleted', color: 'green' });
}
setDeleteOpen(false);
setDeleteTarget(null);
} finally {
setDeleting(false);
}
}}>Delete</Button>
</Group>
</Stack>
</Modal>
</AppShell.Main>
);
}