soulsync/core/web_scan_manager.py
Broque Thomas a6bb5f5b43 MS Cin-5: Drop per-server globals — engine owns the clients
Per-server web_server.py globals (plex_client / jellyfin_client /
navidrome_client / soulsync_library_client) are gone. The engine now
owns the per-server client instances; web_server.py constructs them
inline into the engine init and routes everything through
media_server_engine.client('<name>').

Multi-client consumers refactored to take the engine instead of
separate per-server kwargs:

- services/sync_service.py: PlaylistSyncService.__init__ now takes
  media_server_engine. Internal _get_active_media_client resolves the
  active server's client through self._engine.client(name) instead of
  the per-server self.X_client attributes.
- core/listening_stats_worker.py: ListeningStatsWorker takes
  media_server_engine. The plex/jellyfin/navidrome dispatch in _poll
  collapses to engine.client(active_server) (gated to those three
  servers — SoulSync standalone has no listening data).
- core/web_scan_manager.py: WebScanManager takes media_server_engine
  instead of the hand-keyed media_clients dict that drifted out of
  sync with the engine.
- core/discovery/sync.py: SyncDeps holds media_server_engine instead
  of plex_client / jellyfin_client. Playlist-image dispatch routes
  through engine.client(name).

Web_server.py:
- Per-server globals removed from the chained `= None` init line
  + their try/except construction blocks. Replaced with a
  _safe_init_media_client(factory, name) helper that captures
  per-server init failures + passes the resulting clients straight
  into the MediaServerEngine init dict.
- All construction sites (PlaylistSyncService, WebScanManager,
  ListeningStatsWorker, SyncDeps, library_check) updated to receive
  the engine instead of per-server clients.

Test fixtures (tests/discovery/test_discovery_sync.py) gain a
_FakeMediaServerEngine stub + the SyncDeps build helper passes
that instead of separate plex/jellyfin clients.
2026-05-05 18:05:45 -07:00

345 lines
13 KiB
Python

#!/usr/bin/env python3
import threading
import time
from utils.logging_config import get_logger
logger = get_logger("web_scan_manager")
class WebScanManager:
"""
Web-specific media library scan manager with debouncing and callback support.
Designed for Flask web server integration with automatic post-download scanning.
Features:
- Debounces multiple scan requests to prevent spam
- Thread-safe operation with Flask
- Works with Plex, Jellyfin, and Navidrome
- Scan completion callbacks for chained operations
- Progress tracking and status reporting
"""
def __init__(self, media_server_engine, delay_seconds: int = 60):
"""
Initialize the web scan manager.
Args:
media_server_engine: MediaServerEngine that owns the per-server
clients. Replaces the legacy ``media_clients`` dict — the
manager now resolves the active server's client through
``self._engine.client(name)`` instead of a hand-keyed
dict that drifted out of sync with the engine's source
of truth.
delay_seconds: Debounce delay in seconds (default 60s)
"""
self.delay = delay_seconds
self._engine = media_server_engine
self._timer = None
self._scan_in_progress = False
self._downloads_during_scan = False
self._lock = threading.Lock()
self._scan_completion_callbacks = []
self._scan_start_time = None
self._max_scan_time = 1800 # 30 minutes maximum
self._current_server_type = None
self._scan_progress = {}
self._completion_check_timer = None
self._shutting_down = False
logger.info(f"WebScanManager initialized with {delay_seconds}s debounce delay")
def _get_active_media_client(self):
"""Get the active media client through the engine."""
try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
if not self._engine:
logger.error("Web scan manager has no engine reference")
return None, None
client = self._engine.client(active_server)
if client and hasattr(client, 'is_connected') and client.is_connected():
return client, active_server
logger.warning(f"{(active_server or 'unknown').title()} client not connected — scan skipped")
return None, None
except Exception as e:
logger.error(f"Error determining active media server: {e}")
return None, None
def request_scan(self, reason: str = "Download completed", callback=None):
"""
Request a library scan with smart debouncing logic.
Args:
reason: Optional reason for the scan request (for logging)
callback: Optional callback function to call when scan completes
Returns:
dict: Scan request status and timing info
"""
logger.info(f"Web scan requested - reason: {reason}")
with self._lock:
if self._shutting_down:
logger.debug("Web scan request ignored during shutdown")
return {
"status": "ignored",
"message": "Server is shutting down",
"delay_seconds": 0,
"reason": reason,
}
# Add callback if provided
if callback and callback not in self._scan_completion_callbacks:
self._scan_completion_callbacks.append(callback)
if self._scan_in_progress:
# Server is currently scanning - mark that we need another scan later
self._downloads_during_scan = True
logger.info(f"Web scan in progress - queueing follow-up scan ({reason})")
return {
"status": "queued",
"message": "Scan already in progress, queued for later",
"estimated_delay": "after current scan completes"
}
# Cancel any existing timer and start a new one
if self._timer:
self._timer.cancel()
logger.debug(f"Resetting web scan timer ({reason})")
else:
logger.info(f"Web scan queued - will execute in {self.delay}s ({reason})")
# Start the debounce timer
self._timer = threading.Timer(self.delay, self._execute_scan)
self._timer.daemon = True
self._timer.start()
return {
"status": "scheduled",
"message": f"Scan scheduled to start in {self.delay} seconds",
"delay_seconds": self.delay,
"reason": reason
}
def add_scan_completion_callback(self, callback):
"""
Add a callback function to be called when scan completes.
Args:
callback: Function to call when scan completes
"""
with self._lock:
if callback not in self._scan_completion_callbacks:
self._scan_completion_callbacks.append(callback)
logger.info(f"Added web scan completion callback: {callback.__name__}")
def remove_scan_completion_callback(self, callback):
"""Remove a previously registered callback."""
with self._lock:
if callback in self._scan_completion_callbacks:
self._scan_completion_callbacks.remove(callback)
logger.debug(f"Removed web scan completion callback: {callback.__name__}")
def get_scan_status(self):
"""
Get current scan status for web API responses.
Returns:
dict: Current scan status information
"""
with self._lock:
if self._scan_in_progress:
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
return {
"status": "scanning",
"server_type": self._current_server_type,
"elapsed_seconds": int(elapsed_time),
"max_time_seconds": self._max_scan_time,
"progress": self._scan_progress.copy()
}
elif self._timer:
return {
"status": "scheduled",
"server_type": None,
"delay_remaining": "unknown",
"progress": {}
}
else:
return {
"status": "idle",
"server_type": None,
"progress": {}
}
def _execute_scan(self):
"""Execute the actual media library scan"""
with self._lock:
if self._shutting_down:
logger.debug("Web scan execution skipped during shutdown")
return
if self._scan_in_progress:
logger.warning("Web scan already in progress - skipping duplicate execution")
return
self._scan_in_progress = True
self._downloads_during_scan = False
self._timer = None
self._scan_start_time = time.time()
self._scan_progress = {"status": "starting", "message": "Initializing scan"}
# Get the active media client
media_client, server_type = self._get_active_media_client()
if not media_client:
logger.error("No active media client available for web library scan")
self._reset_scan_state()
return
self._current_server_type = server_type
logger.info(f"Starting {server_type.upper()} library scan via web interface...")
try:
# Update progress
with self._lock:
self._scan_progress = {
"status": "scanning",
"message": f"Triggering {server_type.upper()} library scan"
}
success = media_client.trigger_library_scan()
if success:
logger.info(f"{server_type.upper()} library scan initiated successfully via web")
with self._lock:
self._scan_progress = {
"status": "active",
"message": f"{server_type.upper()} is scanning library"
}
# Start periodic completion checking
self._start_periodic_completion_check()
else:
logger.error(f"Failed to initiate {server_type.upper()} library scan via web")
with self._lock:
self._scan_progress = {
"status": "failed",
"message": f"Failed to start {server_type.upper()} scan"
}
self._reset_scan_state()
except Exception as e:
logger.error(f"Error during {server_type.upper()} library scan via web: {e}")
with self._lock:
self._scan_progress = {
"status": "error",
"message": f"Scan error: {str(e)}"
}
self._reset_scan_state()
def _start_periodic_completion_check(self):
"""Start periodic checking for scan completion"""
def check_completion():
try:
if self._shutting_down:
logger.debug("Web scan completion check aborted during shutdown")
return
# Check for timeout
if self._scan_start_time and (time.time() - self._scan_start_time) > self._max_scan_time:
logger.warning(f"Web scan timed out after {self._max_scan_time} seconds")
with self._lock:
self._scan_progress = {
"status": "timeout",
"message": "Scan timed out - assuming complete"
}
self._handle_scan_completion()
return
# Use simple time-based completion (5 minutes)
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
if elapsed_time >= 300: # 5 minutes
logger.info(f"Web scan completion assumed after {elapsed_time:.0f} seconds")
with self._lock:
self._scan_progress = {
"status": "completed",
"message": "Scan completed successfully"
}
self._handle_scan_completion()
else:
# Continue checking
if self._shutting_down:
return
timer = threading.Timer(30, check_completion) # Check every 30 seconds
timer.daemon = True
self._completion_check_timer = timer
timer.start()
except Exception as e:
logger.error(f"Error during web scan completion check: {e}")
self._reset_scan_state()
# Start first check after 30 seconds
timer = threading.Timer(30, check_completion)
timer.daemon = True
self._completion_check_timer = timer
timer.start()
def _handle_scan_completion(self):
"""Handle scan completion and trigger callbacks"""
with self._lock:
if self._shutting_down:
return
server_type = self._current_server_type
callbacks_to_call = self._scan_completion_callbacks.copy()
downloads_during_scan = self._downloads_during_scan
if not server_type:
logger.debug("Skipping web scan completion: no active server type")
self._reset_scan_state()
return
logger.info(f"Web {server_type.upper()} library scan completed")
for callback in callbacks_to_call:
try:
logger.info(f"Calling web scan completion callback: {callback.__name__}")
callback()
except Exception as e:
logger.error(f"Error in web scan completion callback {callback.__name__}: {e}")
# Reset scan state
self._reset_scan_state()
# Check if we need another scan due to downloads during this scan
if downloads_during_scan:
logger.info("Web scan follow-up needed for downloads during scan")
self.request_scan("Follow-up scan for downloads during previous scan")
def _reset_scan_state(self):
"""Reset internal scan state"""
with self._lock:
self._scan_in_progress = False
self._current_server_type = None
self._scan_start_time = None
self._scan_progress = {}
# Don't clear callbacks - they might be reused
def shutdown(self):
"""Cancel any pending timers and stop scheduling new work."""
with self._lock:
self._shutting_down = True
self._scan_in_progress = False
self._current_server_type = None
self._scan_start_time = None
self._scan_progress = {}
if self._timer:
self._timer.cancel()
self._timer = None
if self._completion_check_timer:
self._completion_check_timer.cancel()
self._completion_check_timer = None
self._downloads_during_scan = False
logger.info("WebScanManager shutdown - cancelled all pending timers")