Merge pull request #315 from kettui/feat/gunicorn

Run SoulSync under Gunicorn
This commit is contained in:
BoulderBadgeDad 2026-04-18 11:48:48 -07:00 committed by GitHub
commit 64d87389d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 176 additions and 84 deletions

View file

@ -81,8 +81,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
# Set environment variables # Set environment variables
ENV PYTHONPATH=/app ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV FLASK_APP=web_server.py
ENV FLASK_ENV=production
ENV DATABASE_PATH=/app/data/music_library.db ENV DATABASE_PATH=/app/data/music_library.db
ENV PUID=1000 ENV PUID=1000
ENV PGID=1000 ENV PGID=1000
@ -90,4 +88,4 @@ ENV UMASK=022
# Set entrypoint and default command # Set entrypoint and default command
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
CMD ["python", "web_server.py"] CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:application"]

View file

@ -228,7 +228,7 @@ PUID/PGID are exposed in the template — set them to match your Unraid permissi
git clone https://github.com/Nezreka/SoulSync git clone https://github.com/Nezreka/SoulSync
cd SoulSync cd SoulSync
pip install -r requirements.txt pip install -r requirements.txt
python web_server.py gunicorn -c gunicorn.conf.py wsgi:application
# Open http://localhost:8008 # Open http://localhost:8008
``` ```
@ -237,6 +237,7 @@ For local development and tests:
```bash ```bash
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
pytest pytest
gunicorn -c gunicorn.dev.conf.py wsgi:application
``` ```
--- ---

View file

@ -14,7 +14,6 @@ services:
- PGID=1000 - PGID=1000
- UMASK=022 - UMASK=022
# Web server configuration # Web server configuration
- FLASK_ENV=production
- PYTHONPATH=/app - PYTHONPATH=/app
# Optional: Configure through environment variables # Optional: Configure through environment variables
- SOULSYNC_CONFIG_PATH=/app/config/config.json - SOULSYNC_CONFIG_PATH=/app/config/config.json

17
gunicorn.conf.py Normal file
View file

@ -0,0 +1,17 @@
"""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
# 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 = "-"
loglevel = "info"

19
gunicorn.dev.conf.py Normal file
View file

@ -0,0 +1,19 @@
"""Gunicorn configuration for local development."""
bind = "127.0.0.1:8008"
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
# 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 = "-"
loglevel = "info"

View file

@ -44,3 +44,5 @@ tidalapi>=0.7.6
# WebSocket server for real-time UI updates # WebSocket server for real-time UI updates
flask-socketio>=5.3.0 flask-socketio>=5.3.0
gunicorn>=25.3.0
simple-websocket>=1.1.0

View file

@ -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 os
import json import json
import asyncio import asyncio
@ -111,6 +119,7 @@ from core.automation_engine import AutomationEngine
# --- Flask App Setup --- # --- Flask App Setup ---
base_dir = os.path.abspath(os.path.dirname(__file__)) base_dir = os.path.abspath(os.path.dirname(__file__))
project_root = os.path.dirname(base_dir) # Go up one level to the project root 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 # Check for environment variable first (Docker support), then fallback to calculated path
env_config_path = os.environ.get('SOULSYNC_CONFIG_PATH') env_config_path = os.environ.get('SOULSYNC_CONFIG_PATH')
@ -150,6 +159,8 @@ app = Flask(
template_folder=os.path.join(base_dir, 'webui'), template_folder=os.path.join(base_dir, 'webui'),
static_folder=os.path.join(base_dir, 'webui', 'static') 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) --- # --- Flask Session Setup (for multi-profile support) ---
import secrets as _secrets import secrets as _secrets
@ -174,6 +185,7 @@ socketio = SocketIO(app, async_mode='threading', cors_allowed_origins='*')
@app.before_request @app.before_request
def _set_profile_context(): def _set_profile_context():
"""Set g.profile_id from session for every request""" """Set g.profile_id from session for every request"""
g.request_start_monotonic = time.perf_counter()
# Skip for profile management, static, and root routes # Skip for profile management, static, and root routes
path = request.path path = request.path
if (path.startswith('/api/profiles') or if (path.startswith('/api/profiles') or
@ -199,6 +211,34 @@ def _set_profile_context():
g.profile_id = pid g.profile_id = pid
@app.after_request
def _log_slow_request(response):
"""Log slow HTTP requests so we can identify UI stall sources."""
try:
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: def get_current_profile_id() -> int:
"""Get the current profile ID from Flask g context or default to 1""" """Get the current profile ID from Flask g context or default to 1"""
try: try:
@ -54383,10 +54423,19 @@ def _emit_repair_progress_loop():
# END WEBSOCKET HANDLERS # END WEBSOCKET HANDLERS
# ================================================================================================ # ================================================================================================
_runtime_start_lock = threading.Lock()
_runtime_started = False
if __name__ == '__main__':
print("Starting SoulSync Web UI Server...") def start_runtime_services():
print("Open your browser and navigate to http://127.0.0.1:8008") """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) # 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_')} _soulsync_env = {k: v for k, v in os.environ.items() if k.startswith('SOULSYNC_')}
@ -54419,7 +54468,6 @@ if __name__ == '__main__':
start_import_suggestions_cache() start_import_suggestions_cache()
# Initialize app start time for uptime tracking # Initialize app start time for uptime tracking
import time
app.start_time = time.time() app.start_time = time.time()
# Register action handlers and start automation engine # Register action handlers and start automation engine
@ -54473,4 +54521,4 @@ if __name__ == '__main__':
socketio.start_background_task(_emit_rate_monitor_loop) socketio.start_background_task(_emit_rate_monitor_loop)
print("WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair + rate monitor)") print("WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair + rate monitor)")
socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True) _runtime_started = True

8
wsgi.py Normal file
View file

@ -0,0 +1,8 @@
"""WSGI entrypoint for SoulSync production deployments."""
from web_server import app, start_runtime_services
start_runtime_services()
application = app