New 'soulsync' media server option manages the library directly from the filesystem, bypassing Plex/Jellyfin/Navidrome entirely. Two paths populate the library: 1. Downloads/imports write artist/album/track to DB immediately at post-processing completion, with pre-populated enrichment IDs (Spotify, Deezer, MusicBrainz) so workers skip re-discovery 2. soulsync_client.py scans Transfer folder for incremental/deep scan via DatabaseUpdateWorker (same interface as server clients) New files: - core/soulsync_client.py: filesystem scanner implementing the same interface as Plex/Jellyfin/Navidrome clients. Recursive folder scan, Mutagen tag reading, artist/album/track grouping, hash-based stable IDs, incremental scan by modification time. Modified: - web_server.py: _record_soulsync_library_entry() at post-processing completion, client init, scan endpoint integration, status endpoint, web_scan_manager media_clients dict, test-connection cache updates - config/settings.py: accept 'soulsync' in set_active_media_server, get_active_media_server_config, is_configured, validate_config - core/web_scan_manager.py: add soulsync to server_client_map Dedup: checks existing artist/album by name across ALL server sources before inserting to avoid duplicates. Enrichment IDs only written when the column is empty (won't overwrite existing data).
350 lines
14 KiB
Python
350 lines
14 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_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")
|