#!/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_clients, delay_seconds: int = 60): """ Initialize the web scan manager. Args: media_clients: Dict containing plex_client, jellyfin_client, navidrome_client delay_seconds: Debounce delay in seconds (default 60s) """ self.delay = delay_seconds self.media_clients = media_clients 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 based on config settings""" try: from config.settings import config_manager active_server = config_manager.get_active_media_server() server_client_map = { 'jellyfin': 'jellyfin_client', 'navidrome': 'navidrome_client', 'plex': 'plex_client', 'soulsync': 'soulsync_library_client', } # Try to get the configured active server if active_server in server_client_map: client_key = server_client_map[active_server] client = self.media_clients.get(client_key) if client and hasattr(client, 'is_connected') and client.is_connected(): return client, active_server else: logger.warning(f"{active_server.title()} client not connected — scan skipped") return None, None logger.error("No active media server configured for scanning") 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")