Merge pull request #315 from kettui/feat/gunicorn
Run SoulSync under Gunicorn
This commit is contained in:
commit
64d87389d6
8 changed files with 176 additions and 84 deletions
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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
17
gunicorn.conf.py
Normal 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
19
gunicorn.dev.conf.py
Normal 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"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
206
web_server.py
206
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 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,94 +54423,102 @@ 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...")
|
|
||||||
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)
|
def start_runtime_services():
|
||||||
_soulsync_env = {k: v for k, v in os.environ.items() if k.startswith('SOULSYNC_')}
|
"""Start one-time server background services for direct and WSGI launches."""
|
||||||
if _soulsync_env:
|
global _runtime_started
|
||||||
print(f"[Startup] SOULSYNC environment variables: {_soulsync_env}")
|
|
||||||
else:
|
|
||||||
print("[Startup] No SOULSYNC_* environment variables detected")
|
|
||||||
|
|
||||||
# Start OAuth callback servers
|
with _runtime_start_lock:
|
||||||
print("Starting OAuth callback servers...")
|
if _runtime_started:
|
||||||
start_oauth_callback_servers()
|
return
|
||||||
|
|
||||||
# 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 SoulSync runtime services...")
|
||||||
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
|
# 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")
|
||||||
|
|
||||||
# Pre-build import suggestions cache in background
|
# Start OAuth callback servers
|
||||||
print("Pre-building import suggestions cache...")
|
print("Starting OAuth callback servers...")
|
||||||
start_import_suggestions_cache()
|
start_oauth_callback_servers()
|
||||||
|
|
||||||
# Initialize app start time for uptime tracking
|
# Startup diagnostics: Check and recover stuck flags
|
||||||
import time
|
print("Running startup diagnostics...")
|
||||||
app.start_time = time.time()
|
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")
|
||||||
|
|
||||||
# Register action handlers and start automation engine
|
# Start simple background monitor when server starts
|
||||||
_register_automation_handlers()
|
print("Starting simple background monitor...")
|
||||||
if automation_engine:
|
start_simple_background_monitor()
|
||||||
try:
|
print("Simple background monitor started (includes automatic search cleanup)")
|
||||||
print("Starting automation engine...")
|
|
||||||
automation_engine.start()
|
# Wishlist/watchlist timers are now managed by AutomationEngine system automations
|
||||||
print("Automation engine started")
|
|
||||||
|
# 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:
|
try:
|
||||||
automation_engine.emit('app_started', {})
|
print("Starting automation engine...")
|
||||||
except Exception:
|
automation_engine.start()
|
||||||
pass
|
print("Automation engine started")
|
||||||
except AttributeError as e:
|
try:
|
||||||
print(f"Automation engine failed to start: {e}")
|
automation_engine.emit('app_started', {})
|
||||||
print(" If using Docker, check that your volume mount is /app/data (not /app/database)")
|
except Exception:
|
||||||
logger.error(f"Automation engine start error (possible stale Docker volume): {e}")
|
pass
|
||||||
except Exception as e:
|
except AttributeError as e:
|
||||||
print(f"Automation engine failed to start: {e}")
|
print(f"Automation engine failed to start: {e}")
|
||||||
logger.error(f"Automation engine start error: {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 startup activity
|
||||||
add_activity_item("", "System Started", "SoulSync Web UI Server initialized", "Now")
|
add_activity_item("", "System Started", "SoulSync Web UI Server initialized", "Now")
|
||||||
|
|
||||||
# Start WebSocket background emitters
|
# Start WebSocket background emitters
|
||||||
print("Starting WebSocket background emitters...")
|
print("Starting WebSocket background emitters...")
|
||||||
# Phase 1: Global pollers
|
# Phase 1: Global pollers
|
||||||
socketio.start_background_task(_emit_service_status_loop)
|
socketio.start_background_task(_emit_service_status_loop)
|
||||||
socketio.start_background_task(_emit_watchlist_count_loop)
|
socketio.start_background_task(_emit_watchlist_count_loop)
|
||||||
socketio.start_background_task(_emit_download_status_loop)
|
socketio.start_background_task(_emit_download_status_loop)
|
||||||
# Phase 2: Dashboard pollers
|
# Phase 2: Dashboard pollers
|
||||||
socketio.start_background_task(_emit_system_stats_loop)
|
socketio.start_background_task(_emit_system_stats_loop)
|
||||||
socketio.start_background_task(_emit_activity_feed_loop)
|
socketio.start_background_task(_emit_activity_feed_loop)
|
||||||
socketio.start_background_task(_emit_db_stats_loop)
|
socketio.start_background_task(_emit_db_stats_loop)
|
||||||
socketio.start_background_task(_emit_wishlist_count_loop)
|
socketio.start_background_task(_emit_wishlist_count_loop)
|
||||||
# Phase 3: Enrichment sidebar workers
|
# Phase 3: Enrichment sidebar workers
|
||||||
socketio.start_background_task(_emit_enrichment_status_loop)
|
socketio.start_background_task(_emit_enrichment_status_loop)
|
||||||
# Phase 4: Tool progress pollers
|
# Phase 4: Tool progress pollers
|
||||||
socketio.start_background_task(_emit_tool_progress_loop)
|
socketio.start_background_task(_emit_tool_progress_loop)
|
||||||
# Phase 5: Sync/discovery progress + scans
|
# Phase 5: Sync/discovery progress + scans
|
||||||
socketio.start_background_task(_emit_sync_progress_loop)
|
socketio.start_background_task(_emit_sync_progress_loop)
|
||||||
socketio.start_background_task(_emit_discovery_progress_loop)
|
socketio.start_background_task(_emit_discovery_progress_loop)
|
||||||
socketio.start_background_task(_emit_scan_status_loop)
|
socketio.start_background_task(_emit_scan_status_loop)
|
||||||
# Phase 6: Automation progress
|
# Phase 6: Automation progress
|
||||||
socketio.start_background_task(_emit_automation_progress_loop)
|
socketio.start_background_task(_emit_automation_progress_loop)
|
||||||
# Phase 7: Repair job progress
|
# Phase 7: Repair job progress
|
||||||
socketio.start_background_task(_emit_repair_progress_loop)
|
socketio.start_background_task(_emit_repair_progress_loop)
|
||||||
# Hydrabase auto-reconnect monitor
|
# Hydrabase auto-reconnect monitor
|
||||||
socketio.start_background_task(_hydrabase_reconnect_loop)
|
socketio.start_background_task(_hydrabase_reconnect_loop)
|
||||||
# API Rate Monitor — 1s push for speedometer gauges
|
# API Rate Monitor — 1s push for speedometer gauges
|
||||||
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
8
wsgi.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""WSGI entrypoint for SoulSync production deployments."""
|
||||||
|
|
||||||
|
from web_server import app, start_runtime_services
|
||||||
|
|
||||||
|
|
||||||
|
start_runtime_services()
|
||||||
|
|
||||||
|
application = app
|
||||||
Loading…
Reference in a new issue