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-socketio
|
||||
eventlet
|
||||
gunicorn
|
||||
python-dotenv
|
||||
|
||||
# PyQt6 Native App Dependencies
|
||||
|
|
|
|||
34
server.py
34
server.py
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue