From 14bc9b6fad5c569ec16869eb3f27ac69bfe8c251 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 18 Apr 2026 10:39:33 +0300 Subject: [PATCH 1/6] 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 From 8ff89c63a325e709892681e07004626c403a3f77 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 18 Apr 2026 10:43:33 +0300 Subject: [PATCH 2/6] Add logging for slow requests --- web_server.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/web_server.py b/web_server.py index 62d2a3ea..d77be76c 100644 --- a/web_server.py +++ b/web_server.py @@ -174,6 +174,7 @@ socketio = SocketIO(app, async_mode='threading', cors_allowed_origins='*') @app.before_request def _set_profile_context(): """Set g.profile_id from session for every request""" + g.request_start_monotonic = time.perf_counter() # Skip for profile management, static, and root routes path = request.path if (path.startswith('/api/profiles') or @@ -200,6 +201,34 @@ def _set_profile_context(): g.profile_id = pid +@app.after_request +def _log_slow_request(response): + """Log slow HTTP requests so we can identify UI stall sources.""" + try: + # Skip websocket upgrades and very common noise. + path = request.path + if path.startswith('/socket.io/'): + return response + + start = getattr(g, 'request_start_monotonic', None) + if start is None: + return response + + elapsed_ms = (time.perf_counter() - start) * 1000 + slow_threshold_ms = 1000.0 + if elapsed_ms >= slow_threshold_ms: + logger.warning( + "Slow request: %s %s -> %s in %.1fms", + request.method, + request.full_path.rstrip('?'), + response.status_code, + elapsed_ms, + ) + except Exception: + pass + + return response + def get_current_profile_id() -> int: """Get the current profile ID from Flask g context or default to 1""" try: From cb9a4b23b650a8cb09ed589a9ef42f64412a8a93 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 18 Apr 2026 12:17:53 +0300 Subject: [PATCH 3/6] Clean up legacy env vars, print a warning log if running web_server directly --- Dockerfile | 2 -- docker-compose.yml | 1 - web_server.py | 1 + 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 97430150..fbda152c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,8 +81,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ # Set environment variables ENV PYTHONPATH=/app ENV PYTHONUNBUFFERED=1 -ENV FLASK_APP=web_server.py -ENV FLASK_ENV=production ENV DATABASE_PATH=/app/data/music_library.db ENV PUID=1000 ENV PGID=1000 diff --git a/docker-compose.yml b/docker-compose.yml index b8e4dfc4..cafa2e54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,6 @@ services: - PGID=1000 - UMASK=022 # Web server configuration - - FLASK_ENV=production - PYTHONPATH=/app # Optional: Configure through environment variables - SOULSYNC_CONFIG_PATH=/app/config/config.json diff --git a/web_server.py b/web_server.py index d77be76c..4360bad0 100644 --- a/web_server.py +++ b/web_server.py @@ -54308,5 +54308,6 @@ def start_runtime_services(): if __name__ == '__main__': print("Starting SoulSync Web UI Server...") print("Open your browser and navigate to http://127.0.0.1:8008") + print("Note: direct `python web_server.py` startup is a legacy fallback; use Gunicorn for production.") start_runtime_services() socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True) From 832bb0778700ac9ca7bbadb4c450c5bfefe8c8aa Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 18 Apr 2026 12:38:36 +0300 Subject: [PATCH 4/6] Prevent running web_server.py directly, enforce gunicorn Should not support completely different ways of running the server, as that can lead to inconsistencies between environments / runtimes --- web_server.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/web_server.py b/web_server.py index 4360bad0..928cbb2f 100644 --- a/web_server.py +++ b/web_server.py @@ -1,3 +1,11 @@ +if __name__ == '__main__': + raise SystemExit( + "SoulSync must be started with Gunicorn.\n" + "Use:\n" + "`gunicorn -c gunicorn.conf.py wsgi:application` for production, or\n" + "`gunicorn -c gunicorn.dev.conf.py wsgi:application` for local development." + ) + import os import json import asyncio @@ -54303,11 +54311,3 @@ def start_runtime_services(): 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") - print("Note: direct `python web_server.py` startup is a legacy fallback; use Gunicorn for production.") - start_runtime_services() - socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True) From bbd736e915aaee783696f6e775ebfcb68729068c Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 18 Apr 2026 13:43:10 +0300 Subject: [PATCH 5/6] Reduce gunicorn graceful_timeout to speed up app teardown The app goes through the usual teardown process quite fast on it's own, so keeping the graceful timeout config high only arbitrarily slows things down. This is especially releavnt in dev mode, since app reloads should feel snappy when changes are made --- gunicorn.conf.py | 3 +++ gunicorn.dev.conf.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/gunicorn.conf.py b/gunicorn.conf.py index 65b4183e..0500e468 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -8,6 +8,9 @@ threads = 8 # Keep requests from hanging forever on slow external services. timeout = 120 +# Keep shutdowns under Docker's stop window so container restarts stay graceful. +graceful_timeout = 8 + # Logging goes to stdout/stderr so Docker can collect it. accesslog = "-" errorlog = "-" diff --git a/gunicorn.dev.conf.py b/gunicorn.dev.conf.py index f1e31beb..6a4c73a1 100644 --- a/gunicorn.dev.conf.py +++ b/gunicorn.dev.conf.py @@ -9,6 +9,9 @@ reload = True # Keep requests from hanging forever on slow external services. timeout = 120 +# Don't let local reloads wait too long for shutdown. +graceful_timeout = 1 + # Logging goes to stdout/stderr so the shell launcher can collect it. accesslog = "-" errorlog = "-" From 3191f1fe3bb1d43c5926a63bea7f411ac0a64da5 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 18 Apr 2026 14:24:56 +0300 Subject: [PATCH 6/6] Auto-reload static assets in development --- gunicorn.dev.conf.py | 1 + web_server.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/gunicorn.dev.conf.py b/gunicorn.dev.conf.py index 6a4c73a1..e62ce120 100644 --- a/gunicorn.dev.conf.py +++ b/gunicorn.dev.conf.py @@ -5,6 +5,7 @@ worker_class = "gthread" workers = 1 threads = 4 reload = True +raw_env = ["SOULSYNC_WEB_DEV_NO_CACHE=1"] # Keep requests from hanging forever on slow external services. timeout = 120 diff --git a/web_server.py b/web_server.py index 928cbb2f..8df60532 100644 --- a/web_server.py +++ b/web_server.py @@ -119,6 +119,7 @@ from core.automation_engine import AutomationEngine # --- Flask App Setup --- base_dir = os.path.abspath(os.path.dirname(__file__)) project_root = os.path.dirname(base_dir) # Go up one level to the project root +DEV_STATIC_NO_CACHE = os.environ.get('SOULSYNC_WEB_DEV_NO_CACHE', '0').lower() in ('1', 'true', 'yes', 'on') # Check for environment variable first (Docker support), then fallback to calculated path env_config_path = os.environ.get('SOULSYNC_CONFIG_PATH') @@ -158,6 +159,8 @@ app = Flask( template_folder=os.path.join(base_dir, 'webui'), static_folder=os.path.join(base_dir, 'webui', 'static') ) +app.config['TEMPLATES_AUTO_RELOAD'] = DEV_STATIC_NO_CACHE +app.jinja_env.auto_reload = DEV_STATIC_NO_CACHE # --- Flask Session Setup (for multi-profile support) --- import secrets as _secrets @@ -213,7 +216,6 @@ def _set_profile_context(): def _log_slow_request(response): """Log slow HTTP requests so we can identify UI stall sources.""" try: - # Skip websocket upgrades and very common noise. path = request.path if path.startswith('/socket.io/'): return response