Stripped 4,200+ emoji characters from print(), logger calls across 39 Python files. Logs are now clean text — easier to grep, more professional, no encoding issues on terminals without Unicode support. Seasonal config icons preserved for UI display.
323 lines
No EOL
13 KiB
Python
323 lines
No EOL
13 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import threading
|
|
import time
|
|
from utils.logging_config import get_logger
|
|
|
|
logger = get_logger("plex_scan_manager")
|
|
|
|
class PlexScanManager:
|
|
"""
|
|
Smart Plex library scan manager with debouncing and scan-aware follow-up logic.
|
|
|
|
Features:
|
|
- Debounces multiple scan requests to prevent spam
|
|
- Tracks downloads that happen during active scans
|
|
- Automatically triggers follow-up scans when needed
|
|
- Thread-safe operation
|
|
"""
|
|
|
|
def __init__(self, plex_client, delay_seconds: int = 60):
|
|
"""
|
|
Initialize the scan manager.
|
|
|
|
Args:
|
|
plex_client: PlexClient instance with trigger_library_scan method
|
|
delay_seconds: Debounce delay in seconds (default 60s)
|
|
"""
|
|
self.plex_client = plex_client
|
|
self.delay = delay_seconds
|
|
self._timer = None
|
|
self._scan_in_progress = False
|
|
self._downloads_during_scan = False
|
|
self._lock = threading.Lock()
|
|
self._scan_completion_callbacks = [] # List of callback functions to call when scan completes
|
|
self._scan_start_time = None # Track when scan started for timeout
|
|
self._max_scan_time = 1800 # Maximum scan time in seconds (30 minutes)
|
|
|
|
# New periodic update system
|
|
self._periodic_update_timer = None # Timer for 5-minute periodic updates
|
|
self._periodic_update_interval = 300 # 5 minutes in seconds
|
|
self._is_doing_periodic_updates = False # Track if we're in periodic update mode
|
|
|
|
logger.info(f"PlexScanManager initialized with {delay_seconds}s debounce delay")
|
|
|
|
def request_scan(self, reason: str = "Download completed"):
|
|
"""
|
|
Request a library scan with smart debouncing logic.
|
|
|
|
Args:
|
|
reason: Optional reason for the scan request (for logging)
|
|
"""
|
|
logger.info(f"DEBUG: Plex scan requested - reason: {reason}")
|
|
with self._lock:
|
|
if self._scan_in_progress:
|
|
# Plex is currently scanning - mark that we need another scan later
|
|
self._downloads_during_scan = True
|
|
logger.info(f"Plex scan in progress - queueing follow-up scan ({reason})")
|
|
return
|
|
|
|
# Cancel any existing timer and start a new one
|
|
if self._timer:
|
|
self._timer.cancel()
|
|
logger.debug(f"Resetting scan timer ({reason})")
|
|
else:
|
|
logger.info(f"Plex 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()
|
|
|
|
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 (no arguments)
|
|
"""
|
|
with self._lock:
|
|
if callback not in self._scan_completion_callbacks:
|
|
self._scan_completion_callbacks.append(callback)
|
|
logger.info(f"DEBUG: Added scan completion callback: {callback.__name__}")
|
|
logger.info(f"DEBUG: Total callbacks registered: {len(self._scan_completion_callbacks)}")
|
|
|
|
def remove_scan_completion_callback(self, callback):
|
|
"""
|
|
Remove a previously registered callback.
|
|
|
|
Args:
|
|
callback: Function to remove from callbacks
|
|
"""
|
|
with self._lock:
|
|
if callback in self._scan_completion_callbacks:
|
|
self._scan_completion_callbacks.remove(callback)
|
|
logger.debug(f"Removed scan completion callback: {callback.__name__}")
|
|
|
|
def _execute_scan(self):
|
|
"""Execute the actual Plex library scan"""
|
|
with self._lock:
|
|
if self._scan_in_progress:
|
|
logger.warning("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()
|
|
|
|
logger.info("Starting Plex library scan...")
|
|
|
|
try:
|
|
success = self.plex_client.trigger_library_scan()
|
|
|
|
if success:
|
|
logger.info("Plex library scan initiated successfully")
|
|
# Start new periodic update system instead of completion detection
|
|
self._start_periodic_updates()
|
|
else:
|
|
logger.error("Failed to initiate Plex library scan")
|
|
self._reset_scan_state()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Exception during Plex library scan: {e}")
|
|
self._reset_scan_state()
|
|
|
|
def _start_periodic_updates(self):
|
|
"""Start periodic database updates while Plex is scanning"""
|
|
try:
|
|
with self._lock:
|
|
if self._is_doing_periodic_updates:
|
|
logger.debug("Periodic updates already in progress")
|
|
return
|
|
|
|
self._is_doing_periodic_updates = True
|
|
|
|
logger.info(f"Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes")
|
|
|
|
# Schedule first periodic update after 5 minutes
|
|
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
|
self._periodic_update_timer.start()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error starting periodic updates: {e}")
|
|
self._reset_scan_state()
|
|
|
|
def _do_periodic_update(self):
|
|
"""Execute periodic database update and check if scanning continues"""
|
|
try:
|
|
with self._lock:
|
|
if not self._scan_in_progress:
|
|
logger.debug("Scan no longer in progress, stopping periodic updates")
|
|
return
|
|
|
|
# Check for timeout
|
|
if self._scan_start_time and (time.time() - self._scan_start_time) > self._max_scan_time:
|
|
logger.warning(f"Plex scan timeout reached ({self._max_scan_time}s), stopping periodic updates")
|
|
self._stop_periodic_updates()
|
|
return
|
|
|
|
# Check if Plex is still scanning
|
|
is_scanning = self.plex_client.is_library_scanning("Music")
|
|
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
|
|
|
|
logger.info(f"PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - Plex scanning: {is_scanning}")
|
|
|
|
if is_scanning:
|
|
# Still scanning - trigger database update and continue periodic updates
|
|
logger.info("Plex still scanning - triggering database update")
|
|
self._call_completion_callbacks()
|
|
|
|
# Schedule next periodic update
|
|
logger.info(f"Scheduling next periodic update in {self._periodic_update_interval//60} minutes")
|
|
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
|
self._periodic_update_timer.start()
|
|
else:
|
|
# Scanning stopped - final update and cleanup
|
|
logger.info("Plex scanning completed - doing final database update")
|
|
self._call_completion_callbacks()
|
|
self._stop_periodic_updates()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during periodic update: {e}")
|
|
self._stop_periodic_updates()
|
|
|
|
def _stop_periodic_updates(self):
|
|
"""Stop periodic updates and clean up"""
|
|
try:
|
|
with self._lock:
|
|
self._is_doing_periodic_updates = False
|
|
|
|
if self._periodic_update_timer:
|
|
self._periodic_update_timer.cancel()
|
|
self._periodic_update_timer = None
|
|
|
|
logger.info("Stopped periodic database updates")
|
|
self._scan_completed()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error stopping periodic updates: {e}")
|
|
|
|
def _poll_scan_status(self):
|
|
"""Poll Plex to check if library scan is still running"""
|
|
try:
|
|
with self._lock:
|
|
if not self._scan_in_progress:
|
|
logger.debug("Scan no longer in progress, stopping polling")
|
|
return
|
|
|
|
# Check for timeout
|
|
if self._scan_start_time and (time.time() - self._scan_start_time) > self._max_scan_time:
|
|
logger.warning(f"Plex scan timeout reached ({self._max_scan_time}s), assuming completion")
|
|
self._scan_completed()
|
|
return
|
|
|
|
# Check if Plex is still scanning
|
|
is_scanning = self.plex_client.is_library_scanning("Music")
|
|
logger.info(f"DEBUG: Plex scan status check - is_scanning: {is_scanning}")
|
|
|
|
if is_scanning:
|
|
# Still scanning, poll again in 30 seconds
|
|
logger.info("DEBUG: Plex library still scanning, will check again in 30 seconds")
|
|
threading.Timer(30, self._poll_scan_status).start()
|
|
else:
|
|
# Scan completed!
|
|
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
|
|
logger.info(f"Plex library scan detected as completed (took {elapsed_time:.1f} seconds)")
|
|
self._scan_completed()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error polling scan status: {e}")
|
|
# Fallback to assuming completion after error
|
|
self._scan_completed()
|
|
|
|
def _scan_completed(self):
|
|
"""Called when we assume the scan has completed"""
|
|
with self._lock:
|
|
was_in_progress = self._scan_in_progress
|
|
downloads_during_scan = self._downloads_during_scan
|
|
|
|
# Reset scan state
|
|
self._scan_in_progress = False
|
|
|
|
if not was_in_progress:
|
|
logger.debug("Scan completion callback called but scan was not in progress")
|
|
return
|
|
|
|
logger.info("Plex library scan completed")
|
|
|
|
# Call registered completion callbacks
|
|
self._call_completion_callbacks()
|
|
|
|
# Check if we need a follow-up scan
|
|
if downloads_during_scan:
|
|
logger.info("Downloads occurred during scan - triggering follow-up scan")
|
|
self.request_scan("Follow-up scan for downloads during previous scan")
|
|
else:
|
|
logger.info("No downloads during scan - scan cycle complete")
|
|
|
|
def _call_completion_callbacks(self):
|
|
"""Call all registered scan completion callbacks"""
|
|
with self._lock:
|
|
callbacks = self._scan_completion_callbacks.copy() # Copy to avoid lock issues
|
|
|
|
logger.info(f"DEBUG: Calling {len(callbacks)} scan completion callbacks")
|
|
for callback in callbacks:
|
|
try:
|
|
logger.info(f"DEBUG: Executing callback: {callback.__name__}")
|
|
callback()
|
|
logger.info(f"DEBUG: Callback {callback.__name__} completed successfully")
|
|
except Exception as e:
|
|
logger.error(f"Error in scan completion callback {callback.__name__}: {e}")
|
|
|
|
def _reset_scan_state(self):
|
|
"""Reset scan state after an error"""
|
|
with self._lock:
|
|
self._scan_in_progress = False
|
|
self._scan_start_time = None
|
|
|
|
# Cancel periodic updates if running
|
|
if self._periodic_update_timer:
|
|
self._periodic_update_timer.cancel()
|
|
self._periodic_update_timer = None
|
|
self._is_doing_periodic_updates = False
|
|
|
|
def force_scan(self):
|
|
"""
|
|
Force an immediate scan, bypassing debouncing.
|
|
Use sparingly - mainly for manual/administrative triggers.
|
|
"""
|
|
with self._lock:
|
|
if self._timer:
|
|
self._timer.cancel()
|
|
self._timer = None
|
|
|
|
if self._scan_in_progress:
|
|
logger.warning("Force scan requested but scan already in progress")
|
|
return
|
|
|
|
logger.info("Force scan requested - executing immediately")
|
|
self._execute_scan()
|
|
|
|
def get_status(self) -> dict:
|
|
"""Get current status of the scan manager"""
|
|
with self._lock:
|
|
return {
|
|
'scan_in_progress': self._scan_in_progress,
|
|
'downloads_during_scan': self._downloads_during_scan,
|
|
'timer_active': self._timer is not None,
|
|
'delay_seconds': self.delay
|
|
}
|
|
|
|
def shutdown(self):
|
|
"""Clean shutdown - cancel any pending timers"""
|
|
with self._lock:
|
|
if self._timer:
|
|
self._timer.cancel()
|
|
self._timer = None
|
|
|
|
if self._periodic_update_timer:
|
|
self._periodic_update_timer.cancel()
|
|
self._periodic_update_timer = None
|
|
|
|
self._is_doing_periodic_updates = False
|
|
logger.info("PlexScanManager shutdown - cancelled all pending timers") |