From 14bc9b6fad5c569ec16869eb3f27ac69bfe8c251 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 18 Apr 2026 10:39:33 +0300 Subject: [PATCH] Introduce Gunicorn production runner Switch the web UI from Werkzeug's built-in server to Gunicorn for a more stable production deployment path. Keep a separate dev config so local runs still reload quickly, while the production path uses a dedicated WSGI entrypoint and cleaner startup behavior. The main motivation is to reduce the websocket teardown noise and make the server behavior more predictable under the app's mostly background-driven workload. --- Dockerfile | 2 +- README.md | 3 +- gunicorn.conf.py | 14 ++++ gunicorn.dev.conf.py | 15 ++++ requirements.txt | 2 + web_server.py | 188 +++++++++++++++++++++++-------------------- wsgi.py | 8 ++ 7 files changed, 144 insertions(+), 88 deletions(-) create mode 100644 gunicorn.conf.py create mode 100644 gunicorn.dev.conf.py create mode 100644 wsgi.py diff --git a/Dockerfile b/Dockerfile index 453a99b6..97430150 100644 --- a/Dockerfile +++ b/Dockerfile @@ -90,4 +90,4 @@ ENV UMASK=022 # Set entrypoint and default command ENTRYPOINT ["/entrypoint.sh"] -CMD ["python", "web_server.py"] +CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:application"] diff --git a/README.md b/README.md index bff906da..e0728442 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ PUID/PGID are exposed in the template — set them to match your Unraid permissi git clone https://github.com/Nezreka/SoulSync cd SoulSync pip install -r requirements.txt -python web_server.py +gunicorn -c gunicorn.conf.py wsgi:application # Open http://localhost:8008 ``` @@ -229,6 +229,7 @@ For local development and tests: ```bash pip install -r requirements-dev.txt pytest +gunicorn -c gunicorn.dev.conf.py wsgi:application ``` --- diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 00000000..65b4183e --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,14 @@ +"""Gunicorn configuration for production deployments.""" + +bind = "0.0.0.0:8008" +worker_class = "gthread" +workers = 1 +threads = 8 + +# Keep requests from hanging forever on slow external services. +timeout = 120 + +# Logging goes to stdout/stderr so Docker can collect it. +accesslog = "-" +errorlog = "-" +loglevel = "info" diff --git a/gunicorn.dev.conf.py b/gunicorn.dev.conf.py new file mode 100644 index 00000000..f1e31beb --- /dev/null +++ b/gunicorn.dev.conf.py @@ -0,0 +1,15 @@ +"""Gunicorn configuration for local development.""" + +bind = "127.0.0.1:8008" +worker_class = "gthread" +workers = 1 +threads = 4 +reload = True + +# Keep requests from hanging forever on slow external services. +timeout = 120 + +# Logging goes to stdout/stderr so the shell launcher can collect it. +accesslog = "-" +errorlog = "-" +loglevel = "info" diff --git a/requirements.txt b/requirements.txt index e3705690..92cdcc06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,3 +44,5 @@ tidalapi>=0.7.6 # WebSocket server for real-time UI updates flask-socketio>=5.3.0 +gunicorn>=25.3.0 +simple-websocket>=1.1.0 diff --git a/web_server.py b/web_server.py index f9930be3..62d2a3ea 100644 --- a/web_server.py +++ b/web_server.py @@ -199,6 +199,7 @@ def _set_profile_context(): g.profile_id = pid + def get_current_profile_id() -> int: """Get the current profile ID from Flask g context or default to 1""" try: @@ -54174,94 +54175,109 @@ def _emit_repair_progress_loop(): # END WEBSOCKET HANDLERS # ================================================================================================ +_runtime_start_lock = threading.Lock() +_runtime_started = False + + +def start_runtime_services(): + """Start one-time server background services for direct and WSGI launches.""" + global _runtime_started + + with _runtime_start_lock: + if _runtime_started: + return + + print("Starting SoulSync runtime services...") + + # Dump SOULSYNC_* env vars for diagnostics (helps debug Docker/Unraid env issues) + _soulsync_env = {k: v for k, v in os.environ.items() if k.startswith('SOULSYNC_')} + if _soulsync_env: + print(f"[Startup] SOULSYNC environment variables: {_soulsync_env}") + else: + print("[Startup] No SOULSYNC_* environment variables detected") + + # Start OAuth callback servers + print("Starting OAuth callback servers...") + start_oauth_callback_servers() + + # Startup diagnostics: Check and recover stuck flags + print("Running startup diagnostics...") + stuck_flags_recovered = check_and_recover_stuck_flags() + if stuck_flags_recovered: + print("Recovered stuck flags from previous session") + else: + print("No stuck flags detected - system healthy") + + # Start simple background monitor when server starts + print("Starting simple background monitor...") + start_simple_background_monitor() + print("Simple background monitor started (includes automatic search cleanup)") + + # Wishlist/watchlist timers are now managed by AutomationEngine system automations + + # Pre-build import suggestions cache in background + print("Pre-building import suggestions cache...") + start_import_suggestions_cache() + + # Initialize app start time for uptime tracking + app.start_time = time.time() + + # Register action handlers and start automation engine + _register_automation_handlers() + if automation_engine: + try: + print("Starting automation engine...") + automation_engine.start() + print("Automation engine started") + try: + automation_engine.emit('app_started', {}) + except Exception: + pass + except AttributeError as e: + print(f"Automation engine failed to start: {e}") + print(" If using Docker, check that your volume mount is /app/data (not /app/database)") + logger.error(f"Automation engine start error (possible stale Docker volume): {e}") + except Exception as e: + print(f"Automation engine failed to start: {e}") + logger.error(f"Automation engine start error: {e}") + + # Add startup activity + add_activity_item("", "System Started", "SoulSync Web UI Server initialized", "Now") + + # Start WebSocket background emitters + print("Starting WebSocket background emitters...") + # Phase 1: Global pollers + socketio.start_background_task(_emit_service_status_loop) + socketio.start_background_task(_emit_watchlist_count_loop) + socketio.start_background_task(_emit_download_status_loop) + # Phase 2: Dashboard pollers + socketio.start_background_task(_emit_system_stats_loop) + socketio.start_background_task(_emit_activity_feed_loop) + socketio.start_background_task(_emit_db_stats_loop) + socketio.start_background_task(_emit_wishlist_count_loop) + # Phase 3: Enrichment sidebar workers + socketio.start_background_task(_emit_enrichment_status_loop) + # Phase 4: Tool progress pollers + socketio.start_background_task(_emit_tool_progress_loop) + # Phase 5: Sync/discovery progress + scans + socketio.start_background_task(_emit_sync_progress_loop) + socketio.start_background_task(_emit_discovery_progress_loop) + socketio.start_background_task(_emit_scan_status_loop) + # Phase 6: Automation progress + socketio.start_background_task(_emit_automation_progress_loop) + # Phase 7: Repair job progress + socketio.start_background_task(_emit_repair_progress_loop) + # Hydrabase auto-reconnect monitor + socketio.start_background_task(_hydrabase_reconnect_loop) + # API Rate Monitor — 1s push for speedometer gauges + socketio.start_background_task(_emit_rate_monitor_loop) + print("WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair + rate monitor)") + + _runtime_started = True + if __name__ == '__main__': print("Starting SoulSync Web UI Server...") print("Open your browser and navigate to http://127.0.0.1:8008") - - # Dump SOULSYNC_* env vars for diagnostics (helps debug Docker/Unraid env issues) - _soulsync_env = {k: v for k, v in os.environ.items() if k.startswith('SOULSYNC_')} - if _soulsync_env: - print(f"[Startup] SOULSYNC environment variables: {_soulsync_env}") - else: - print("[Startup] No SOULSYNC_* environment variables detected") - - # Start OAuth callback servers - print("Starting OAuth callback servers...") - start_oauth_callback_servers() - - # Startup diagnostics: Check and recover stuck flags - print("Running startup diagnostics...") - stuck_flags_recovered = check_and_recover_stuck_flags() - if stuck_flags_recovered: - print("Recovered stuck flags from previous session") - else: - print("No stuck flags detected - system healthy") - - # Start simple background monitor when server starts - print("Starting simple background monitor...") - start_simple_background_monitor() - print("Simple background monitor started (includes automatic search cleanup)") - - # Wishlist/watchlist timers are now managed by AutomationEngine system automations - - # Pre-build import suggestions cache in background - print("Pre-building import suggestions cache...") - start_import_suggestions_cache() - - # Initialize app start time for uptime tracking - import time - app.start_time = time.time() - - # Register action handlers and start automation engine - _register_automation_handlers() - if automation_engine: - try: - print("Starting automation engine...") - automation_engine.start() - print("Automation engine started") - try: - automation_engine.emit('app_started', {}) - except Exception: - pass - except AttributeError as e: - print(f"Automation engine failed to start: {e}") - print(" If using Docker, check that your volume mount is /app/data (not /app/database)") - logger.error(f"Automation engine start error (possible stale Docker volume): {e}") - except Exception as e: - print(f"Automation engine failed to start: {e}") - logger.error(f"Automation engine start error: {e}") - - # Add startup activity - add_activity_item("", "System Started", "SoulSync Web UI Server initialized", "Now") - - # Start WebSocket background emitters - print("Starting WebSocket background emitters...") - # Phase 1: Global pollers - socketio.start_background_task(_emit_service_status_loop) - socketio.start_background_task(_emit_watchlist_count_loop) - socketio.start_background_task(_emit_download_status_loop) - # Phase 2: Dashboard pollers - socketio.start_background_task(_emit_system_stats_loop) - socketio.start_background_task(_emit_activity_feed_loop) - socketio.start_background_task(_emit_db_stats_loop) - socketio.start_background_task(_emit_wishlist_count_loop) - # Phase 3: Enrichment sidebar workers - socketio.start_background_task(_emit_enrichment_status_loop) - # Phase 4: Tool progress pollers - socketio.start_background_task(_emit_tool_progress_loop) - # Phase 5: Sync/discovery progress + scans - socketio.start_background_task(_emit_sync_progress_loop) - socketio.start_background_task(_emit_discovery_progress_loop) - socketio.start_background_task(_emit_scan_status_loop) - # Phase 6: Automation progress - socketio.start_background_task(_emit_automation_progress_loop) - # Phase 7: Repair job progress - socketio.start_background_task(_emit_repair_progress_loop) - # Hydrabase auto-reconnect monitor - socketio.start_background_task(_hydrabase_reconnect_loop) - # API Rate Monitor — 1s push for speedometer gauges - socketio.start_background_task(_emit_rate_monitor_loop) - print("WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair + rate monitor)") - + start_runtime_services() socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True) diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 00000000..7e9b1c8e --- /dev/null +++ b/wsgi.py @@ -0,0 +1,8 @@ +"""WSGI entrypoint for SoulSync production deployments.""" + +from web_server import app, start_runtime_services + + +start_runtime_services() + +application = app