mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
302 lines
12 KiB
302 lines
12 KiB
#!/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 = {}
|
|
|
|
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'
|
|
}
|
|
|
|
# Try to get the configured active server first
|
|
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, falling back to Plex")
|
|
|
|
# Fallback to Plex
|
|
plex_client = self.media_clients.get('plex_client')
|
|
if plex_client and hasattr(plex_client, 'is_connected') and plex_client.is_connected():
|
|
return plex_client, "plex"
|
|
|
|
logger.error("No active media client available 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:
|
|
# 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.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._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:
|
|
# 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
|
|
threading.Timer(30, check_completion).start() # Check every 30 seconds
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during web scan completion check: {e}")
|
|
self._reset_scan_state()
|
|
|
|
# Start first check after 30 seconds
|
|
threading.Timer(30, check_completion).start()
|
|
|
|
def _handle_scan_completion(self):
|
|
"""Handle scan completion and trigger callbacks"""
|
|
logger.info(f"🏁 Web {self._current_server_type.upper()} library scan completed")
|
|
|
|
# Call completion callbacks
|
|
callbacks_to_call = []
|
|
with self._lock:
|
|
callbacks_to_call = self._scan_completion_callbacks.copy()
|
|
|
|
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
|
|
with self._lock:
|
|
if self._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 |