mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Plugin fixes/updates
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
Added password fields - #616 Fixed multiple GUI issues - #494 Adds some QoL upgrades See CHANGELOG.md for more info
This commit is contained in:
parent
b8e1785d0e
commit
78a53e03db
13 changed files with 1192 additions and 142 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -32,6 +32,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- `useWarningsStore` tests for warning suppression functionality
|
||||
- Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810)
|
||||
- EPG auto-matching: Added advanced options to strip prefixes, suffixes, and custom text from channel names to assist matching; default matching behavior and settings remain unchanged (Closes #771) - Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- Plugin logos: if a plugin ZIP includes `logo.png`, it is surfaced in the Plugins UI and shown next to the plugin name.
|
||||
- Plugin manifests (`plugin.json`) for safe metadata discovery, plus legacy warnings and folder-name fallbacks when a manifest is missing.
|
||||
- Plugin stop hooks: Dispatcharr now calls a plugin's optional `stop()` method (or `run("stop")` action) when disabling, deleting, or reloading plugins to allow graceful shutdown.
|
||||
- Plugin action buttons can define `button_label`, `button_variant`, and `button_color` (e.g., Stop in red), falling back to “Run” for older plugins.
|
||||
- Plugin card metadata: plugins can specify `author` and `help_url` in `plugin.json` to show author and docs link in the UI.
|
||||
- Plugin cards can now be expanded/collapsed by clicking the header or chevron to hide settings and actions.
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
@ -58,6 +64,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible.
|
||||
- Backup Scheduler Test: Fixed test to correctly validate that automatic backups are enabled by default with a retention count of 3, matching the actual scheduler defaults. - Thanks [@jcasimir](https://github.com/jcasimir)
|
||||
- Hardened plugin loading to avoid executing plugin code unless the plugin is enabled.
|
||||
- Prevented plugin package names from shadowing standard library or installed modules by namespacing plugin imports with safe aliases.
|
||||
- Added safety limits to plugin ZIP imports (file count and size caps) and sanitized plugin keys derived from uploads.
|
||||
- Enforced strict boolean parsing for plugin enable/disable requests to avoid accidental enables from truthy strings.
|
||||
- Applied plugin field defaults server-side when running actions so plugins receive expected settings even before a user saves.
|
||||
- Plugin settings UI improvements: render `info`/`text` fields, support `input_type: password`, show descriptions/placeholders, surface save failures, and keep settings in sync after refresh.
|
||||
- Disabled plugins now collapse settings/actions to match the closed state before first enable.
|
||||
- Plugin card header controls (delete/version/toggle) now stay right-aligned even with long descriptions.
|
||||
- Improved plugin logo resolution (case-insensitive paths + absolute URLs), fixing dev UI logo loading without a Vite proxy.
|
||||
- Plugin reload now hits the backend, clears module caches across workers, and refreshes the UI so code changes apply without a full backend restart.
|
||||
- Plugin loader now supports `plugin.py` without `__init__.py`, including folders with non-identifier names, by loading modules directly from file paths.
|
||||
- Plugin action handling stabilized: avoids registry race conditions and only shows loading on the active action.
|
||||
- Plugin enable/disable toggles now update immediately without requiring a full page refresh.
|
||||
|
||||
## [0.18.1] - 2026-01-27
|
||||
|
||||
|
|
|
|||
250
Plugins.md
250
Plugins.md
|
|
@ -8,7 +8,43 @@ This document explains how to build, install, and use Python plugins in Dispatch
|
|||
|
||||
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:
|
||||
2) Add a `plugin.json` manifest (new standard) and a `plugin.py` file:
|
||||
|
||||
`/app/data/plugins/my_plugin/plugin.json`
|
||||
```json
|
||||
{
|
||||
"name": "My Plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "Does something useful",
|
||||
"author": "Acme Labs",
|
||||
"help_url": "https://example.com/docs/my-plugin",
|
||||
"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": [
|
||||
{
|
||||
"id": "do_work",
|
||||
"label": "Do Work",
|
||||
"description": "Process items",
|
||||
"button_label": "Run Job",
|
||||
"button_variant": "filled",
|
||||
"button_color": "blue"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
# /app/data/plugins/my_plugin/plugin.py
|
||||
|
|
@ -16,6 +52,8 @@ class Plugin:
|
|||
name = "My Plugin"
|
||||
version = "0.1.0"
|
||||
description = "Does something useful"
|
||||
author = "Acme Labs"
|
||||
help_url = "https://example.com/docs/my-plugin"
|
||||
|
||||
# Settings fields rendered by the UI and persisted by the backend
|
||||
fields = [
|
||||
|
|
@ -31,7 +69,14 @@ class Plugin:
|
|||
|
||||
# Actions appear as buttons. Clicking one calls run(action, params, context)
|
||||
actions = [
|
||||
{"id": "do_work", "label": "Do Work", "description": "Process items"},
|
||||
{
|
||||
"id": "do_work",
|
||||
"label": "Do Work",
|
||||
"description": "Process items",
|
||||
"button_label": "Run Job",
|
||||
"button_variant": "filled",
|
||||
"button_color": "blue",
|
||||
},
|
||||
]
|
||||
|
||||
def run(self, action: str, params: dict, context: dict):
|
||||
|
|
@ -59,8 +104,10 @@ class Plugin:
|
|||
- Each plugin is a directory containing either:
|
||||
- `plugin.py` exporting a `Plugin` class, or
|
||||
- a Python package (`__init__.py`) exporting a `Plugin` class.
|
||||
- New standard: include a `plugin.json` manifest alongside your code for safe metadata discovery.
|
||||
- Optional: include `logo.png` next to `plugin.py` to show a logo in the UI.
|
||||
|
||||
The directory name (lowercased, spaces as `_`) is used as the registry key and module import path (e.g. `my_plugin.plugin`).
|
||||
The directory name (lowercased, spaces as `_`) is used as the registry key. Plugins are imported under a safe internal package name; if the folder name is a valid identifier (and not reserved), it is also registered as an alias for convenience.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -69,7 +116,8 @@ The directory name (lowercased, spaces as `_`) is used as the registry key and m
|
|||
- 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()`.
|
||||
- The loader reads `plugin.json` for metadata without executing plugin code.
|
||||
- Plugin code is only imported and instantiated when the plugin is enabled.
|
||||
- Metadata (name, version, description) and a per-plugin settings JSON are stored in the DB.
|
||||
|
||||
Backend code:
|
||||
|
|
@ -80,6 +128,45 @@ Backend code:
|
|||
|
||||
---
|
||||
|
||||
## Plugin Manifest (`plugin.json`)
|
||||
|
||||
`plugin.json` lets Dispatcharr list your plugin safely without executing code. It should live next to `plugin.py`.
|
||||
|
||||
Example:
|
||||
```
|
||||
{
|
||||
"name": "My Plugin",
|
||||
"version": "1.2.3",
|
||||
"description": "Does something useful",
|
||||
"author": "Acme Labs",
|
||||
"help_url": "https://example.com/docs/my-plugin",
|
||||
"fields": [
|
||||
{ "id": "limit", "label": "Item limit", "type": "number", "default": 5 }
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "do_work",
|
||||
"label": "Do Work",
|
||||
"description": "Process items",
|
||||
"button_label": "Run Job",
|
||||
"button_variant": "filled",
|
||||
"button_color": "blue"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `author` and `help_url` are optional. If provided, the UI shows “By {author}” and a Docs link.
|
||||
- If your plugin includes a `logo.png` file next to `plugin.py`, it will be shown on the plugin card.
|
||||
|
||||
If `plugin.json` is missing or invalid, the plugin is treated as **legacy**:
|
||||
- The name is inferred from the folder name.
|
||||
- `logo.png` still displays if present.
|
||||
- The UI shows a warning asking the developer to upgrade to the new standard.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Interface
|
||||
|
||||
Export a `Plugin` class. Supported attributes and behavior:
|
||||
|
|
@ -87,34 +174,72 @@ Export a `Plugin` class. Supported attributes and behavior:
|
|||
- `name` (str): Human-readable name.
|
||||
- `version` (str): Semantic version string.
|
||||
- `description` (str): Short description.
|
||||
- `author` (str, optional): Author or team name shown on the card.
|
||||
- `help_url` (str, optional): Docs/support link shown on the card.
|
||||
- `fields` (list): Settings schema used by the UI to render controls.
|
||||
- `actions` (list): Available actions; the UI renders a Run button for each.
|
||||
- `actions` (list): Available actions; the UI renders a button for each (defaults to Run).
|
||||
- `run(action, params, context)` (callable): Invoked when a user clicks an action.
|
||||
- `stop(context)` (optional callable): Invoked when the plugin is disabled, deleted, or reloaded so you can gracefully shut down any processes you started. If `stop()` is not defined but you have an action with id `stop`, Dispatcharr will call `run("stop", {}, context)` as a fallback.
|
||||
|
||||
### Settings Schema
|
||||
Supported field `type`s:
|
||||
- `boolean`
|
||||
- `number`
|
||||
- `string`
|
||||
- `string` (single-line text)
|
||||
- `text` (multi-line textarea)
|
||||
- `select` (requires `options`: `[{"value": ..., "label": ...}, ...]`)
|
||||
- `info` (display-only text; useful for headings or notes)
|
||||
|
||||
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.
|
||||
- `help_text` / `description` (str, optional): Shown under the control.
|
||||
- `placeholder` (str, optional): Placeholder text for inputs.
|
||||
- `input_type` (str, optional): For `string` fields, set to `"password"` to mask input.
|
||||
- `options` (list, for select): List of `{value, label}`.
|
||||
|
||||
Notes:
|
||||
- For `info` fields, you can use `description`/`help_text` (or `value`) to show the text.
|
||||
|
||||
The UI automatically renders settings and persists them. The backend stores settings in `PluginConfig.settings`.
|
||||
|
||||
### Example: stop() Hook
|
||||
```
|
||||
import signal
|
||||
|
||||
class Plugin:
|
||||
name = "Example Plugin"
|
||||
version = "1.0.0"
|
||||
description = "Shows how to shut down gracefully."
|
||||
|
||||
def run(self, action: str, params: dict, context: dict):
|
||||
# Start a subprocess or background task here and store its PID.
|
||||
# Example: save pid in /data or in your own module-level variable.
|
||||
return {"status": "ok"}
|
||||
|
||||
def stop(self, context: dict):
|
||||
logger = context.get("logger")
|
||||
pid = self._read_pid() # your helper
|
||||
if pid:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
logger.info("Stopped process %s", pid)
|
||||
except Exception:
|
||||
logger.exception("Failed to stop process %s", pid)
|
||||
```
|
||||
|
||||
Read settings in `run` via `context["settings"]`.
|
||||
|
||||
### Actions
|
||||
Each action is a dict:
|
||||
- `id` (str): Unique action id.
|
||||
- `label` (str): Button label.
|
||||
- `label` (str): Action label.
|
||||
- `description` (str, optional): Helper text.
|
||||
- `button_label` (str, optional): Button text (defaults to “Run”).
|
||||
- `button_variant` (str, optional): Button style (Mantine variants like `filled`, `outline`, `subtle`).
|
||||
- `button_color` (str, optional): Button color (e.g., `red`, `blue`, `orange`).
|
||||
|
||||
Clicking an action calls your plugin’s `run(action, params, context)` and shows a notification with the result or error.
|
||||
|
||||
|
|
@ -182,6 +307,110 @@ Plugins are server-side Python code running within the Django application. You c
|
|||
|
||||
Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking.
|
||||
|
||||
### Important: Don’t Ask Users for URL/User/Password
|
||||
Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the app’s models, tasks, and internal utilities.
|
||||
Plugins **should not** ask users for “Dispatcharr URL”, “Admin Username”, or “Admin Password” just to call the API. That is unnecessary and unsafe because:
|
||||
|
||||
- It encourages users to enter privileged credentials.
|
||||
- Malicious plugins could exfiltrate credentials.
|
||||
- It duplicates access that plugins already have internally.
|
||||
|
||||
If you are writing a plugin, **use internal Python APIs** (models/tasks/utils) instead of making HTTP calls with user credentials.
|
||||
|
||||
### When You Do Need HTTP
|
||||
In rare cases you may need to call a Dispatcharr HTTP endpoint (for example, to reuse an existing API response serializer). In that case:
|
||||
|
||||
1. **Do not ask the user for credentials.**
|
||||
Use the backend’s internal access where possible.
|
||||
|
||||
2. Prefer **local/internal URLs** (never user-provided):
|
||||
- Docker: `http://web:9191` (service name inside the container network)
|
||||
- Dev: `http://127.0.0.1:5656`
|
||||
|
||||
3. Use Django helpers when building URLs:
|
||||
```
|
||||
from django.urls import reverse
|
||||
path = reverse("api:channels:list") # example name
|
||||
url = f"http://127.0.0.1:5656{path}"
|
||||
```
|
||||
|
||||
4. Use a short timeout and robust error handling:
|
||||
```
|
||||
import requests
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
```
|
||||
|
||||
### Examples: Preferred Internal Access (No HTTP, No Credentials)
|
||||
|
||||
**Example 1: List channels directly from the DB**
|
||||
```
|
||||
from apps.channels.models import Channel
|
||||
|
||||
channels = Channel.objects.all().values("id", "name", "number")[:50]
|
||||
return {"status": "ok", "channels": list(channels)}
|
||||
```
|
||||
|
||||
**Example 2: Kick off an existing refresh task**
|
||||
```
|
||||
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"}
|
||||
```
|
||||
|
||||
**Example 3: Send a WebSocket update to the UI**
|
||||
```
|
||||
from core.utils import send_websocket_update
|
||||
|
||||
send_websocket_update(
|
||||
"updates",
|
||||
"update",
|
||||
{"type": "plugin", "plugin": "my_plugin", "message": "Refresh queued"}
|
||||
)
|
||||
```
|
||||
|
||||
### Example: HTTP Access (Only If You Must)
|
||||
|
||||
**Find the endpoint**
|
||||
- Use `reverse()` with the named route when possible.
|
||||
- If you don’t know the route name, inspect `apps/*/api_urls.py` or Django’s URL config to find it.
|
||||
|
||||
```
|
||||
from django.urls import reverse
|
||||
import requests
|
||||
|
||||
path = reverse("api:channels:list") # named route from apps/channels/api_urls.py
|
||||
url = f"http://127.0.0.1:5656{path}"
|
||||
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
```
|
||||
|
||||
### How Developers Find the API
|
||||
|
||||
1. **Prefer internal models/tasks** (best and safest).
|
||||
2. **Check `apps/*/api_urls.py`** for named routes and endpoint patterns.
|
||||
- Example: `apps/channels/api_urls.py` for channel endpoints.
|
||||
3. **Find the view** referenced in the URL config to see required params.
|
||||
- Example: `apps/channels/api_views.py` or `apps/epg/api_views.py`.
|
||||
4. **Use `reverse()`** with the named route to build the path.
|
||||
- This avoids hardcoding paths and keeps plugins compatible if URLs change.
|
||||
5. **Only use internal hostnames** (never user-provided URL).
|
||||
|
||||
### What Plugins Can Access
|
||||
Because plugins run inside the server process, they can:
|
||||
- Read and write database models (same permissions as the app)
|
||||
- Invoke Celery tasks
|
||||
- Send websocket updates
|
||||
- Read configuration and settings
|
||||
|
||||
Treat plugins as **trusted server code** and avoid exposing sensitive data in plugin settings or logs.
|
||||
|
||||
---
|
||||
|
||||
## REST Endpoints (for UI and tooling)
|
||||
|
|
@ -203,7 +432,9 @@ Notes:
|
|||
|
||||
- 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`).
|
||||
- Include `plugin.json` in the plugin folder to provide metadata without executing code.
|
||||
- On success, the UI shows the plugin name/description and lets you enable it immediately (plugins are disabled by default).
|
||||
- If `plugin.json` is missing, the plugin is marked as legacy and the UI will show a warning.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -214,6 +445,7 @@ Notes:
|
|||
- 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.
|
||||
- Dispatcharr will not import or execute plugin code unless the plugin is enabled.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -263,7 +495,7 @@ class Plugin:
|
|||
## 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.
|
||||
- Import errors: ensure the folder contains `plugin.py` or a package `__init__.py`. Folder names with spaces or dashes are supported; if you need to import by folder name inside your plugin, use a valid Python identifier.
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from .api_views import (
|
|||
PluginEnabledAPIView,
|
||||
PluginImportAPIView,
|
||||
PluginDeleteAPIView,
|
||||
PluginLogoAPIView,
|
||||
)
|
||||
|
||||
app_name = "plugins"
|
||||
|
|
@ -19,4 +20,5 @@ urlpatterns = [
|
|||
path("plugins/<str:key>/settings/", PluginSettingsAPIView.as_view(), name="settings"),
|
||||
path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"),
|
||||
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
|
||||
path("plugins/<str:key>/logo/", PluginLogoAPIView.as_view(), name="logo"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import logging
|
||||
import re
|
||||
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
|
||||
from django.http import FileResponse
|
||||
import os
|
||||
import zipfile
|
||||
import shutil
|
||||
import tempfile
|
||||
from urllib.parse import urlparse
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
permission_classes_by_method,
|
||||
)
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
|
||||
from .loader import PluginManager
|
||||
from .models import PluginConfig
|
||||
|
|
@ -21,6 +23,42 @@ from .models import PluginConfig
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MAX_PLUGIN_IMPORT_FILES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILES", 2000)
|
||||
MAX_PLUGIN_IMPORT_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_BYTES", 200 * 1024 * 1024)
|
||||
MAX_PLUGIN_IMPORT_FILE_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILE_BYTES", 200 * 1024 * 1024)
|
||||
|
||||
|
||||
def _parse_bool(value):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int) and value in (0, 1):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ("true", "1", "yes", "y", "on"):
|
||||
return True
|
||||
if normalized in ("false", "0", "no", "n", "off"):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _sanitize_plugin_key(value: str) -> str:
|
||||
base = os.path.basename(value or "")
|
||||
base = base.replace(" ", "_").lower()
|
||||
base = re.sub(r"[^a-z0-9_-]", "_", base)
|
||||
base = base.strip("._-")
|
||||
return base or "plugin"
|
||||
|
||||
|
||||
def _absolutize_logo_url(request, url: str | None) -> str | None:
|
||||
if not url or not request:
|
||||
return url
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme:
|
||||
return url
|
||||
return request.build_absolute_uri(url)
|
||||
|
||||
|
||||
class PluginsListAPIView(APIView):
|
||||
def get_permissions(self):
|
||||
try:
|
||||
|
|
@ -32,9 +70,12 @@ class PluginsListAPIView(APIView):
|
|||
|
||||
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()})
|
||||
# Prefer cached registry; reload explicitly via the reload endpoint
|
||||
pm.discover_plugins(sync_db=False, use_cache=True)
|
||||
plugins = pm.list_plugins()
|
||||
for plugin in plugins:
|
||||
plugin["logo_url"] = _absolutize_logo_url(request, plugin.get("logo_url"))
|
||||
return Response({"plugins": plugins})
|
||||
|
||||
|
||||
class PluginReloadAPIView(APIView):
|
||||
|
|
@ -48,7 +89,8 @@ class PluginReloadAPIView(APIView):
|
|||
|
||||
def post(self, request):
|
||||
pm = PluginManager.get()
|
||||
pm.discover_plugins()
|
||||
pm.stop_all_plugins(reason="reload")
|
||||
pm.discover_plugins(force_reload=True)
|
||||
return Response({"success": True, "count": len(pm._registry)})
|
||||
|
||||
|
||||
|
|
@ -81,6 +123,19 @@ class PluginImportAPIView(APIView):
|
|||
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)
|
||||
if len(file_members) > MAX_PLUGIN_IMPORT_FILES:
|
||||
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||
return Response({"success": False, "error": "Archive has too many files"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
total_size = 0
|
||||
for member in file_members:
|
||||
total_size += member.file_size
|
||||
if member.file_size > MAX_PLUGIN_IMPORT_FILE_BYTES:
|
||||
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||
return Response({"success": False, "error": "Archive contains a file that is too large"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if total_size > MAX_PLUGIN_IMPORT_BYTES:
|
||||
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||
return Response({"success": False, "error": "Archive is too large"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
for member in file_members:
|
||||
name = member.filename
|
||||
|
|
@ -115,7 +170,31 @@ class PluginImportAPIView(APIView):
|
|||
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()
|
||||
plugin_key = _sanitize_plugin_key(plugin_key)
|
||||
if len(plugin_key) > 128:
|
||||
plugin_key = plugin_key[:128]
|
||||
logo_bytes = None
|
||||
try:
|
||||
logo_candidates = []
|
||||
chosen_abs = os.path.abspath(chosen)
|
||||
for dirpath, _, filenames in os.walk(tmp_root):
|
||||
for filename in filenames:
|
||||
if filename.lower() != "logo.png":
|
||||
continue
|
||||
full_path = os.path.join(dirpath, filename)
|
||||
full_abs = os.path.abspath(full_path)
|
||||
try:
|
||||
in_chosen = os.path.commonpath([chosen_abs, full_abs]) == chosen_abs
|
||||
except Exception:
|
||||
in_chosen = False
|
||||
depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep))
|
||||
logo_candidates.append((0 if in_chosen else 1, depth, full_path))
|
||||
if logo_candidates:
|
||||
logo_candidates.sort()
|
||||
with open(logo_candidates[0][2], "rb") as fh:
|
||||
logo_bytes = fh.read()
|
||||
except Exception:
|
||||
logo_bytes = None
|
||||
|
||||
final_dir = os.path.join(plugins_dir, plugin_key)
|
||||
if os.path.exists(final_dir):
|
||||
|
|
@ -136,6 +215,12 @@ class PluginImportAPIView(APIView):
|
|||
shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item))
|
||||
else:
|
||||
shutil.move(chosen, final_dir)
|
||||
if logo_bytes:
|
||||
try:
|
||||
with open(os.path.join(final_dir, "logo.png"), "wb") as fh:
|
||||
fh.write(logo_bytes)
|
||||
except Exception:
|
||||
pass
|
||||
# Cleanup temp
|
||||
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||
target_dir = final_dir
|
||||
|
|
@ -145,49 +230,50 @@ class PluginImportAPIView(APIView):
|
|||
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
|
||||
# Ensure DB config exists (untrusted plugins are registered without loading)
|
||||
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
|
||||
cfg, _ = PluginConfig.objects.get_or_create(
|
||||
key=plugin_key,
|
||||
defaults={
|
||||
"name": plugin_key,
|
||||
"version": "",
|
||||
"description": "",
|
||||
"settings": {},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
cfg = None
|
||||
|
||||
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 [],
|
||||
# Reload discovery to register the plugin (trusted plugins will load)
|
||||
pm.discover_plugins(force_reload=True)
|
||||
plugin_entry = None
|
||||
try:
|
||||
plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == plugin_key), None)
|
||||
except Exception:
|
||||
plugin_entry = None
|
||||
|
||||
if not plugin_entry:
|
||||
logo_path = os.path.join(plugins_dir, plugin_key, "logo.png")
|
||||
logo_url = f"/api/plugins/plugins/{plugin_key}/logo/" if os.path.isfile(logo_path) else None
|
||||
legacy = not os.path.isfile(os.path.join(plugins_dir, plugin_key, "plugin.json"))
|
||||
plugin_entry = {
|
||||
"key": plugin_key,
|
||||
"name": cfg.name if cfg else plugin_key,
|
||||
"version": cfg.version if cfg else "",
|
||||
"description": cfg.description if cfg else "",
|
||||
"enabled": cfg.enabled if cfg else False,
|
||||
"ever_enabled": getattr(cfg, "ever_enabled", False) if cfg else False,
|
||||
"fields": [],
|
||||
"actions": [],
|
||||
"trusted": bool(cfg and (cfg.ever_enabled or cfg.enabled)),
|
||||
"loaded": False,
|
||||
"missing": False,
|
||||
"legacy": legacy,
|
||||
"logo_url": logo_url,
|
||||
}
|
||||
})
|
||||
|
||||
plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url"))
|
||||
return Response({"success": True, "plugin": plugin_entry})
|
||||
|
||||
|
||||
class PluginSettingsAPIView(APIView):
|
||||
|
|
@ -254,21 +340,63 @@ class PluginEnabledAPIView(APIView):
|
|||
return [Authenticated()]
|
||||
|
||||
def post(self, request, key):
|
||||
enabled = request.data.get("enabled")
|
||||
if enabled is None:
|
||||
enabled_raw = request.data.get("enabled")
|
||||
if enabled_raw is None:
|
||||
return Response({"success": False, "error": "Missing 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
enabled = _parse_bool(enabled_raw)
|
||||
if enabled is None:
|
||||
return Response({"success": False, "error": "Invalid 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
cfg = PluginConfig.objects.get(key=key)
|
||||
cfg.enabled = bool(enabled)
|
||||
pm = PluginManager.get()
|
||||
if not enabled and cfg.enabled:
|
||||
try:
|
||||
pm.stop_plugin(key, reason="disable")
|
||||
except Exception:
|
||||
logger.exception("Failed to stop plugin '%s' on disable", key)
|
||||
cfg.enabled = 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})
|
||||
pm.discover_plugins(force_reload=True)
|
||||
plugin_entry = None
|
||||
try:
|
||||
plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == key), None)
|
||||
except Exception:
|
||||
plugin_entry = None
|
||||
response = {"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled}
|
||||
if plugin_entry:
|
||||
plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url"))
|
||||
response["plugin"] = plugin_entry
|
||||
return Response(response)
|
||||
except PluginConfig.DoesNotExist:
|
||||
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class PluginLogoAPIView(APIView):
|
||||
def get_permissions(self):
|
||||
return []
|
||||
|
||||
def get(self, request, key):
|
||||
if not network_access_allowed(request, "UI"):
|
||||
return Response({"success": False, "error": "Network access denied"}, status=status.HTTP_403_FORBIDDEN)
|
||||
pm = PluginManager.get()
|
||||
pm.discover_plugins(use_cache=True)
|
||||
plugins_dir = pm.plugins_dir
|
||||
logo_path = os.path.join(plugins_dir, key, "logo.png")
|
||||
lp = pm.get_plugin(key)
|
||||
if lp and getattr(lp, "path", None):
|
||||
logo_path = os.path.join(lp.path, "logo.png")
|
||||
abs_plugins = os.path.abspath(plugins_dir) + os.sep
|
||||
abs_target = os.path.abspath(logo_path)
|
||||
if not abs_target.startswith(abs_plugins):
|
||||
return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not os.path.isfile(logo_path):
|
||||
return Response({"success": False, "error": "Logo not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return FileResponse(open(logo_path, "rb"), content_type="image/png")
|
||||
|
||||
|
||||
class PluginDeleteAPIView(APIView):
|
||||
def get_permissions(self):
|
||||
try:
|
||||
|
|
@ -280,6 +408,10 @@ class PluginDeleteAPIView(APIView):
|
|||
|
||||
def delete(self, request, key):
|
||||
pm = PluginManager.get()
|
||||
try:
|
||||
pm.stop_plugin(key, reason="delete")
|
||||
except Exception:
|
||||
logger.exception("Failed to stop plugin '%s' before delete", key)
|
||||
plugins_dir = pm.plugins_dir
|
||||
target_dir = os.path.join(plugins_dir, key)
|
||||
# Safety: ensure path inside plugins_dir
|
||||
|
|
@ -302,5 +434,5 @@ class PluginDeleteAPIView(APIView):
|
|||
pass
|
||||
|
||||
# Reload registry
|
||||
pm.discover_plugins()
|
||||
pm.discover_plugins(force_reload=True)
|
||||
return Response({"success": True})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
|
@ -19,10 +23,17 @@ class LoadedPlugin:
|
|||
name: str
|
||||
version: str = ""
|
||||
description: str = ""
|
||||
author: str = ""
|
||||
help_url: str = ""
|
||||
module: Any = None
|
||||
instance: Any = None
|
||||
fields: List[Dict[str, Any]] = field(default_factory=list)
|
||||
actions: List[Dict[str, Any]] = field(default_factory=list)
|
||||
trusted: bool = False
|
||||
loaded: bool = False
|
||||
path: Optional[str] = None
|
||||
folder_name: Optional[str] = None
|
||||
legacy: bool = False
|
||||
|
||||
|
||||
class PluginManager:
|
||||
|
|
@ -39,70 +50,254 @@ class PluginManager:
|
|||
def __init__(self) -> None:
|
||||
self.plugins_dir = os.environ.get("DISPATCHARR_PLUGINS_DIR", "/data/plugins")
|
||||
self._registry: Dict[str, LoadedPlugin] = {}
|
||||
self._package_names: Dict[str, str] = {}
|
||||
self._alias_names: Dict[str, str] = {}
|
||||
self._reload_token_path = os.path.join(self.plugins_dir, ".reload_token")
|
||||
self._last_reload_token = 0.0
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# Ensure plugins directory exists
|
||||
os.makedirs(self.plugins_dir, exist_ok=True)
|
||||
if self.plugins_dir not in sys.path:
|
||||
sys.path.append(self.plugins_dir)
|
||||
|
||||
def discover_plugins(self, *, sync_db: bool = True) -> Dict[str, LoadedPlugin]:
|
||||
def discover_plugins(
|
||||
self,
|
||||
*,
|
||||
sync_db: bool = True,
|
||||
force_reload: bool = False,
|
||||
use_cache: bool = False,
|
||||
) -> Dict[str, LoadedPlugin]:
|
||||
token = self._get_reload_token()
|
||||
if use_cache and not force_reload:
|
||||
with self._lock:
|
||||
if self._registry and token <= self._last_reload_token:
|
||||
return self._registry
|
||||
if token > self._last_reload_token:
|
||||
force_reload = True
|
||||
if force_reload:
|
||||
self._touch_reload_token()
|
||||
token = self._get_reload_token()
|
||||
|
||||
if sync_db:
|
||||
logger.info(f"Discovering plugins in {self.plugins_dir}")
|
||||
else:
|
||||
logger.debug(f"Discovering plugins (no DB sync) in {self.plugins_dir}")
|
||||
self._registry.clear()
|
||||
|
||||
with self._lock:
|
||||
previous_packages = dict(self._package_names)
|
||||
previous_aliases = dict(self._alias_names)
|
||||
previous_paths = {
|
||||
key: lp.path for key, lp in self._registry.items() if lp and lp.path
|
||||
}
|
||||
|
||||
try:
|
||||
configs: Optional[Dict[str, PluginConfig]] = None
|
||||
try:
|
||||
configs = {c.key: c for c in PluginConfig.objects.all()}
|
||||
except Exception:
|
||||
# DB might not be ready; treat all plugins as untrusted
|
||||
configs = None
|
||||
|
||||
new_registry: Dict[str, LoadedPlugin] = {}
|
||||
new_packages: Dict[str, str] = {}
|
||||
new_aliases: Dict[str, str] = {}
|
||||
for entry in sorted(os.listdir(self.plugins_dir)):
|
||||
path = os.path.join(self.plugins_dir, entry)
|
||||
if not os.path.isdir(path):
|
||||
continue
|
||||
|
||||
has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
|
||||
has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
|
||||
if not (has_pkg or has_pluginpy):
|
||||
continue
|
||||
|
||||
plugin_key = entry.replace(" ", "_").lower()
|
||||
alias_name = self._resolve_alias_name(entry, path)
|
||||
|
||||
if force_reload:
|
||||
prev_alias = previous_aliases.get(plugin_key)
|
||||
if prev_alias:
|
||||
self._unload_alias(prev_alias)
|
||||
prev_path = previous_paths.get(plugin_key)
|
||||
if prev_path:
|
||||
self._unload_path_modules(prev_path)
|
||||
|
||||
cfg = configs.get(plugin_key) if configs else None
|
||||
enabled = bool(cfg and cfg.enabled)
|
||||
trusted = bool(cfg and (cfg.ever_enabled or cfg.enabled))
|
||||
|
||||
manifest, has_manifest = self._read_manifest(path)
|
||||
legacy = not has_manifest
|
||||
manifest_name = None
|
||||
manifest_version = None
|
||||
manifest_description = None
|
||||
manifest_author = None
|
||||
manifest_help_url = None
|
||||
manifest_fields: List[Dict[str, Any]] = []
|
||||
manifest_actions: List[Dict[str, Any]] = []
|
||||
if has_manifest and isinstance(manifest, dict):
|
||||
manifest_name = manifest.get("name") if isinstance(manifest.get("name"), str) else None
|
||||
manifest_version = manifest.get("version") if isinstance(manifest.get("version"), str) else None
|
||||
manifest_description = manifest.get("description") if isinstance(manifest.get("description"), str) else None
|
||||
manifest_author = manifest.get("author") if isinstance(manifest.get("author"), str) else None
|
||||
manifest_help_url = manifest.get("help_url") if isinstance(manifest.get("help_url"), str) else None
|
||||
manifest_fields = self._normalize_fields(manifest.get("fields", []))
|
||||
manifest_actions = self._normalize_actions(manifest.get("actions", []))
|
||||
|
||||
display_name = manifest_name or entry
|
||||
display_version = (
|
||||
manifest_version if manifest_version is not None else (cfg.version if cfg else "")
|
||||
)
|
||||
display_description = (
|
||||
manifest_description if manifest_description is not None else (cfg.description if cfg else "")
|
||||
)
|
||||
|
||||
def _make_placeholder() -> LoadedPlugin:
|
||||
return LoadedPlugin(
|
||||
key=plugin_key,
|
||||
name=display_name,
|
||||
version=display_version,
|
||||
description=display_description,
|
||||
author=manifest_author or "",
|
||||
help_url=manifest_help_url or "",
|
||||
fields=manifest_fields if has_manifest else [],
|
||||
actions=manifest_actions if has_manifest else [],
|
||||
trusted=trusted,
|
||||
loaded=False,
|
||||
path=path,
|
||||
folder_name=entry,
|
||||
legacy=legacy,
|
||||
)
|
||||
|
||||
if not enabled:
|
||||
new_registry[plugin_key] = _make_placeholder()
|
||||
continue
|
||||
|
||||
try:
|
||||
self._load_plugin(plugin_key, path)
|
||||
lp, package_name = self._load_plugin(
|
||||
plugin_key,
|
||||
path,
|
||||
folder_name=entry,
|
||||
force_reload=force_reload,
|
||||
previous_package=previous_packages.get(plugin_key),
|
||||
)
|
||||
if lp:
|
||||
if manifest_name and (not lp.name or lp.name == plugin_key):
|
||||
lp.name = manifest_name
|
||||
if manifest_version is not None and not lp.version:
|
||||
lp.version = manifest_version
|
||||
if manifest_description is not None and not lp.description:
|
||||
lp.description = manifest_description
|
||||
if manifest_author is not None and not lp.author:
|
||||
lp.author = manifest_author
|
||||
if manifest_help_url is not None and not lp.help_url:
|
||||
lp.help_url = manifest_help_url
|
||||
if manifest_fields and not lp.fields:
|
||||
lp.fields = manifest_fields
|
||||
if manifest_actions and not lp.actions:
|
||||
lp.actions = manifest_actions
|
||||
lp.trusted = trusted
|
||||
lp.loaded = True
|
||||
lp.path = path
|
||||
lp.folder_name = entry
|
||||
lp.legacy = legacy
|
||||
new_registry[plugin_key] = lp
|
||||
if package_name:
|
||||
new_packages[plugin_key] = package_name
|
||||
if alias_name:
|
||||
new_aliases[plugin_key] = alias_name
|
||||
else:
|
||||
new_registry[plugin_key] = _make_placeholder()
|
||||
except Exception:
|
||||
logger.exception(f"Failed to load plugin '{plugin_key}' from {path}")
|
||||
new_registry[plugin_key] = _make_placeholder()
|
||||
|
||||
logger.info(f"Discovered {len(self._registry)} plugin(s)")
|
||||
if force_reload:
|
||||
# Remove stale modules for plugins that no longer exist
|
||||
removed_keys = set(previous_packages.keys()) - set(new_packages.keys())
|
||||
for key in removed_keys:
|
||||
self._unload_package(previous_packages[key])
|
||||
prev_alias = previous_aliases.get(key)
|
||||
if prev_alias:
|
||||
self._unload_alias(prev_alias)
|
||||
prev_path = previous_paths.get(key)
|
||||
if prev_path:
|
||||
self._unload_path_modules(prev_path)
|
||||
|
||||
with self._lock:
|
||||
self._registry = new_registry
|
||||
self._package_names = new_packages
|
||||
self._alias_names = new_aliases
|
||||
if token > self._last_reload_token:
|
||||
self._last_reload_token = token
|
||||
|
||||
logger.info(f"Discovered {len(new_registry)} plugin(s)")
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Plugins directory not found: {self.plugins_dir}")
|
||||
|
||||
# Sync DB records (optional)
|
||||
if sync_db:
|
||||
try:
|
||||
self._sync_db_with_registry()
|
||||
self._sync_db_with_registry(new_registry if 'new_registry' in locals() else None)
|
||||
except Exception:
|
||||
# Defer sync if database is not ready (e.g., first startup before migrate)
|
||||
logger.exception("Deferring plugin DB sync; database not ready yet")
|
||||
return self._registry
|
||||
|
||||
def _load_plugin(self, key: str, path: str):
|
||||
def _load_plugin(
|
||||
self,
|
||||
key: str,
|
||||
path: str,
|
||||
*,
|
||||
folder_name: str,
|
||||
force_reload: bool,
|
||||
previous_package: Optional[str],
|
||||
) -> tuple[Optional[LoadedPlugin], Optional[str]]:
|
||||
# Plugin can be a package and/or contain plugin.py. Prefer plugin.py when present.
|
||||
has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
|
||||
has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
|
||||
if not (has_pkg or has_pluginpy):
|
||||
logger.debug(f"Skipping {path}: no plugin.py or package")
|
||||
return
|
||||
return None, None
|
||||
|
||||
candidate_modules = []
|
||||
if has_pluginpy:
|
||||
candidate_modules.append(f"{key}.plugin")
|
||||
if has_pkg:
|
||||
candidate_modules.append(key)
|
||||
package_name = self._resolve_package_name(key)
|
||||
alias_name = self._resolve_alias_name(folder_name, path)
|
||||
|
||||
if force_reload and previous_package:
|
||||
self._unload_package(previous_package)
|
||||
|
||||
module = None
|
||||
plugin_cls = None
|
||||
last_error = None
|
||||
for module_name in candidate_modules:
|
||||
|
||||
# Ensure a package context exists for plugin.py (even without __init__.py)
|
||||
if has_pluginpy:
|
||||
self._ensure_namespace_package(package_name, path, alias=alias_name)
|
||||
|
||||
module_name = f"{package_name}.plugin"
|
||||
plugin_path = os.path.join(path, "plugin.py")
|
||||
try:
|
||||
logger.debug(f"Importing plugin module {module_name}")
|
||||
module = importlib.import_module(module_name)
|
||||
logger.debug(f"Importing plugin module {module_name} from {plugin_path}")
|
||||
module = self._load_module_from_path(module_name, plugin_path, is_package=False)
|
||||
if alias_name:
|
||||
self._register_alias_module(f"{alias_name}.plugin", module, path)
|
||||
plugin_cls = getattr(module, "Plugin", None)
|
||||
if plugin_cls is not None:
|
||||
break
|
||||
else:
|
||||
if plugin_cls is None:
|
||||
logger.warning(f"Module {module_name} has no Plugin class")
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.exception(f"Error importing module {module_name}")
|
||||
|
||||
if plugin_cls is None and has_pkg:
|
||||
module_name = package_name
|
||||
init_path = os.path.join(path, "__init__.py")
|
||||
try:
|
||||
logger.debug(f"Importing plugin package {module_name} from {init_path}")
|
||||
module = self._load_module_from_path(module_name, init_path, is_package=True)
|
||||
self._register_alias_module(alias_name, module, path)
|
||||
plugin_cls = getattr(module, "Plugin", None)
|
||||
if plugin_cls is None:
|
||||
logger.warning(f"Module {module_name} has no Plugin class")
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
|
@ -111,32 +306,43 @@ class PluginManager:
|
|||
if plugin_cls is None:
|
||||
if last_error:
|
||||
raise last_error
|
||||
else:
|
||||
logger.warning(f"No Plugin class found for {key}; skipping")
|
||||
return
|
||||
logger.warning(f"No Plugin class found for {key}; skipping")
|
||||
return None, package_name
|
||||
|
||||
instance = plugin_cls()
|
||||
|
||||
name = getattr(instance, "name", key)
|
||||
version = getattr(instance, "version", "")
|
||||
description = getattr(instance, "description", "")
|
||||
author = getattr(instance, "author", "")
|
||||
help_url = getattr(instance, "help_url", "")
|
||||
fields = getattr(instance, "fields", [])
|
||||
actions = getattr(instance, "actions", [])
|
||||
fields = self._normalize_fields(fields)
|
||||
actions = self._normalize_actions(actions)
|
||||
|
||||
self._registry[key] = LoadedPlugin(
|
||||
lp = LoadedPlugin(
|
||||
key=key,
|
||||
name=name,
|
||||
version=version,
|
||||
description=description,
|
||||
author=author or "",
|
||||
help_url=help_url or "",
|
||||
module=module,
|
||||
instance=instance,
|
||||
fields=fields,
|
||||
actions=actions,
|
||||
path=path,
|
||||
folder_name=folder_name,
|
||||
)
|
||||
return lp, package_name
|
||||
|
||||
def _sync_db_with_registry(self):
|
||||
def _sync_db_with_registry(self, registry: Optional[Dict[str, LoadedPlugin]] = None):
|
||||
if registry is None:
|
||||
with self._lock:
|
||||
registry = dict(self._registry)
|
||||
with transaction.atomic():
|
||||
for key, lp in self._registry.items():
|
||||
for key, lp in registry.items():
|
||||
obj, _ = PluginConfig.objects.get_or_create(
|
||||
key=key,
|
||||
defaults={
|
||||
|
|
@ -164,6 +370,8 @@ class PluginManager:
|
|||
from .models import PluginConfig
|
||||
|
||||
plugins: List[Dict[str, Any]] = []
|
||||
with self._lock:
|
||||
registry_snapshot = dict(self._registry)
|
||||
try:
|
||||
configs = {c.key: c for c in PluginConfig.objects.all()}
|
||||
except Exception as e:
|
||||
|
|
@ -172,25 +380,33 @@ class PluginManager:
|
|||
configs = {}
|
||||
|
||||
# First, include all discovered plugins
|
||||
for key, lp in self._registry.items():
|
||||
for key, lp in registry_snapshot.items():
|
||||
conf = configs.get(key)
|
||||
trusted = bool(conf and (conf.ever_enabled or conf.enabled))
|
||||
logo_url = self._get_logo_url(key, path=lp.path)
|
||||
plugins.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": lp.name,
|
||||
"version": lp.version,
|
||||
"description": lp.description,
|
||||
"author": getattr(lp, "author", "") or "",
|
||||
"help_url": getattr(lp, "help_url", "") or "",
|
||||
"enabled": conf.enabled if conf else False,
|
||||
"ever_enabled": 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,
|
||||
"trusted": trusted,
|
||||
"loaded": bool(lp.loaded),
|
||||
"legacy": bool(getattr(lp, "legacy", False)),
|
||||
"logo_url": logo_url,
|
||||
}
|
||||
)
|
||||
|
||||
# Then, include any DB-only configs (files missing or failed to load)
|
||||
discovered_keys = set(self._registry.keys())
|
||||
discovered_keys = set(registry_snapshot.keys())
|
||||
for key, conf in configs.items():
|
||||
if key in discovered_keys:
|
||||
continue
|
||||
|
|
@ -200,19 +416,26 @@ class PluginManager:
|
|||
"name": conf.name,
|
||||
"version": conf.version,
|
||||
"description": conf.description,
|
||||
"author": "",
|
||||
"help_url": "",
|
||||
"enabled": conf.enabled,
|
||||
"ever_enabled": getattr(conf, "ever_enabled", False),
|
||||
"fields": [],
|
||||
"settings": conf.settings or {},
|
||||
"actions": [],
|
||||
"missing": True,
|
||||
"trusted": bool(conf.ever_enabled or conf.enabled),
|
||||
"loaded": False,
|
||||
"legacy": False,
|
||||
"logo_url": self._get_logo_url(key),
|
||||
}
|
||||
)
|
||||
|
||||
return plugins
|
||||
|
||||
def get_plugin(self, key: str) -> Optional[LoadedPlugin]:
|
||||
return self._registry.get(key)
|
||||
with self._lock:
|
||||
return self._registry.get(key)
|
||||
|
||||
def update_settings(self, key: str, settings: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cfg = PluginConfig.objects.get(key=key)
|
||||
|
|
@ -223,19 +446,18 @@ class PluginManager:
|
|||
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")
|
||||
# Attempt a lightweight re-discovery in case the registry was rebuilt
|
||||
self.discover_plugins(sync_db=False, force_reload=False, use_cache=False)
|
||||
lp = self.get_plugin(key)
|
||||
if not lp or not lp.instance:
|
||||
raise ValueError(f"Plugin '{key}' not found")
|
||||
|
||||
cfg = PluginConfig.objects.get(key=key)
|
||||
if not cfg.enabled:
|
||||
raise PermissionError(f"Plugin '{key}' is disabled")
|
||||
params = params or {}
|
||||
|
||||
# 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 [])},
|
||||
}
|
||||
context = self._build_context(lp, cfg)
|
||||
|
||||
# Run either via Celery if plugin provides a delayed method, or inline
|
||||
run_method = getattr(lp.instance, "run", None)
|
||||
|
|
@ -252,3 +474,286 @@ class PluginManager:
|
|||
if isinstance(result, dict):
|
||||
return result
|
||||
return {"status": "ok", "result": result}
|
||||
|
||||
def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool:
|
||||
lp = self.get_plugin(key)
|
||||
if not lp or not lp.instance:
|
||||
return False
|
||||
try:
|
||||
cfg = PluginConfig.objects.get(key=key)
|
||||
except PluginConfig.DoesNotExist:
|
||||
return False
|
||||
if not cfg.enabled:
|
||||
return False
|
||||
|
||||
context = self._build_context(lp, cfg)
|
||||
if reason:
|
||||
context["reason"] = reason
|
||||
|
||||
stop_method = getattr(lp.instance, "stop", None)
|
||||
if callable(stop_method):
|
||||
try:
|
||||
stop_method(context)
|
||||
return True
|
||||
except TypeError:
|
||||
try:
|
||||
stop_method()
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Plugin '%s' stop() failed", key)
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Plugin '%s' stop() failed", key)
|
||||
return False
|
||||
|
||||
run_method = getattr(lp.instance, "run", None)
|
||||
if callable(run_method):
|
||||
actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)}
|
||||
if "stop" in actions:
|
||||
try:
|
||||
run_method("stop", {}, context)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Plugin '%s' stop action failed", key)
|
||||
return False
|
||||
return False
|
||||
|
||||
def stop_all_plugins(self, reason: Optional[str] = None) -> int:
|
||||
stopped = 0
|
||||
with self._lock:
|
||||
registry_snapshot = dict(self._registry)
|
||||
for key in registry_snapshot.keys():
|
||||
if self.stop_plugin(key, reason=reason):
|
||||
stopped += 1
|
||||
return stopped
|
||||
|
||||
def _resolve_package_name(self, key: str) -> str:
|
||||
safe_key = self._safe_module_name(key)
|
||||
return f"_dispatcharr_plugin_{safe_key}"
|
||||
|
||||
def _resolve_alias_name(self, folder_name: str, path: str) -> Optional[str]:
|
||||
if not self._is_valid_identifier(folder_name):
|
||||
return None
|
||||
if self._is_reserved_module_name(folder_name, path):
|
||||
return None
|
||||
return folder_name
|
||||
|
||||
def _is_valid_identifier(self, name: str) -> bool:
|
||||
return re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) is not None
|
||||
|
||||
def _safe_module_name(self, value: str) -> str:
|
||||
safe = re.sub(r"[^0-9A-Za-z_]", "_", value)
|
||||
if not safe or safe[0].isdigit():
|
||||
safe = f"p_{safe}"
|
||||
return safe
|
||||
|
||||
def _normalize_fields(self, fields: Any) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
from .serializers import PluginFieldSerializer
|
||||
except Exception:
|
||||
return fields if isinstance(fields, list) else []
|
||||
if not isinstance(fields, list):
|
||||
return []
|
||||
serializer = PluginFieldSerializer(data=fields, many=True)
|
||||
if serializer.is_valid():
|
||||
return serializer.validated_data
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for item in fields:
|
||||
item_ser = PluginFieldSerializer(data=item)
|
||||
if item_ser.is_valid():
|
||||
normalized.append(item_ser.validated_data)
|
||||
else:
|
||||
logger.warning("Invalid plugin field entry ignored: %s", item_ser.errors)
|
||||
return normalized
|
||||
|
||||
def _normalize_actions(self, actions: Any) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
from .serializers import PluginActionSerializer
|
||||
except Exception:
|
||||
return actions if isinstance(actions, list) else []
|
||||
if not isinstance(actions, list):
|
||||
return []
|
||||
serializer = PluginActionSerializer(data=actions, many=True)
|
||||
if serializer.is_valid():
|
||||
return serializer.validated_data
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for item in actions:
|
||||
item_ser = PluginActionSerializer(data=item)
|
||||
if item_ser.is_valid():
|
||||
normalized.append(item_ser.validated_data)
|
||||
else:
|
||||
logger.warning("Invalid plugin action entry ignored: %s", item_ser.errors)
|
||||
return normalized
|
||||
|
||||
def _merge_settings_with_defaults(self, settings: Dict[str, Any], fields: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
merged = dict(settings or {})
|
||||
for field_def in fields or []:
|
||||
field_id = field_def.get("id")
|
||||
if not field_id:
|
||||
continue
|
||||
if field_id not in merged and "default" in field_def:
|
||||
merged[field_id] = field_def.get("default")
|
||||
return merged
|
||||
|
||||
def _build_context(self, lp: LoadedPlugin, cfg: PluginConfig) -> Dict[str, Any]:
|
||||
settings = self._merge_settings_with_defaults(cfg.settings or {}, lp.fields or [])
|
||||
return {
|
||||
"settings": settings,
|
||||
"logger": logger,
|
||||
"actions": {a.get("id"): a for a in (lp.actions or [])},
|
||||
}
|
||||
|
||||
def _read_manifest(self, path: str) -> tuple[Optional[Dict[str, Any]], bool]:
|
||||
manifest_path = os.path.join(path, "plugin.json")
|
||||
if not os.path.isfile(manifest_path):
|
||||
return None, False
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except Exception:
|
||||
logger.warning("Invalid plugin.json for plugin at %s", path)
|
||||
return None, False
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("plugin.json must be an object for plugin at %s", path)
|
||||
return None, False
|
||||
return data, True
|
||||
|
||||
def _get_logo_url(self, key: str, *, path: Optional[str] = None) -> Optional[str]:
|
||||
logo_path = os.path.join(self.plugins_dir, key, "logo.png")
|
||||
if path:
|
||||
logo_path = os.path.join(path, "logo.png")
|
||||
try:
|
||||
if os.path.isfile(logo_path):
|
||||
return f"/api/plugins/plugins/{key}/logo/"
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _ensure_namespace_package(self, package_name: str, path: str, *, alias: Optional[str] = None) -> None:
|
||||
existing = sys.modules.get(package_name)
|
||||
if existing and getattr(existing, "__path__", None):
|
||||
return
|
||||
pkg = types.ModuleType(package_name)
|
||||
pkg.__path__ = [path]
|
||||
pkg.__package__ = package_name
|
||||
sys.modules[package_name] = pkg
|
||||
self._register_alias_module(alias, pkg, path)
|
||||
|
||||
def _register_alias_module(
|
||||
self,
|
||||
alias_name: Optional[str],
|
||||
module: Any,
|
||||
path: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if not alias_name:
|
||||
return
|
||||
if self._is_reserved_module_name(alias_name, path):
|
||||
return
|
||||
if alias_name in sys.modules:
|
||||
if not force:
|
||||
return
|
||||
self._unload_alias(alias_name)
|
||||
sys.modules[alias_name] = module
|
||||
|
||||
def _is_reserved_module_name(self, name: str, path: str) -> bool:
|
||||
if name in sys.builtin_module_names:
|
||||
return True
|
||||
if hasattr(sys, "stdlib_module_names") and name in sys.stdlib_module_names:
|
||||
return True
|
||||
existing = sys.modules.get(name)
|
||||
if existing:
|
||||
origin = getattr(existing, "__file__", None)
|
||||
if origin is None:
|
||||
return True
|
||||
try:
|
||||
if not os.path.abspath(origin).startswith(os.path.abspath(path)):
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
try:
|
||||
spec = importlib.util.find_spec(name)
|
||||
except Exception:
|
||||
spec = None
|
||||
if spec:
|
||||
if spec.origin is None:
|
||||
return True
|
||||
try:
|
||||
if not os.path.abspath(spec.origin).startswith(os.path.abspath(path)):
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _load_module_from_path(self, module_name: str, path: str, *, is_package: bool) -> Any:
|
||||
importlib.invalidate_caches()
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
path,
|
||||
submodule_search_locations=[os.path.dirname(path)] if is_package else None,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Could not load spec for {module_name} from {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def _get_reload_token(self) -> float:
|
||||
try:
|
||||
return os.path.getmtime(self._reload_token_path)
|
||||
except FileNotFoundError:
|
||||
return 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _touch_reload_token(self) -> None:
|
||||
try:
|
||||
os.makedirs(self.plugins_dir, exist_ok=True)
|
||||
with open(self._reload_token_path, "a", encoding="utf-8"):
|
||||
pass
|
||||
os.utime(self._reload_token_path, None)
|
||||
except Exception:
|
||||
logger.debug("Failed to update plugin reload token", exc_info=True)
|
||||
|
||||
def _unload_package(self, package_name: str) -> None:
|
||||
if not package_name:
|
||||
return
|
||||
for name in list(sys.modules.keys()):
|
||||
if name == package_name or name.startswith(f"{package_name}."):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
def _unload_alias(self, alias_name: str) -> None:
|
||||
if not alias_name:
|
||||
return
|
||||
for name in list(sys.modules.keys()):
|
||||
if name == alias_name or name.startswith(f"{alias_name}."):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
def _unload_path_modules(self, path: str) -> None:
|
||||
if not path:
|
||||
return
|
||||
root = os.path.abspath(path)
|
||||
for name, module in list(sys.modules.items()):
|
||||
if not module:
|
||||
continue
|
||||
mod_path = getattr(module, "__file__", None)
|
||||
if mod_path:
|
||||
try:
|
||||
abs_path = os.path.abspath(mod_path)
|
||||
if abs_path == root or abs_path.startswith(f"{root}{os.sep}"):
|
||||
sys.modules.pop(name, None)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
mod_paths = getattr(module, "__path__", None)
|
||||
if mod_paths:
|
||||
try:
|
||||
for pkg_path in mod_paths:
|
||||
abs_pkg = os.path.abspath(pkg_path)
|
||||
if abs_pkg == root or abs_pkg.startswith(f"{root}{os.sep}"):
|
||||
sys.modules.pop(name, None)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -5,15 +5,31 @@ class PluginActionSerializer(serializers.Serializer):
|
|||
id = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
confirm = serializers.JSONField(required=False)
|
||||
button_label = serializers.CharField(required=False, allow_blank=True)
|
||||
button_variant = serializers.CharField(required=False, allow_blank=True)
|
||||
button_color = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class PluginFieldOptionSerializer(serializers.Serializer):
|
||||
value = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
|
||||
|
||||
class PluginFieldSerializer(serializers.Serializer):
|
||||
id = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
type = serializers.ChoiceField(choices=["string", "number", "boolean", "select"]) # simple types
|
||||
label = serializers.CharField(required=False, allow_blank=True)
|
||||
type = serializers.ChoiceField(choices=["string", "number", "boolean", "select", "text", "info"])
|
||||
default = serializers.JSONField(required=False)
|
||||
help_text = serializers.CharField(required=False, allow_blank=True)
|
||||
options = serializers.ListField(child=serializers.DictField(), required=False)
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
placeholder = serializers.CharField(required=False, allow_blank=True)
|
||||
input_type = serializers.CharField(required=False, allow_blank=True)
|
||||
min = serializers.FloatField(required=False)
|
||||
max = serializers.FloatField(required=False)
|
||||
step = serializers.FloatField(required=False)
|
||||
value = serializers.CharField(required=False, allow_blank=True)
|
||||
options = PluginFieldOptionSerializer(many=True, required=False)
|
||||
|
||||
|
||||
class PluginSerializer(serializers.Serializer):
|
||||
|
|
@ -21,8 +37,9 @@ class PluginSerializer(serializers.Serializer):
|
|||
name = serializers.CharField()
|
||||
version = serializers.CharField(allow_blank=True)
|
||||
description = serializers.CharField(allow_blank=True)
|
||||
author = serializers.CharField(required=False, allow_blank=True)
|
||||
help_url = serializers.CharField(required=False, allow_blank=True)
|
||||
enabled = serializers.BooleanField()
|
||||
fields = PluginFieldSerializer(many=True)
|
||||
settings = serializers.JSONField()
|
||||
actions = PluginActionSerializer(many=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -1770,6 +1770,7 @@ export default class API {
|
|||
return response?.settings || {};
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update plugin settings', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,33 @@
|
|||
import { NumberInput, Select, Switch, TextInput } from '@mantine/core';
|
||||
import { NumberInput, Select, Switch, Text, Textarea, TextInput } from '@mantine/core';
|
||||
import React from 'react';
|
||||
|
||||
export const Field = ({ field, value, onChange }) => {
|
||||
const common = { label: field.label, description: field.help_text };
|
||||
const description = field.help_text ?? field.description ?? field.value;
|
||||
const common = { label: field.label, description };
|
||||
const effective = value ?? field.default;
|
||||
switch (field.type) {
|
||||
case 'info':
|
||||
return (
|
||||
<div>
|
||||
{field.label && (
|
||||
<Text fw={600} size="sm">
|
||||
{field.label}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'boolean':
|
||||
return (
|
||||
<Switch
|
||||
checked={!!effective}
|
||||
onChange={(e) => onChange(field.id, e.currentTarget.checked)}
|
||||
label={field.label}
|
||||
description={field.help_text}
|
||||
description={description}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
|
|
@ -19,6 +35,7 @@ export const Field = ({ field, value, onChange }) => {
|
|||
<NumberInput
|
||||
value={value ?? field.default ?? 0}
|
||||
onChange={(v) => onChange(field.id, v)}
|
||||
placeholder={field.placeholder}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
|
|
@ -31,6 +48,16 @@ export const Field = ({ field, value, onChange }) => {
|
|||
label: o.label,
|
||||
}))}
|
||||
onChange={(v) => onChange(field.id, v)}
|
||||
placeholder={field.placeholder}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<Textarea
|
||||
value={value ?? field.default ?? ''}
|
||||
onChange={(e) => onChange(field.id, e.currentTarget.value)}
|
||||
placeholder={field.placeholder}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
|
|
@ -40,8 +67,10 @@ export const Field = ({ field, value, onChange }) => {
|
|||
<TextInput
|
||||
value={value ?? field.default ?? ''}
|
||||
onChange={(e) => onChange(field.id, e.currentTarget.value)}
|
||||
type={field.input_type === 'password' ? 'password' : 'text'}
|
||||
placeholder={field.placeholder}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { showNotification } from '../../utils/notificationUtils.js';
|
|||
import { Field } from '../Field.jsx';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Box,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
|
|
@ -10,8 +13,9 @@ import {
|
|||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
|
||||
import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js';
|
||||
|
||||
const PluginFieldList = ({ plugin, settings, updateField }) => {
|
||||
|
|
@ -25,7 +29,7 @@ const PluginFieldList = ({ plugin, settings, updateField }) => {
|
|||
));
|
||||
};
|
||||
|
||||
const PluginActionList = ({ plugin, enabled, running, handlePluginRun }) => {
|
||||
const PluginActionList = ({ plugin, enabled, runningActionId, handlePluginRun }) => {
|
||||
return plugin.actions.map((action) => (
|
||||
<Group key={action.id} justify="space-between">
|
||||
<div>
|
||||
|
|
@ -37,12 +41,16 @@ const PluginActionList = ({ plugin, enabled, running, handlePluginRun }) => {
|
|||
)}
|
||||
</div>
|
||||
<Button
|
||||
loading={running}
|
||||
disabled={!enabled}
|
||||
loading={runningActionId === action.id}
|
||||
disabled={!enabled || runningActionId === action.id}
|
||||
onClick={() => handlePluginRun(action)}
|
||||
size="xs"
|
||||
variant={action.button_variant || 'filled'}
|
||||
color={action.button_color}
|
||||
>
|
||||
{running ? 'Running…' : 'Run'}
|
||||
{runningActionId === action.id
|
||||
? 'Running…'
|
||||
: action.button_label || 'Run'}
|
||||
</Button>
|
||||
</Group>
|
||||
));
|
||||
|
|
@ -81,18 +89,24 @@ const PluginCard = ({
|
|||
}) => {
|
||||
const [settings, setSettings] = useState(plugin.settings || {});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runningActionId, setRunningActionId] = useState(null);
|
||||
const [enabled, setEnabled] = useState(!!plugin.enabled);
|
||||
const [lastResult, setLastResult] = useState(null);
|
||||
const [expanded, setExpanded] = useState(!!plugin.enabled);
|
||||
|
||||
// Keep local enabled state in sync with props (e.g., after import + enable)
|
||||
React.useEffect(() => {
|
||||
setEnabled(!!plugin.enabled);
|
||||
}, [plugin.enabled]);
|
||||
React.useEffect(() => {
|
||||
if (!plugin.enabled) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [plugin.enabled]);
|
||||
// Sync settings if plugin changes identity
|
||||
React.useEffect(() => {
|
||||
setSettings(plugin.settings || {});
|
||||
}, [plugin.key]);
|
||||
}, [plugin.key, plugin.settings]);
|
||||
|
||||
const updateField = (id, val) => {
|
||||
setSettings((prev) => ({ ...prev, [id]: val }));
|
||||
|
|
@ -101,11 +115,25 @@ const PluginCard = ({
|
|||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSaveSettings(plugin.key, settings);
|
||||
const result = await onSaveSettings(plugin.key, settings);
|
||||
if (result) {
|
||||
showNotification({
|
||||
title: 'Saved',
|
||||
message: `${plugin.name} settings updated`,
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: `${plugin.name} error`,
|
||||
message: 'Failed to update settings',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification({
|
||||
title: 'Saved',
|
||||
message: `${plugin.name} settings updated`,
|
||||
color: 'green',
|
||||
title: `${plugin.name} error`,
|
||||
message: e?.message || 'Failed to update settings',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
|
@ -125,17 +153,21 @@ const PluginCard = ({
|
|||
return;
|
||||
}
|
||||
}
|
||||
const previous = enabled;
|
||||
setEnabled(next);
|
||||
const resp = await onToggleEnabled(plugin.key, next);
|
||||
if (next && resp?.ever_enabled) {
|
||||
plugin.ever_enabled = true;
|
||||
try {
|
||||
const resp = await onToggleEnabled(plugin.key, next);
|
||||
if (!resp?.success) {
|
||||
setEnabled(previous);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
setEnabled(previous);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handlePluginRun = async (a) => {
|
||||
setRunning(true);
|
||||
setLastResult(null);
|
||||
try {
|
||||
// Determine if confirmation is required from action metadata or fallback field
|
||||
const { requireConfirm, confirmTitle, confirmMessage } =
|
||||
|
|
@ -150,6 +182,9 @@ const PluginCard = ({
|
|||
}
|
||||
}
|
||||
|
||||
setRunningActionId(a.id);
|
||||
setLastResult(null);
|
||||
|
||||
// Save settings before running to ensure backend uses latest values
|
||||
try {
|
||||
await onSaveSettings(plugin.key, settings);
|
||||
|
|
@ -175,25 +210,72 @@ const PluginCard = ({
|
|||
});
|
||||
}
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setRunningActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
opacity={!missing && enabled ? 1 : 0.6}
|
||||
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">
|
||||
<Group justify="space-between" mb="xs" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" align="flex-start" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={toggleExpanded}
|
||||
title={expanded ? 'Collapse settings' : 'Expand settings'}
|
||||
>
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</ActionIcon>
|
||||
{plugin.logo_url && (
|
||||
<Avatar
|
||||
src={plugin.logo_url}
|
||||
radius="sm"
|
||||
size={44}
|
||||
alt={`${plugin.name} logo`}
|
||||
/>
|
||||
)}
|
||||
<UnstyledButton
|
||||
onClick={toggleExpanded}
|
||||
style={{ minWidth: 0, flex: 1, textAlign: 'left' }}
|
||||
>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={600}>{plugin.name}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{plugin.description}
|
||||
</Text>
|
||||
{(plugin.author || plugin.help_url) && (
|
||||
<Group gap="xs" mt={2}>
|
||||
{plugin.author && (
|
||||
<Text size="xs" c="dimmed">
|
||||
By {plugin.author}
|
||||
</Text>
|
||||
)}
|
||||
{plugin.help_url && (
|
||||
<Anchor
|
||||
href={plugin.help_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Docs
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<Group gap="xs" align="center" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
|
|
@ -216,13 +298,15 @@ const PluginCard = ({
|
|||
</Group>
|
||||
</Group>
|
||||
|
||||
{missing && (
|
||||
<Text size="sm" c="red">
|
||||
Missing plugin files. Re-import or delete this entry.
|
||||
{(missing || plugin.legacy) && (
|
||||
<Text size="sm" c={missing ? 'red' : 'yellow'}>
|
||||
{missing
|
||||
? 'Missing plugin files. Re-import or delete this entry.'
|
||||
: 'Please update or ask the developer to add plugin.json.'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!missing && plugin.fields && plugin.fields.length > 0 && (
|
||||
{expanded && !missing && enabled && plugin.fields && plugin.fields.length > 0 && (
|
||||
<Stack gap="xs" mt="sm">
|
||||
<PluginFieldList
|
||||
plugin={plugin}
|
||||
|
|
@ -237,17 +321,17 @@ const PluginCard = ({
|
|||
</Stack>
|
||||
)}
|
||||
|
||||
{!missing && plugin.actions && plugin.actions.length > 0 && (
|
||||
{expanded && !missing && enabled && plugin.actions && plugin.actions.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Stack gap="xs">
|
||||
<PluginActionList
|
||||
plugin={plugin}
|
||||
enabled={enabled}
|
||||
running={running}
|
||||
runningActionId={runningActionId}
|
||||
handlePluginRun={handlePluginRun}
|
||||
/>
|
||||
<PluginActionStatus running={running} lastResult={lastResult} />
|
||||
<PluginActionStatus running={!!runningActionId} lastResult={lastResult} />
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -255,4 +339,4 @@ const PluginCard = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default PluginCard;
|
||||
export default PluginCard;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { usePluginStore } from '../store/plugins.jsx';
|
|||
import {
|
||||
deletePluginByKey,
|
||||
importPlugin,
|
||||
reloadPlugins,
|
||||
runPluginAction,
|
||||
setPluginEnabled,
|
||||
updatePluginSettings,
|
||||
|
|
@ -52,11 +53,13 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
|
|||
const resp = await setPluginEnabled(key, next);
|
||||
|
||||
if (resp?.success) {
|
||||
usePluginStore.getState().updatePlugin(key, {
|
||||
const updates = resp?.plugin || {
|
||||
enabled: next,
|
||||
ever_enabled: resp?.ever_enabled,
|
||||
});
|
||||
};
|
||||
usePluginStore.getState().updatePlugin(key, updates);
|
||||
}
|
||||
return resp;
|
||||
};
|
||||
|
||||
if (loading && plugins.length === 0) {
|
||||
|
|
@ -120,7 +123,8 @@ export default function PluginsPage() {
|
|||
resolve: null,
|
||||
});
|
||||
|
||||
const handleReload = () => {
|
||||
const handleReload = async () => {
|
||||
await reloadPlugins();
|
||||
usePluginStore.getState().invalidatePlugins();
|
||||
};
|
||||
|
||||
|
|
@ -212,7 +216,8 @@ export default function PluginsPage() {
|
|||
if (proceed) {
|
||||
const resp = await setPluginEnabled(imported.key, true);
|
||||
if (resp?.success) {
|
||||
usePluginStore.getState().updatePlugin(imported.key, { enabled: true, ever_enabled: true });
|
||||
const updates = resp?.plugin || { enabled: true, ever_enabled: true };
|
||||
usePluginStore.getState().updatePlugin(imported.key, updates);
|
||||
|
||||
showNotification({
|
||||
title: imported.name,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { showNotification, updateNotification } from '../../utils/notificationUt
|
|||
import {
|
||||
deletePluginByKey,
|
||||
importPlugin,
|
||||
reloadPlugins,
|
||||
setPluginEnabled,
|
||||
updatePluginSettings,
|
||||
} from '../../utils/pages/PluginsUtils';
|
||||
|
|
@ -15,6 +16,7 @@ vi.mock('../../store/plugins');
|
|||
vi.mock('../../utils/pages/PluginsUtils', () => ({
|
||||
deletePluginByKey: vi.fn(),
|
||||
importPlugin: vi.fn(),
|
||||
reloadPlugins: vi.fn(),
|
||||
setPluginEnabled: vi.fn(),
|
||||
updatePluginSettings: vi.fn(),
|
||||
runPluginAction: vi.fn(),
|
||||
|
|
@ -554,6 +556,7 @@ describe('PluginsPage', () => {
|
|||
fireEvent.click(reloadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reloadPlugins).toHaveBeenCalled();
|
||||
expect(invalidatePlugins).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ export const setPluginEnabled = async (key, next) => {
|
|||
export const importPlugin = async (importFile) => {
|
||||
return await API.importPlugin(importFile);
|
||||
};
|
||||
export const reloadPlugins = async () => {
|
||||
return await API.reloadPlugins();
|
||||
};
|
||||
export const deletePluginByKey = (key) => {
|
||||
return API.deletePlugin(key);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ vi.mock('../../../api.js', () => ({
|
|||
runPluginAction: vi.fn(),
|
||||
setPluginEnabled: vi.fn(),
|
||||
importPlugin: vi.fn(),
|
||||
reloadPlugins: vi.fn(),
|
||||
deletePlugin: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
|
@ -112,6 +113,23 @@ describe('PluginsUtils', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('reloadPlugins', () => {
|
||||
it('should call API reloadPlugins', async () => {
|
||||
await PluginsUtils.reloadPlugins();
|
||||
|
||||
expect(API.reloadPlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return API response', async () => {
|
||||
const mockResponse = { success: true };
|
||||
API.reloadPlugins.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await PluginsUtils.reloadPlugins();
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPluginEnabled', () => {
|
||||
it('should call API setPluginEnabled with key and next value', async () => {
|
||||
const key = 'test-plugin';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue