forked from Mirrors/Dispatcharr
commit
3fb8e0ebd1
16 changed files with 1552 additions and 1 deletions
286
Plugins.md
Normal file
286
Plugins.md
Normal 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 plugin’s `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`
|
||||
|
|
@ -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
2
apps/plugins/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
default_app_config = "apps.plugins.apps.PluginsConfig"
|
||||
|
||||
22
apps/plugins/api_urls.py
Normal file
22
apps/plugins/api_urls.py
Normal 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
306
apps/plugins/api_views.py
Normal 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
54
apps/plugins/apps.py
Normal 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
254
apps/plugins/loader.py
Normal 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}
|
||||
25
apps/plugins/migrations/0001_initial.py
Normal file
25
apps/plugins/migrations/0001_initial.py
Normal 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
19
apps/plugins/models.py
Normal 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})"
|
||||
28
apps/plugins/serializers.py
Normal file
28
apps/plugins/serializers.py
Normal 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)
|
||||
|
||||
|
|
@ -43,6 +43,7 @@ INSTALLED_APPS = [
|
|||
"corsheaders",
|
||||
"django_filters",
|
||||
"django_celery_beat",
|
||||
"apps.plugins",
|
||||
]
|
||||
|
||||
# EPG Processing optimization settings
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />,
|
||||
|
|
|
|||
453
frontend/src/pages/Plugins.jsx
Normal file
453
frontend/src/pages/Plugins.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue