From e9a11588c4d2d18c1a82fbe7b8c6de3a94d1e743 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Fri, 5 Sep 2025 17:10:11 -0500 Subject: [PATCH 1/3] Init Plugins --- Plugins.md | 268 ++++++++++++++++++++++++ apps/api/urls.py | 1 + apps/plugins/__init__.py | 2 + apps/plugins/api_urls.py | 18 ++ apps/plugins/api_views.py | 121 +++++++++++ apps/plugins/apps.py | 19 ++ apps/plugins/loader.py | 199 ++++++++++++++++++ apps/plugins/migrations/0001_initial.py | 25 +++ apps/plugins/models.py | 18 ++ apps/plugins/serializers.py | 28 +++ dispatcharr/settings.py | 1 + frontend/src/App.jsx | 2 + frontend/src/WebSocket.jsx | 9 + frontend/src/api.js | 60 ++++++ frontend/src/components/Sidebar.jsx | 3 +- frontend/src/pages/Plugins.jsx | 242 +++++++++++++++++++++ 16 files changed, 1015 insertions(+), 1 deletion(-) create mode 100644 Plugins.md create mode 100644 apps/plugins/__init__.py create mode 100644 apps/plugins/api_urls.py create mode 100644 apps/plugins/api_views.py create mode 100644 apps/plugins/apps.py create mode 100644 apps/plugins/loader.py create mode 100644 apps/plugins/migrations/0001_initial.py create mode 100644 apps/plugins/models.py create mode 100644 apps/plugins/serializers.py create mode 100644 frontend/src/pages/Plugins.jsx diff --git a/Plugins.md b/Plugins.md new file mode 100644 index 00000000..1047d7f5 --- /dev/null +++ b/Plugins.md @@ -0,0 +1,268 @@ +# 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. + +### Confirmation Prompt +If you want the UI to ask for confirmation before executing an action, include a boolean field with id `confirm` set to `True` (or default `True`). The UI checks it and prompts using a confirm dialog. + +Example: +``` +fields = [ + {"id": "confirm", "label": "Require confirmation", "type": "boolean", "default": True}, +] +``` + +--- + +## 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/` +- Update settings: `POST /api/plugins/plugins//settings/` with `{"settings": {...}}` +- Run action: `POST /api/plugins/plugins//run/` with `{"action": "id", "params": {...}}` +- Enable/disable: `POST /api/plugins/plugins//enabled/` with `{"enabled": true|false}` + +Notes: +- When disabled, a plugin cannot run actions; backend returns HTTP 403. + +--- + +## Enabling / Disabling Plugins + +- Each plugin has a persisted `enabled` flag in the DB (`apps/plugins/models.py`). +- 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` + diff --git a/apps/api/urls.py b/apps/api/urls.py index 3de2e560..7d9edb52 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -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')), diff --git a/apps/plugins/__init__.py b/apps/plugins/__init__.py new file mode 100644 index 00000000..22c35396 --- /dev/null +++ b/apps/plugins/__init__.py @@ -0,0 +1,2 @@ +default_app_config = "apps.plugins.apps.PluginsConfig" + diff --git a/apps/plugins/api_urls.py b/apps/plugins/api_urls.py new file mode 100644 index 00000000..cd2039d9 --- /dev/null +++ b/apps/plugins/api_urls.py @@ -0,0 +1,18 @@ +from django.urls import path +from .api_views import ( + PluginsListAPIView, + PluginReloadAPIView, + PluginSettingsAPIView, + PluginRunAPIView, + PluginEnabledAPIView, +) + +app_name = "plugins" + +urlpatterns = [ + path("plugins/", PluginsListAPIView.as_view(), name="list"), + path("plugins/reload/", PluginReloadAPIView.as_view(), name="reload"), + path("plugins//settings/", PluginSettingsAPIView.as_view(), name="settings"), + path("plugins//run/", PluginRunAPIView.as_view(), name="run"), + path("plugins//enabled/", PluginEnabledAPIView.as_view(), name="enabled"), +] diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py new file mode 100644 index 00000000..1cb72078 --- /dev/null +++ b/apps/plugins/api_views.py @@ -0,0 +1,121 @@ +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 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 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) + cfg.save(update_fields=["enabled", "updated_at"]) + return Response({"success": True, "enabled": cfg.enabled}) + except PluginConfig.DoesNotExist: + return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND) diff --git a/apps/plugins/apps.py b/apps/plugins/apps.py new file mode 100644 index 00000000..4cca8f6e --- /dev/null +++ b/apps/plugins/apps.py @@ -0,0 +1,19 @@ +from django.apps import AppConfig + + +class PluginsConfig(AppConfig): + name = "apps.plugins" + verbose_name = "Plugins" + + def ready(self): + # Perform plugin discovery on startup + try: + from .loader import PluginManager + + PluginManager.get().discover_plugins() + except Exception: + # Avoid breaking startup due to plugin errors + import logging + + logging.getLogger(__name__).exception("Plugin discovery failed during app ready") + diff --git a/apps/plugins/loader.py b/apps/plugins/loader.py new file mode 100644 index 00000000..80864688 --- /dev/null +++ b/apps/plugins/loader.py @@ -0,0 +1,199 @@ +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) -> Dict[str, LoadedPlugin]: + logger.info(f"Discovering plugins 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 + self._sync_db_with_registry() + return self._registry + + def _load_plugin(self, key: str, path: str): + # Plugin can be a package or contain plugin.py + module_name = None + if os.path.exists(os.path.join(path, "__init__.py")): + module_name = key + elif os.path.exists(os.path.join(path, "plugin.py")): + module_name = f"{key}.plugin" + else: + logger.debug(f"Skipping {path}: no plugin.py or package") + return + + logger.debug(f"Importing plugin module {module_name}") + module = importlib.import_module(module_name) + + # Expect a class named Plugin in the module + plugin_cls = getattr(module, "Plugin", None) + if plugin_cls is None: + logger.warning(f"Module {module_name} has no Plugin class; 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 = [] + configs = {c.key: c for c in PluginConfig.objects.all()} + 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 True, + "fields": lp.fields or [], + "settings": (conf.settings if conf else {}), + "actions": lp.actions or [], + } + ) + 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} diff --git a/apps/plugins/migrations/0001_initial.py b/apps/plugins/migrations/0001_initial.py new file mode 100644 index 00000000..2fc99949 --- /dev/null +++ b/apps/plugins/migrations/0001_initial.py @@ -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=True)), + ("settings", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + ) + ] + diff --git a/apps/plugins/models.py b/apps/plugins/models.py new file mode 100644 index 00000000..e8e2125b --- /dev/null +++ b/apps/plugins/models.py @@ -0,0 +1,18 @@ +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=True) + 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})" + diff --git a/apps/plugins/serializers.py b/apps/plugins/serializers.py new file mode 100644 index 00000000..cc7b1882 --- /dev/null +++ b/apps/plugins/serializers.py @@ -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) + diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 040e9156..98dc4f8c 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -43,6 +43,7 @@ INSTALLED_APPS = [ "corsheaders", "django_filters", "django_celery_beat", + "apps.plugins", ] # EPG Processing optimization settings diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 04555488..4b701533 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index f8bd7f4c..2ca8a43c 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -454,6 +454,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') { diff --git a/frontend/src/api.js b/frontend/src/api.js index 71b2d692..efdff7e3 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1219,6 +1219,66 @@ 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 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?.enabled; + } catch (e) { + errorNotification('Failed to update plugin enabled state', e); + } + } + static async checkSetting(values) { const { id, ...payload } = values; diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 6f854d2b..143d01ab 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -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: , path: '/guide' }, { label: 'DVR', icon: , path: '/dvr' }, { label: 'Stats', icon: , path: '/stats' }, + { label: 'Plugins', icon: , path: '/plugins' }, { label: 'Users', icon: , diff --git a/frontend/src/pages/Plugins.jsx b/frontend/src/pages/Plugins.jsx new file mode 100644 index 00000000..d20517b8 --- /dev/null +++ b/frontend/src/pages/Plugins.jsx @@ -0,0 +1,242 @@ +import React, { useEffect, useState } from 'react'; +import { + AppShell, + Box, + Button, + Card, + Group, + Loader, + Stack, + Switch, + Text, + TextInput, + NumberInput, + Select, + Divider, + ActionIcon, + SimpleGrid, +} from '@mantine/core'; +import { RefreshCcw } 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 ( + onChange(field.id, e.currentTarget.checked)} + label={field.label} + description={field.help_text} + /> + ); + case 'number': + return ( + onChange(field.id, v)} + {...common} + /> + ); + case 'select': + return ( +