From 5fb281785da80c96266d9b768dfdb79b7c4c576e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 30 Jan 2026 14:16:34 -0600 Subject: [PATCH 1/8] Update workflow to use pyproject.toml and modify Dockerfile for UV integration --- .github/workflows/base-image.yml | 4 +-- .gitignore | 3 +- docker/DispatcharrBase | 27 +++++++++------ pyproject.toml | 56 ++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 pyproject.toml diff --git a/.github/workflows/base-image.yml b/.github/workflows/base-image.yml index d290d49a..e3a8a4ee 100644 --- a/.github/workflows/base-image.yml +++ b/.github/workflows/base-image.yml @@ -6,13 +6,13 @@ on: paths: - 'docker/DispatcharrBase' - '.github/workflows/base-image.yml' - - 'requirements.txt' + - 'pyproject.toml' pull_request: branches: [main, dev] paths: - 'docker/DispatcharrBase' - '.github/workflows/base-image.yml' - - 'requirements.txt' + - 'pyproject.toml' workflow_dispatch: # Allow manual triggering permissions: diff --git a/.gitignore b/.gitignore index 20968f46..6d1becaa 100755 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ debugpy* uwsgi.sock package-lock.json models -.idea \ No newline at end of file +.idea +uv.lock \ No newline at end of file diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase index 149bfffb..d2e8ceaa 100644 --- a/docker/DispatcharrBase +++ b/docker/DispatcharrBase @@ -1,8 +1,11 @@ FROM lscr.io/linuxserver/ffmpeg:latest ENV DEBIAN_FRONTEND=noninteractive +ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy ENV VIRTUAL_ENV=/dispatcharrpy ENV PATH="$VIRTUAL_ENV/bin:$PATH" +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy # --- Install Python 3.13 and build dependencies --- # Note: Hardware acceleration (VA-API, VDPAU, NVENC) already included in base ffmpeg image @@ -18,24 +21,28 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ vlc-bin vlc-plugin-base \ build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build -# --- Create Python virtual environment --- -RUN python3.13 -m venv $VIRTUAL_ENV && $VIRTUAL_ENV/bin/pip install --upgrade pip +# --- Install UV --- +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ -# --- Install Python dependencies --- -COPY requirements.txt /tmp/requirements.txt -RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir -r /tmp/requirements.txt && \ - rm /tmp/requirements.txt +# --- Create Python virtual environment and install dependencies --- +WORKDIR /tmp/build +COPY pyproject.toml /tmp/build/ +COPY version.py /tmp/build/ +COPY README.md /tmp/build/ +RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \ + rm -rf /tmp/build +WORKDIR / # --- Build legacy NumPy wheel for old hardware (store for runtime switching) --- -RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir build && \ +RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \ cd /tmp && \ - $VIRTUAL_ENV/bin/pip download --no-binary numpy --no-deps numpy && \ + $UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \ tar -xzf numpy-*.tar.gz && \ cd numpy-*/ && \ - $VIRTUAL_ENV/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ + $UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ mv dist/*.whl /opt/ && \ cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \ - $VIRTUAL_ENV/bin/pip uninstall -y build + uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip # --- Clean up build dependencies to reduce image size --- RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..9400a55d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "dispatcharr" +description = "Dispatcharr - Stream dispatching and management" +readme = "README.md" +license = "CC-BY-NC-SA-4.0" +requires-python = ">=3.13" +dynamic = ["version"] +dependencies = [ + "Django==5.2.9", + "psycopg2-binary==2.9.11", + "celery[redis]==5.6.0", + "djangorestframework==3.16.1", + "requests==2.32.5", + "psutil==7.1.3", + "pillow", + "drf-spectacular>=0.29.0", + "streamlink", + "python-vlc", + "yt-dlp", + "gevent==25.9.1", + "daphne", + "uwsgi", + "django-cors-headers", + "djangorestframework-simplejwt", + "m3u8", + "rapidfuzz==3.14.3", + "regex", + "tzlocal", + "pytz", + "torch==2.9.1+cpu", + "sentence-transformers==5.2.0", + "channels", + "channels-redis==4.3.0", + "django-filter", + "django-celery-beat", + "lxml==6.0.2", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# PyTorch CPU-only wheels index +[[tool.uv.index]] +url = "https://download.pytorch.org/whl/cpu" +name = "pytorch-cpu" +explicit = true + +[tool.uv.sources] +torch = { index = "pytorch-cpu" } + +[tool.hatch.build.targets.wheel] +packages = ["dispatcharr", "apps", "core"] + +[tool.hatch.version] +path = "version.py" From 7496a9d67f2c6f8a4f2e4a322ae2f32a14aa4d8b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 30 Jan 2026 14:40:23 -0600 Subject: [PATCH 2/8] Remove README.md from .dockerignore to allow Dockerfile COPY --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 296537de..002f1c63 100755 --- a/.dockerignore +++ b/.dockerignore @@ -29,6 +29,5 @@ **/secrets.dev.yaml **/values.dev.yaml LICENSE -README.md data/ docker/data/ From e217960500be87238c82053a66b3eb0a25b46318 Mon Sep 17 00:00:00 2001 From: None Date: Tue, 3 Feb 2026 21:42:06 -0600 Subject: [PATCH 3/8] feat: Add Redis authentication support for modular deployment Add support for authentication when connecting to external Redis instances in modular deployment mode. This enables secure Redis deployments using either password-only authentication (Redis <6) or username + password authentication (Redis 6+ ACL). Changes: - Add REDIS_PASSWORD and REDIS_USER environment variables - Implement URL encoding for special characters in passwords - Update all Redis connection points to support auth - Add comprehensive documentation and examples to docker-compose.yml - Maintains full backward compatibility (empty defaults = no auth) All authentication mechanisms have been fully tested including pasword-only authentication, Redis 6+ ACL authentication with username + password, volume-mounted configuration files, and special character handling. Existing deployments are not effected, authentication support is entirely opt-in using docker-compose. Also, acknowledging the fact that the docker-compose file for modular deployments has been getting out of hand with auth support, the docker-compose.yml file was re-formatted for better visibility in configuration. This seemed like a better way to go than mandating a .env file. --- apps/proxy/ts_proxy/server.py | 4 + apps/proxy/vod_proxy/connection_manager.py | 11 ++- apps/proxy/vod_proxy/views.py | 11 ++- core/utils.py | 8 ++ core/views.py | 10 +- dispatcharr/persistent_lock.py | 10 +- dispatcharr/settings.py | 27 +++++- docker/docker-compose.yml | 108 ++++++++++++++++++--- docker/entrypoint.sh | 4 +- scripts/wait_for_redis.py | 8 +- 10 files changed, 176 insertions(+), 25 deletions(-) diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index db5b3d57..df51744d 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -149,11 +149,15 @@ class ProxyServer: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) pubsub_client = redis.Redis( host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=60, socket_connect_timeout=10, socket_keepalive=True, diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py index ec0bffa5..aafc75cd 100644 --- a/apps/proxy/vod_proxy/connection_manager.py +++ b/apps/proxy/vod_proxy/connection_manager.py @@ -101,7 +101,16 @@ class PersistentVODConnection: redis_host = getattr(settings, 'REDIS_HOST', 'localhost') redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) redis_db = int(getattr(settings, 'REDIS_DB', 0)) - r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True + ) content_length_key = f"vod_content_length:{self.session_id}" stored_length = r.get(content_length_key) if stored_length: diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 2ec95cc3..948604d6 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -333,7 +333,16 @@ class VODStreamView(View): redis_host = getattr(settings, 'REDIS_HOST', 'localhost') redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) redis_db = int(getattr(settings, 'REDIS_DB', 0)) - r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True + ) content_length_key = f"vod_content_length:{session_id}" r.set(content_length_key, total_size, ex=1800) # Store for 30 minutes logger.info(f"[VOD-HEAD] Stored total content length {total_size} for session {session_id}") diff --git a/core/utils.py b/core/utils.py index 5c5a0045..c0e87a42 100644 --- a/core/utils.py +++ b/core/utils.py @@ -55,6 +55,8 @@ class RedisClient: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) # Use standardized settings socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5) @@ -68,6 +70,8 @@ class RedisClient: host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=socket_timeout, socket_connect_timeout=socket_connect_timeout, socket_keepalive=socket_keepalive, @@ -148,6 +152,8 @@ class RedisClient: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) # Use standardized settings but without socket timeouts for PubSub # Important: socket_timeout is None for PubSub operations @@ -161,6 +167,8 @@ class RedisClient: host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=None, # Critical: No timeout for PubSub operations socket_connect_timeout=socket_connect_timeout, socket_keepalive=socket_keepalive, diff --git a/core/views.py b/core/views.py index 5806d63c..082cccc3 100644 --- a/core/views.py +++ b/core/views.py @@ -40,7 +40,15 @@ def stream_view(request, channel_uuid): redis_host = getattr(settings, "REDIS_HOST", "localhost") redis_port = int(getattr(settings, "REDIS_PORT", 6379)) redis_db = int(getattr(settings, "REDIS_DB", "0")) - redis_client = redis.Redis(host=redis_host, port=redis_port, db=redis_db) + redis_password = getattr(settings, "REDIS_PASSWORD", "") + redis_user = getattr(settings, "REDIS_USER", "") + redis_client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None + ) # Retrieve the channel by the provided stream_id. channel = Channel.objects.get(uuid=channel_uuid) diff --git a/dispatcharr/persistent_lock.py b/dispatcharr/persistent_lock.py index 27d480be..2ed72bf4 100644 --- a/dispatcharr/persistent_lock.py +++ b/dispatcharr/persistent_lock.py @@ -78,7 +78,15 @@ if __name__ == "__main__": redis_host = os.environ.get("REDIS_HOST", "localhost") redis_port = int(os.environ.get("REDIS_PORT", 6379)) redis_db = int(os.environ.get("REDIS_DB", 0)) - client = redis.Redis(host=redis_host, port=redis_port, db=redis_db) + redis_password = os.environ.get("REDIS_PASSWORD", "") + redis_user = os.environ.get("REDIS_USER", "") + client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None + ) lock = PersistentLock(client, "lock:example_account", lock_timeout=120) if lock.acquire(): diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 7af6e939..f0ef75a0 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -1,6 +1,7 @@ import os from pathlib import Path from datetime import timedelta +from urllib.parse import quote_plus BASE_DIR = Path(__file__).resolve().parent.parent @@ -8,6 +9,8 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379)) REDIS_DB = os.environ.get("REDIS_DB", "0") +REDIS_USER = os.environ.get("REDIS_USER", "") +REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") # Set DEBUG to True for development, False for production if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true": @@ -119,7 +122,12 @@ CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { - "hosts": [(REDIS_HOST, REDIS_PORT, REDIS_DB)], # Ensure Redis is running + "hosts": ["redis://{redis_auth}{host}:{port}/{db}".format( + redis_auth=f"{quote_plus(REDIS_USER)}:{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD and REDIS_USER else f":{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD else "", + host=REDIS_HOST, + port=REDIS_PORT, + db=REDIS_DB + )], # URL format supports authentication }, }, } @@ -197,8 +205,19 @@ STATICFILES_DIRS = [ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "accounts.User" -# Build default Redis URL from components for Celery -_default_redis_url = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" +# Build default Redis URL from components for Celery with optional authentication +# Build auth string conditionally with URL encoding for special characters +if REDIS_PASSWORD: + encoded_password = quote_plus(REDIS_PASSWORD) + if REDIS_USER: + encoded_user = quote_plus(REDIS_USER) + redis_auth = f"{encoded_user}:{encoded_password}@" + else: + redis_auth = f":{encoded_password}@" +else: + redis_auth = "" + +_default_redis_url = f"redis://{redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_redis_url) CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", CELERY_BROKER_URL) @@ -269,7 +288,7 @@ SIMPLE_JWT = { } # Redis connection settings -REDIS_URL = os.environ.get("REDIS_URL", f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}") +REDIS_URL = os.environ.get("REDIS_URL", _default_redis_url) REDIS_SOCKET_TIMEOUT = 60 # Socket timeout in seconds REDIS_SOCKET_CONNECT_TIMEOUT = 5 # Connection timeout in seconds REDIS_HEALTH_CHECK_INTERVAL = 15 # Health check every 15 seconds diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c8e2d53e..89585fc7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,4 +1,11 @@ +# Dispatcharr - Modular Deployment Configuration +# This compose file runs Dispatcharr in modular mode with separate containers +# for web, celery workers, PostgreSQL database, and Redis cache. + services: + # ============================================================================ + # Web Service + # ============================================================================ web: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_web @@ -9,44 +16,66 @@ services: depends_on: - db - redis + + # --- Environment Configuration --- environment: + # Deployment Mode - DISPATCHARR_ENV=modular + + # PostgreSQL Connection - POSTGRES_HOST=db - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret + + # Redis Connection - REDIS_HOST=redis - REDIS_PORT=6379 + + # Redis Authentication (Optional) + # Uncomment and set if your Redis requires authentication: + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below + + # Logging - DISPATCHARR_LOG_LEVEL=info + # Legacy CPU Support (Optional) # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) - # that lack support for newer baseline CPU features + # that lack support for newer baseline CPU features: #- USE_LEGACY_NUMPY=true + # Process Priority Configuration (Optional) # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) - # Negative values require cap_add: SYS_NICE (uncomment below) + # Negative values require cap_add: SYS_NICE (see below) #- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority) - # + + # --- Advanced Configuration --- # Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0) #cap_add: # - SYS_NICE - # Optional for hardware acceleration + + # --- Hardware Acceleration (Optional) --- + # Uncomment for GPU access (transcoding acceleration) #group_add: # - video - # #- render # Uncomment if your GPU requires it + # #- render # Uncomment if your GPU requires it #devices: # - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API) - # Uncomment the following lines for NVIDIA GPU support - # NVidia GPU support (requires NVIDIA Container Toolkit) + + # NVIDIA GPU Support (requires NVIDIA Container Toolkit) #deploy: # resources: - # reservations: - # devices: - # - driver: nvidia - # count: all - # capabilities: [gpu] + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + # ============================================================================ + # Celery Service - Background task worker + # ============================================================================ celery: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_celery @@ -58,24 +87,47 @@ services: - ./data:/data extra_hosts: - "host.docker.internal:host-gateway" + entrypoint: ["/app/docker/entrypoint.celery.sh"] + + # --- Environment Configuration --- environment: + # Deployment Mode - DISPATCHARR_ENV=modular + + # PostgreSQL Connection - POSTGRES_HOST=db - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret + + # Redis Connection - REDIS_HOST=redis - REDIS_PORT=6379 + + # Redis Authentication (Optional) + # Uncomment and set if your Redis requires authentication: + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below + + # Logging - DISPATCHARR_LOG_LEVEL=info - #- CELERY_NICE_LEVEL=5 #Celery/EPG/Background tasks (default:5, low priority; Range: -20 to 19) + + # Process Priority Configuration (Optional) + #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19) + + # Django Configuration - DJANGO_SETTINGS_MODULE=dispatcharr.settings - PYTHONUNBUFFERED=1 + + # --- Advanced Configuration --- # Uncomment to enable high priority for Celery (required if CELERY_NICE_LEVEL < 0) #cap_add: # - SYS_NICE - entrypoint: ["/app/docker/entrypoint.celery.sh"] + # ============================================================================ + # PostgreSQL + # ============================================================================ db: image: postgres:17 container_name: dispatcharr_db @@ -93,14 +145,42 @@ services: timeout: 5s retries: 5 + # ============================================================================ + # Redis + # ============================================================================ redis: image: redis:latest container_name: dispatcharr_redis + + # --- Authentication Configuration (Optional) --- + # By default, Redis runs without authentication. + # Choose ONE of the following options if authentication is required: + + # Option 1: Password-only authentication (Redis <6 or default user) + #command: ["redis-server", "--requirepass", "your_strong_redis_password"] + + # Option 2: Redis 6+ ACL with username + password (requires custom config file - see Redis documentation for configuration) + #command: ["redis-server", "/etc/redis/redis.conf"] + #volumes: + # - ./redis.conf:/etc/redis/redis.conf:ro + + # --- Health Check Configuration --- healthcheck: + # Default: No authentication test: ["CMD", "redis-cli", "ping"] + + # If using Option 1 (password-only), uncomment this instead: + #test: ["CMD", "redis-cli", "-a", "your_strong_redis_password", "ping"] + + # If using Option 2 (Redis 6+ ACL), uncomment this instead: + #test: ["CMD", "redis-cli", "--user", "your_redis_username", "-a", "your_strong_redis_password", "ping"] + interval: 5s timeout: 5s retries: 5 +# ============================================================================== +# Volumes +# ============================================================================== volumes: postgres_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ead6aadd..8ea47318 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -38,6 +38,8 @@ export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" export REDIS_HOST=${REDIS_HOST:-localhost} export REDIS_PORT=${REDIS_PORT:-6379} export REDIS_DB=${REDIS_DB:-0} +export REDIS_PASSWORD=${REDIS_PASSWORD:-} +export REDIS_USER=${REDIS_USER:-} export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191} export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri' export LD_LIBRARY_PATH='/usr/local/lib' @@ -104,7 +106,7 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL - REDIS_HOST REDIS_PORT REDIS_DB POSTGRES_DIR DISPATCHARR_PORT + REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY ) diff --git a/scripts/wait_for_redis.py b/scripts/wait_for_redis.py index 38737728..0d278150 100644 --- a/scripts/wait_for_redis.py +++ b/scripts/wait_for_redis.py @@ -12,7 +12,7 @@ import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) -def wait_for_redis(host='localhost', port=6379, db=0, max_retries=30, retry_interval=2): +def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', max_retries=30, retry_interval=2): """Wait for Redis to become available""" redis_client = None retry_count = 0 @@ -25,6 +25,8 @@ def wait_for_redis(host='localhost', port=6379, db=0, max_retries=30, retry_inte host=host, port=port, db=db, + password=password if password else None, + username=username if username else None, socket_timeout=2, socket_connect_timeout=2 ) @@ -50,12 +52,14 @@ if __name__ == "__main__": host = os.environ.get('REDIS_HOST', 'localhost') port = int(os.environ.get('REDIS_PORT', 6379)) db = int(os.environ.get('REDIS_DB', 0)) + password = os.environ.get('REDIS_PASSWORD', '') + username = os.environ.get('REDIS_USER', '') max_retries = int(os.environ.get('REDIS_WAIT_RETRIES', 30)) retry_interval = int(os.environ.get('REDIS_WAIT_INTERVAL', 2)) logger.info(f"Starting Redis availability check at {host}:{port}/{db}") - if wait_for_redis(host, port, db, max_retries, retry_interval): + if wait_for_redis(host, port, db, password, username, max_retries, retry_interval): sys.exit(0) else: sys.exit(1) From 78a53e03dbdd2976b9e0bd19079795d3bb3ae2d9 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Thu, 5 Feb 2026 20:05:29 -0600 Subject: [PATCH 4/8] Plugin fixes/updates Added password fields - #616 Fixed multiple GUI issues - #494 Adds some QoL upgrades See CHANGELOG.md for more info --- CHANGELOG.md | 19 + Plugins.md | 250 +++++++- apps/plugins/api_urls.py | 2 + apps/plugins/api_views.py | 236 ++++++-- apps/plugins/loader.py | 573 ++++++++++++++++-- apps/plugins/serializers.py | 25 +- frontend/src/api.js | 1 + frontend/src/components/Field.jsx | 37 +- frontend/src/components/cards/PluginCard.jsx | 152 +++-- frontend/src/pages/Plugins.jsx | 13 +- frontend/src/pages/__tests__/Plugins.test.jsx | 3 + frontend/src/utils/pages/PluginsUtils.js | 5 +- .../pages/__tests__/PluginsUtils.test.js | 18 + 13 files changed, 1192 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffafc56..9de64782 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Plugins.md b/Plugins.md index 62ea0d87..d528f1e4 100644 --- a/Plugins.md +++ b/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. diff --git a/apps/plugins/api_urls.py b/apps/plugins/api_urls.py index a229a07c..5ba85be2 100644 --- a/apps/plugins/api_urls.py +++ b/apps/plugins/api_urls.py @@ -7,6 +7,7 @@ from .api_views import ( PluginEnabledAPIView, PluginImportAPIView, PluginDeleteAPIView, + PluginLogoAPIView, ) app_name = "plugins" @@ -19,4 +20,5 @@ urlpatterns = [ path("plugins//settings/", PluginSettingsAPIView.as_view(), name="settings"), path("plugins//run/", PluginRunAPIView.as_view(), name="run"), path("plugins//enabled/", PluginEnabledAPIView.as_view(), name="enabled"), + path("plugins//logo/", PluginLogoAPIView.as_view(), name="logo"), ] diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py index 0d68fc7d..624dcc4d 100644 --- a/apps/plugins/api_views.py +++ b/apps/plugins/api_views.py @@ -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}) diff --git a/apps/plugins/loader.py b/apps/plugins/loader.py index 5422ae7e..4b53e08b 100644 --- a/apps/plugins/loader.py +++ b/apps/plugins/loader.py @@ -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 diff --git a/apps/plugins/serializers.py b/apps/plugins/serializers.py index cc7b1882..172af265 100644 --- a/apps/plugins/serializers.py +++ b/apps/plugins/serializers.py @@ -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) - diff --git a/frontend/src/api.js b/frontend/src/api.js index 676e97d8..9cc5494c 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1770,6 +1770,7 @@ export default class API { return response?.settings || {}; } catch (e) { errorNotification('Failed to update plugin settings', e); + throw e; } } diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx index 1293bf7b..186ef7e0 100644 --- a/frontend/src/components/Field.jsx +++ b/frontend/src/components/Field.jsx @@ -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 ( +
+ {field.label && ( + + {field.label} + + )} + {description && ( + + {description} + + )} +
+ ); case 'boolean': return ( 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 }) => { 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 ( +