Init Plugins

This commit is contained in:
Dispatcharr 2025-09-05 17:10:11 -05:00
parent ca79cc1a1d
commit e9a11588c4
16 changed files with 1015 additions and 1 deletions

268
Plugins.md Normal file
View file

@ -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 plugins `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/<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.
---
## 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`

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"

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

@ -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/<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"),
]

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

@ -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)

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

@ -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")

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

@ -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}

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=True)),
("settings", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
)
]

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

@ -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})"

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

@ -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') {

View file

@ -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;

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,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 (
<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 }) => {
const [settings, setSettings] = useState(plugin.settings || {});
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [enabled, setEnabled] = useState(plugin.enabled !== false);
const [lastResult, setLastResult] = useState(null);
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);
}
};
return (
<Card shadow="sm" radius="md" withBorder style={{ opacity: 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">
<Text size="xs" c="dimmed">v{plugin.version || '1.0.0'}</Text>
<Switch
checked={enabled}
onChange={async (e) => {
const next = e.currentTarget.checked;
setEnabled(next);
await onToggleEnabled(plugin.key, next);
}}
size="xs"
onLabel="On"
offLabel="Off"
/>
</Group>
</Group>
{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>
)}
{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
const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm');
let requireConfirm = false;
if (confirmField) {
const settingVal = settings?.confirm;
const effectiveConfirm = (settingVal !== undefined ? settingVal : confirmField.default) ?? false;
requireConfirm = !!effectiveConfirm;
}
if (requireConfirm) {
const ok = window.confirm(`Run "${a.label}" from "${plugin.name}"?`);
if (!ok) { return; }
}
// 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>
</>
)}
</Card>
);
};
export default function PluginsPage() {
const [loading, setLoading] = useState(true);
const [plugins, setPlugins] = useState([]);
const load = async () => {
setLoading(true);
try {
const list = await API.getPlugins();
setPlugins(list);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
return (
<AppShell.Main style={{ padding: 16 }}>
<Group justify="space-between" mb="md">
<Text fw={700} size="lg">Plugins</Text>
<ActionIcon variant="light" onClick={async () => { await API.reloadPlugins(); await load(); }} title="Reload">
<RefreshCcw size={18} />
</ActionIcon>
</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={API.setPluginEnabled}
/>
))}
</SimpleGrid>
{plugins.length === 0 && (
<Box>
<Text c="dimmed">No plugins found. Drop a plugin into <code>/app/data/plugins</code> and reload.</Text>
</Box>
)}
</>
)}
</AppShell.Main>
);
}