From e1c939799b146fdf1f952b2002d3dbbf5bae193e Mon Sep 17 00:00:00 2001 From: bramval Date: Thu, 12 Feb 2026 11:22:47 +0100 Subject: [PATCH] chore: production docker + compose for LAN deployment --- .dockerignore | 17 ++++++++++++++ .gitignore | 3 +++ Dockerfile | 45 +++++++++++++++++++++++++++++++++++ README.md | 43 ++++++++++++++++++++++++++++++++++ app/__init__.py | 5 ++-- config.py | 10 ++++++-- docker-compose.yml | 58 ++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 ++ wsgi.py | 39 +++++++++++++++++++++++++++++++ 9 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 wsgi.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..397cab3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git/ +.venv/ +__pycache__/ +*.pyc +node_modules/ + +# local runtime state +logs/ +*.log +syncplayer.db +*.db + +# media uploads should be mounted via volume +media/ + +*.md +.vscode/ diff --git a/.gitignore b/.gitignore index b52adc3..b7a5044 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ syncplayer.db media/* !media/idle_image.png +# Docker compose persistent data directory +data/ + # OS / IDE .DS_Store Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..22de21b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# System deps (kept minimal). We install curl for a simple container healthcheck. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -r -u 10001 -g root syncplayer + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy app code +COPY . . + +# Default persistent directories (can be mounted). +ENV DATA_DIR=/data \ + MEDIA_DIR=/data/media \ + LOG_DIR=/data/logs \ + HOST=0.0.0.0 \ + PORT=5000 \ + ASYNC_MODE=eventlet + +RUN mkdir -p /data/media /data/logs \ + && chown -R syncplayer:root /data + +USER syncplayer + +EXPOSE 5000 + +# Basic HTTP healthcheck (admin redirect) +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:${PORT}/ >/dev/null || exit 1 + +# Gunicorn + eventlet is a solid choice for Flask-SocketIO. +# We intentionally run a single worker to avoid starting multiple UDP listeners. +CMD ["gunicorn", "-k", "eventlet", "-w", "1", "-b", "0.0.0.0:5000", "wsgi:app"] diff --git a/README.md b/README.md index 2a4830c..e08c1f2 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,49 @@ Open display pages: - `http://:5000/display/` +## Docker (production-ready) + +This repo includes a `Dockerfile` and `docker-compose.yml` suitable for LAN deployments. + +### Docker networking for LAN + UDP triggers + +SyncPlayer listens on **UDP ports configured per Event**. + +Docker has two workable strategies: + +1) **Publish a UDP port range** (works on Docker Desktop + Linux) + - The default `syncplayer` service publishes `7000-7999/udp`. + - Keep your Event UDP ports within that range. + +2) **Host networking** (Linux only) + - Use `docker compose --profile hostnet up -d --build` + - Dynamic UDP ports work without pre-declaring a range. + - Discovery/casting protocols (mDNS/SSDP/etc.) also work best here. + +In both cases HTTP + Socket.IO is reachable at `http://:5000`. + +### Run + +```bash +docker compose up -d --build +``` + +Data is persisted in `./data/` on the host: + +- `./data/syncplayer.db` +- `./data/media/` (uploaded videos + idle image) +- `./data/logs/system.log` + +Then open: + +- Admin: `http://:5000/admin/` +- Displays: `http://:5000/display/` + +### Notes + +- Default Gunicorn configuration runs **1 worker** to avoid starting multiple UDP listeners. +- Change `SECRET_KEY` in `docker-compose.yml` for real deployments. + ## UDP trigger Configure UDP Port + UDP Payload on an Event in the admin UI. The UDP listener binds ports automatically (refreshes config every 5s). diff --git a/app/__init__.py b/app/__init__.py index e267923..a9d8d7a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -55,8 +55,9 @@ def create_app() -> Flask: def _configure_logging(app: Flask) -> None: - os.makedirs(os.path.join(app.root_path, "..", "logs"), exist_ok=True) - log_path = os.path.abspath(os.path.join(app.root_path, "..", "logs", "system.log")) + log_dir = app.config.get("LOG_DIR") or os.path.abspath(os.path.join(app.root_path, "..", "logs")) + os.makedirs(log_dir, exist_ok=True) + log_path = os.path.abspath(os.path.join(log_dir, "system.log")) handler = RotatingFileHandler(log_path, maxBytes=2_000_000, backupCount=3) fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") handler.setFormatter(fmt) diff --git a/config.py b/config.py index 2cfb87b..b92e18f 100644 --- a/config.py +++ b/config.py @@ -6,11 +6,17 @@ class Config: SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key-change-me") BASE_DIR = os.path.abspath(os.path.dirname(__file__)) - DB_PATH = os.path.join(BASE_DIR, "syncplayer.db") + + # Container-friendly persistent data root. + # Defaults to project root for local dev. + DATA_DIR = os.environ.get("DATA_DIR", BASE_DIR) + + DB_PATH = os.path.join(DATA_DIR, "syncplayer.db") SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", f"sqlite:///{DB_PATH}") SQLALCHEMY_TRACK_MODIFICATIONS = False - MEDIA_DIR = os.environ.get("MEDIA_DIR", os.path.join(BASE_DIR, "media")) + MEDIA_DIR = os.environ.get("MEDIA_DIR", os.path.join(DATA_DIR, "media")) + LOG_DIR = os.environ.get("LOG_DIR", os.path.join(DATA_DIR, "logs")) MAX_CONTENT_LENGTH = int(os.environ.get("MAX_CONTENT_LENGTH", str(2 * 1024 * 1024 * 1024))) # 2GB # Socket.IO tuning for LAN diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0b45784 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +services: + # Default service: works on Docker Desktop (Windows/macOS) and Linux. + # Exposes HTTP and a *range* of UDP ports for triggers. + # Keep your Event UDP ports within this published range. + syncplayer: + build: . + + ports: + - "5000:5000/tcp" + # UDP trigger ports (edit range if needed) + - "7000-7999:7000-7999/udp" + + environment: + # Persistent data root inside container (mounted below) + DATA_DIR: /data + MEDIA_DIR: /data/media + LOG_DIR: /data/logs + + # Flask-SocketIO async backend for Linux containers + ASYNC_MODE: eventlet + + # App bind settings (when using host networking, PORT must match gunicorn bind) + HOST: 0.0.0.0 + PORT: 5000 + + # Change this in production + SECRET_KEY: change-me + + # Keep CORS permissive for LAN kiosk browsers + CORS_ORIGINS: "*" + + volumes: + - ./data:/data + + restart: unless-stopped + + # Optional: host-networked service for Linux hosts. + # Use: docker compose --profile hostnet up -d --build + # This avoids needing to pre-declare UDP ports (dynamic ports just work). + syncplayer-host: + profiles: ["hostnet"] + build: . + network_mode: host + + environment: + DATA_DIR: /data + MEDIA_DIR: /data/media + LOG_DIR: /data/logs + ASYNC_MODE: eventlet + HOST: 0.0.0.0 + PORT: 5000 + SECRET_KEY: change-me + CORS_ORIGINS: "*" + + volumes: + - ./data:/data + + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt index f11af54..6a5596a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ Flask==3.0.2 Flask-SocketIO==5.3.6 +# Production WSGI server +gunicorn==21.2.0 # Async backends (optional). On Windows/Python 3.14+ these often have no wheels / compatibility issues. eventlet==0.35.2; platform_system != "Windows" and python_version < "3.14" gevent==24.2.1; platform_system != "Windows" and python_version < "3.14" diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..968aad2 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,39 @@ +"""WSGI entrypoint for production deployments. + +This module is what Gunicorn imports. +We also start background tasks that are normally started in run.py: +- UDP trigger listener +- event expiry monitor +""" + +from __future__ import annotations + +import os + +from app import create_app +from models import db +from sockets import start_event_monitor +from udp_listener import start_udp_listener + + +app = create_app() + +# Ensure DB exists on first boot (SQLite-based default). +with app.app_context(): + db.create_all() + + +def _start_background_tasks() -> None: + start_udp_listener(app) + start_event_monitor(app) + + +# With Gunicorn the module can be imported multiple times (worker processes). +# This app is designed for LAN/kiosk setups; we intentionally run a single worker +# by default to avoid duplicated UDP listeners. +if os.environ.get("SYNCPLAYER_START_TASKS", "1") in ("1", "true", "yes"): + _start_background_tasks() + + +# Note: create_app() calls socketio.init_app(app), which wraps app.wsgi_app. +# For Gunicorn, we can export the Flask app directly.