feat: add gunicorn production server with start/stop/restart manager

This commit is contained in:
ComputerTech 2026-04-04 13:21:05 +01:00
parent ad06ee4854
commit 1174b65b7d
4 changed files with 276 additions and 6 deletions

45
gunicorn.conf.py Normal file
View File

@ -0,0 +1,45 @@
# gunicorn.conf.py — Gunicorn configuration for TechDJ
# Used by production.py. Do not run gunicorn directly without this file.
#
# Architecture:
# - Gunicorn (eventlet worker, 1 worker) serves the DJ panel app on DJ_PORT.
# - post_worker_init spawns the listener app on LISTENER_PORT and starts
# background greenlets — all within the same worker process so they share
# in-process state (SRT state, pre-roll buffer, queue, etc.).
import os
import json
# ── Load ports from config.json ──────────────────────────────────────────────
def _load_cfg():
try:
with open(os.path.join(os.path.dirname(__file__), 'config.json')) as f:
return json.load(f)
except Exception:
return {}
_cfg = _load_cfg()
# ── Gunicorn settings ─────────────────────────────────────────────────────────
worker_class = 'eventlet'
workers = 1 # Must be 1 — eventlet handles concurrency via greenlets
# and both apps share in-process state.
bind = f"{_cfg.get('host', '0.0.0.0')}:{_cfg.get('dj_port', 5000)}"
timeout = 0 # Disable worker timeout — long-lived SSE/WS connections.
keepalive = 5
# Log format
accesslog = '-' # stdout — production.py redirects to techdj.log
errorlog = '-'
loglevel = 'info'
# ── Hooks ─────────────────────────────────────────────────────────────────────
def post_worker_init(worker):
"""Called after the eventlet worker is fully initialised.
Starts the listener server + background greenlets in the same process
as the DJ panel so they share all in-process state.
"""
from server import _start_background_tasks
_start_background_tasks()

200
production.py Normal file
View File

@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
TechDJ production process manager.
Usage:
python production.py start start TechDJ in the background
python production.py stop stop TechDJ
python production.py restart restart TechDJ
python production.py status show whether TechDJ is running
python production.py logs tail the live log output
"""
import sys
import os
import signal
import subprocess
import time
import json
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PID_FILE = os.path.join(BASE_DIR, 'techdj.pid')
LOG_FILE = os.path.join(BASE_DIR, 'techdj.log')
CONF_FILE = os.path.join(BASE_DIR, 'gunicorn.conf.py')
SERVER_MOD = 'server:dj_app'
# Prefer the venv's gunicorn if it exists.
_venv_gunicorn = os.path.join(BASE_DIR, '.venv', 'bin', 'gunicorn')
GUNICORN = _venv_gunicorn if os.path.exists(_venv_gunicorn) else 'gunicorn'
# ── Helpers ───────────────────────────────────────────────────────────────────
def _load_config():
try:
with open(os.path.join(BASE_DIR, 'config.json')) as f:
return json.load(f)
except Exception:
return {}
def _read_pid():
try:
with open(PID_FILE) as f:
return int(f.read().strip())
except (FileNotFoundError, ValueError):
return None
def _is_running(pid):
if pid is None:
return False
try:
os.kill(pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
def _check_gunicorn():
try:
subprocess.check_call(
[GUNICORN, '--version'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except (FileNotFoundError, subprocess.CalledProcessError):
print(f"ERROR: gunicorn not found at '{GUNICORN}'.")
print(" Install it with: pip install gunicorn")
sys.exit(1)
# ── Commands ──────────────────────────────────────────────────────────────────
def start():
pid = _read_pid()
if _is_running(pid):
print(f"TechDJ is already running (PID {pid})")
return
_check_gunicorn()
cfg = _load_config()
host = cfg.get('host', '0.0.0.0')
port = cfg.get('dj_port', 5000)
lport = cfg.get('listener_port', 5001)
print(f"Starting TechDJ...")
print(f" DJ panel : http://{host}:{port}")
print(f" Listener : http://{host}:{lport}")
print(f" Log file : {LOG_FILE}")
cmd = [
GUNICORN,
'--config', CONF_FILE,
'--daemon',
'--pid', PID_FILE,
'--access-logfile', LOG_FILE,
'--error-logfile', LOG_FILE,
SERVER_MOD,
]
try:
subprocess.check_call(cmd, cwd=BASE_DIR)
except subprocess.CalledProcessError as exc:
print(f"ERROR: gunicorn exited with code {exc.returncode}. Check {LOG_FILE}")
sys.exit(1)
# Give gunicorn a moment to write the PID file.
for _ in range(10):
time.sleep(0.5)
pid = _read_pid()
if _is_running(pid):
break
else:
print(f"ERROR: TechDJ did not start. Check logs:")
_tail_log(20)
sys.exit(1)
print(f"TechDJ started (PID {pid})")
def stop():
pid = _read_pid()
if not _is_running(pid):
print("TechDJ is not running.")
if os.path.exists(PID_FILE):
os.remove(PID_FILE)
return
print(f"Stopping TechDJ (PID {pid})...")
os.kill(pid, signal.SIGTERM)
for _ in range(20):
time.sleep(0.5)
if not _is_running(pid):
break
else:
print(" Graceful shutdown timed out — sending SIGKILL.")
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
if os.path.exists(PID_FILE):
os.remove(PID_FILE)
print("TechDJ stopped.")
def restart():
stop()
time.sleep(1)
start()
def status():
pid = _read_pid()
if _is_running(pid):
cfg = _load_config()
host = cfg.get('host', '0.0.0.0')
port = cfg.get('dj_port', 5000)
lport = cfg.get('listener_port', 5001)
print(f"TechDJ is running (PID {pid})")
print(f" DJ panel : http://{host}:{port}")
print(f" Listener : http://{host}:{lport}")
print(f" Log file : {LOG_FILE}")
else:
print("TechDJ is NOT running.")
def _tail_log(lines=50):
if not os.path.exists(LOG_FILE):
print(f"No log file at {LOG_FILE}")
return
subprocess.call(['tail', f'-{lines}', LOG_FILE])
def logs():
if not os.path.exists(LOG_FILE):
print(f"No log file at {LOG_FILE}")
sys.exit(1)
# Replace this process with tail -f so Ctrl+C works cleanly.
os.execvp('tail', ['tail', '-f', LOG_FILE])
# ── Entry point ───────────────────────────────────────────────────────────────
COMMANDS = {
'start': start,
'stop': stop,
'restart': restart,
'status': status,
'logs': logs,
}
if __name__ == '__main__':
cmd = sys.argv[1] if len(sys.argv) > 1 else 'status'
if cmd not in COMMANDS:
print(f"Usage: python production.py [{'|'.join(COMMANDS)}]")
sys.exit(1)
COMMANDS[cmd]()

View File

@ -2,6 +2,7 @@
flask
flask-socketio
eventlet
gunicorn
python-dotenv
# PyQt6 Native App Dependencies

View File

@ -1261,6 +1261,33 @@ def _listener_count_sync_loop():
_broadcast_listener_count()
_background_tasks_started = False
_background_tasks_lock = threading.Lock()
def _start_background_tasks():
"""Start the listener server and background greenlets.
Safe to call multiple times only the first call has any effect.
Called from __main__ (direct run) and from gunicorn's post_worker_init
hook (production run via gunicorn.conf.py).
"""
global _background_tasks_started
with _background_tasks_lock:
if _background_tasks_started:
return
_background_tasks_started = True
eventlet.spawn(_listener_count_sync_loop)
eventlet.spawn(_transcoder_watchdog)
eventlet.spawn(
listener_socketio.run,
listener_app,
host=CONFIG_HOST,
port=CONFIG_LISTENER_PORT,
debug=False,
)
if __name__ == '__main__':
print("=" * 50)
print("TECHDJ PRO - DUAL PORT ARCHITECTURE")
@ -1276,8 +1303,5 @@ if __name__ == '__main__':
print("=" * 50)
print(f"READY: Server ready on {CONFIG_HOST}:{CONFIG_DJ_PORT} & {CONFIG_HOST}:{CONFIG_LISTENER_PORT}")
# Run both servers using eventlet's spawn
eventlet.spawn(_listener_count_sync_loop)
eventlet.spawn(_transcoder_watchdog)
eventlet.spawn(dj_socketio.run, dj_app, host=CONFIG_HOST, port=CONFIG_DJ_PORT, debug=False)
listener_socketio.run(listener_app, host=CONFIG_HOST, port=CONFIG_LISTENER_PORT, debug=False)
_start_background_tasks()
dj_socketio.run(dj_app, host=CONFIG_HOST, port=CONFIG_DJ_PORT, debug=False)