feat: add gunicorn production server with start/stop/restart manager
This commit is contained in:
parent
ad06ee4854
commit
1174b65b7d
|
|
@ -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()
|
||||||
|
|
@ -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]()
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flask
|
flask
|
||||||
flask-socketio
|
flask-socketio
|
||||||
eventlet
|
eventlet
|
||||||
|
gunicorn
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
|
||||||
# PyQt6 Native App Dependencies
|
# PyQt6 Native App Dependencies
|
||||||
|
|
|
||||||
36
server.py
36
server.py
|
|
@ -1261,6 +1261,33 @@ def _listener_count_sync_loop():
|
||||||
_broadcast_listener_count()
|
_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__':
|
if __name__ == '__main__':
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print("TECHDJ PRO - DUAL PORT ARCHITECTURE")
|
print("TECHDJ PRO - DUAL PORT ARCHITECTURE")
|
||||||
|
|
@ -1275,9 +1302,6 @@ if __name__ == '__main__':
|
||||||
print(f"DEBUG: {CONFIG_DEBUG}")
|
print(f"DEBUG: {CONFIG_DEBUG}")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print(f"READY: Server ready on {CONFIG_HOST}:{CONFIG_DJ_PORT} & {CONFIG_HOST}:{CONFIG_LISTENER_PORT}")
|
print(f"READY: Server ready on {CONFIG_HOST}:{CONFIG_DJ_PORT} & {CONFIG_HOST}:{CONFIG_LISTENER_PORT}")
|
||||||
|
|
||||||
# Run both servers using eventlet's spawn
|
_start_background_tasks()
|
||||||
eventlet.spawn(_listener_count_sync_loop)
|
dj_socketio.run(dj_app, host=CONFIG_HOST, port=CONFIG_DJ_PORT, debug=False)
|
||||||
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)
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue