chore: production docker + compose for LAN deployment
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -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/
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -15,6 +15,9 @@ syncplayer.db
|
||||
media/*
|
||||
!media/idle_image.png
|
||||
|
||||
# Docker compose persistent data directory
|
||||
data/
|
||||
|
||||
# OS / IDE
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
45
Dockerfile
Normal file
45
Dockerfile
Normal file
@@ -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"]
|
||||
43
README.md
43
README.md
@@ -107,6 +107,49 @@ Open display pages:
|
||||
|
||||
- `http://<server-ip>:5000/display/<public_id>`
|
||||
|
||||
## 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://<host-ip>: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://<host-ip>:5000/admin/`
|
||||
- Displays: `http://<host-ip>:5000/display/<public_id>`
|
||||
|
||||
### 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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
10
config.py
10
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
|
||||
|
||||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
@@ -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
|
||||
@@ -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"
|
||||
|
||||
39
wsgi.py
Normal file
39
wsgi.py
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user