diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..2d23063 --- /dev/null +++ b/gunicorn.conf.py @@ -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() diff --git a/production.py b/production.py new file mode 100644 index 0000000..97b8274 --- /dev/null +++ b/production.py @@ -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]() diff --git a/requirements.txt b/requirements.txt index 89a6f3b..8456aca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ flask flask-socketio eventlet +gunicorn python-dotenv # PyQt6 Native App Dependencies diff --git a/server.py b/server.py index 34e7cf0..edd13f2 100644 --- a/server.py +++ b/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") @@ -1275,9 +1302,6 @@ if __name__ == '__main__': print(f"DEBUG: {CONFIG_DEBUG}") 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)