diff --git a/core/__pycache__/plex_client.cpython-312.pyc b/core/__pycache__/plex_client.cpython-312.pyc index 040ba5ad..22ed4406 100644 Binary files a/core/__pycache__/plex_client.cpython-312.pyc and b/core/__pycache__/plex_client.cpython-312.pyc differ diff --git a/core/__pycache__/plex_scan_manager.cpython-312.pyc b/core/__pycache__/plex_scan_manager.cpython-312.pyc new file mode 100644 index 00000000..5b2c26c3 Binary files /dev/null and b/core/__pycache__/plex_scan_manager.cpython-312.pyc differ diff --git a/core/plex_client.py b/core/plex_client.py index e5079e31..35bd490b 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -10,6 +10,7 @@ from datetime import datetime, timedelta import re from utils.logging_config import get_logger from config.settings import config_manager +import threading logger = get_logger("plex_client") @@ -630,6 +631,20 @@ class PlexClient: logger.error(f"Error updating track metadata: {e}") return False + def trigger_library_scan(self, library_name: str = "Music") -> bool: + """Trigger Plex library scan for the specified library""" + if not self.ensure_connection(): + return False + + try: + library = self.server.library.section(library_name) + library.update() # Non-blocking scan request + logger.info(f"🎵 Triggered Plex library scan for '{library_name}'") + return True + except Exception as e: + logger.error(f"Failed to trigger library scan for '{library_name}': {e}") + return False + def search_albums(self, album_name: str = "", artist_name: str = "", limit: int = 20) -> List[Dict[str, Any]]: """Search for albums in Plex library""" if not self.ensure_connection() or not self.music_library: diff --git a/core/plex_scan_manager.py b/core/plex_scan_manager.py new file mode 100644 index 00000000..06ca8602 --- /dev/null +++ b/core/plex_scan_manager.py @@ -0,0 +1,151 @@ +#!/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() + + 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) + """ + 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 _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 + + logger.info("🎵 Starting Plex library scan...") + + try: + success = self.plex_client.trigger_library_scan() + + if success: + logger.info("✅ Plex library scan initiated successfully") + # Start a timer to check for follow-up scans + # Use a reasonable delay assuming scan takes at least 30 seconds + threading.Timer(30, self._scan_completed).start() + 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 _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") + + # 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 _reset_scan_state(self): + """Reset scan state after an error""" + with self._lock: + self._scan_in_progress = 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 + logger.info("PlexScanManager shutdown - cancelled pending scan") \ No newline at end of file diff --git a/ui/pages/__pycache__/artists.cpython-312.pyc b/ui/pages/__pycache__/artists.cpython-312.pyc index d7d56fcf..e208ce27 100644 Binary files a/ui/pages/__pycache__/artists.cpython-312.pyc and b/ui/pages/__pycache__/artists.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/sync.cpython-312.pyc b/ui/pages/__pycache__/sync.cpython-312.pyc index c50db33c..6489d90e 100644 Binary files a/ui/pages/__pycache__/sync.cpython-312.pyc and b/ui/pages/__pycache__/sync.cpython-312.pyc differ diff --git a/ui/pages/artists.py b/ui/pages/artists.py index 7aea5362..2c3e8fd3 100644 --- a/ui/pages/artists.py +++ b/ui/pages/artists.py @@ -18,6 +18,7 @@ from core.spotify_client import SpotifyClient, Artist, Album from core.plex_client import PlexClient from core.soulseek_client import SoulseekClient, AlbumResult from core.matching_engine import MusicMatchingEngine +from core.plex_scan_manager import PlexScanManager from database.music_database import get_database import asyncio @@ -1740,6 +1741,7 @@ class DownloadMissingAlbumTracksModal(QDialog): self.album = album self.album_card = album_card self.parent_page = parent_page + self.parent_artists_page = parent_page # Reference to artists page for scan manager self.downloads_page = downloads_page self.plex_client = plex_client self.matching_engine = MusicMatchingEngine() @@ -2648,6 +2650,11 @@ class DownloadMissingAlbumTracksModal(QDialog): self.process_finished.emit() except RuntimeError as e: print(f"⚠️ Modal object deleted during downloads complete signal: {e}") + + # Request Plex library scan if we have successful downloads + if self.successful_downloads > 0 and hasattr(self, 'parent_artists_page') and self.parent_artists_page.scan_manager: + album_name = getattr(self.album, 'name', 'Unknown Album') + self.parent_artists_page.scan_manager.request_scan(f"Album download completed: {album_name} ({self.successful_downloads} tracks)") # Determine the final message based on success or failure if self.permanently_failed_tracks: @@ -2815,9 +2822,13 @@ class DownloadMissingAlbumTracksModal(QDialog): def on_manual_match_resolved(self, resolved_track_info): """Handle a track being successfully resolved by the ManualMatchModal""" + print(f"🔧 Manual match resolved (Artists) - download_index: {resolved_track_info.get('download_index')}, table_index: {resolved_track_info.get('table_index')}") original_failed_track = next((t for t in self.permanently_failed_tracks if t['download_index'] == resolved_track_info['download_index']), None) if original_failed_track: self.permanently_failed_tracks.remove(original_failed_track) + print(f"✅ Removed track from permanently_failed_tracks (Artists) - remaining: {len(self.permanently_failed_tracks)}") + else: + print("⚠️ Could not find original failed track to remove (Artists)") self.update_failed_matches_button() class ArtistsPage(QWidget): @@ -2850,6 +2861,9 @@ class ArtistsPage(QWidget): self.download_status_timer.timeout.connect(self.poll_album_download_statuses) self.download_status_timer.start(2000) # Poll every 2 seconds (consistent with sync.py) self.download_status_pool = QThreadPool() + + # Initialize Plex scan manager (will be set when clients are connected) + self.scan_manager = None self.download_status_pool.setMaxThreadCount(1) # One worker at a time to avoid conflicts self._is_status_update_running = False @@ -2878,6 +2892,11 @@ class ArtistsPage(QWidget): self.soulseek_client.download_path = download_path print(f"✅ Set soulseek_client download path for ArtistsPage to: {download_path}") # --- END FIX --- + + # Initialize Plex scan manager now that clients are available + if self.plex_client: + self.scan_manager = PlexScanManager(self.plex_client, delay_seconds=60) + print("✅ PlexScanManager initialized for ArtistsPage") except Exception as e: print(f"Failed to initialize clients: {e}") diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 409f4c87..d6e64f40 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -16,6 +16,7 @@ import asyncio from core.matching_engine import MusicMatchingEngine from ui.components.toast_manager import ToastType from database.music_database import get_database +from core.plex_scan_manager import PlexScanManager # Define constants for storage STORAGE_DIR = "storage" @@ -1984,6 +1985,11 @@ class SyncPage(QWidget): self.thread_pool = QThreadPool() self.thread_pool.setMaxThreadCount(3) # Limit concurrent Spotify API calls + # Initialize Plex scan manager + self.scan_manager = None + if self.plex_client: + self.scan_manager = PlexScanManager(self.plex_client, delay_seconds=60) + self.setup_ui() # Don't auto-load on startup, but do auto-load when page becomes visible @@ -3564,6 +3570,7 @@ class DownloadMissingTracksModal(QDialog): self.playlist = playlist self.playlist_item = playlist_item self.parent_page = parent_page + self.parent_sync_page = parent_page # Reference to sync page for scan manager self.downloads_page = downloads_page self.matching_engine = MusicMatchingEngine() # State tracking @@ -4163,6 +4170,7 @@ class DownloadMissingTracksModal(QDialog): # Update UI to show the new download has been queued spotify_based_result = self.create_spotify_based_search_result_from_validation(slskd_result, spotify_metadata) + print(f"🔧 Updating table at index {table_index} to '... Queued' for manual retry") self.track_table.setItem(table_index, 4, QTableWidgetItem("... Queued")) # Start the actual download process @@ -4380,6 +4388,7 @@ class DownloadMissingTracksModal(QDialog): track_info['completed'] = True if success: + print(f"🔧 Track {download_index} completed successfully - updating table index {track_info['table_index']} to '✅ Downloaded'") self.track_table.setItem(track_info['table_index'], 4, QTableWidgetItem("✅ Downloaded")) self.downloaded_tracks_count += 1 # --- FIX --- @@ -4387,6 +4396,7 @@ class DownloadMissingTracksModal(QDialog): self.downloaded_count_label.setText(str(self.downloaded_tracks_count)) self.successful_downloads += 1 else: + print(f"🔧 Track {download_index} failed - updating table index {track_info['table_index']} to '❌ Failed'") self.track_table.setItem(track_info['table_index'], 4, QTableWidgetItem("❌ Failed")) self.failed_downloads += 1 if track_info not in self.permanently_failed_tracks: @@ -4421,9 +4431,13 @@ class DownloadMissingTracksModal(QDialog): def on_manual_match_resolved(self, resolved_track_info): """Handles a track being successfully resolved by the ManualMatchModal.""" + print(f"🔧 Manual match resolved - download_index: {resolved_track_info.get('download_index')}, table_index: {resolved_track_info.get('table_index')}") original_failed_track = next((t for t in self.permanently_failed_tracks if t['download_index'] == resolved_track_info['download_index']), None) if original_failed_track: self.permanently_failed_tracks.remove(original_failed_track) + print(f"✅ Removed track from permanently_failed_tracks - remaining: {len(self.permanently_failed_tracks)}") + else: + print("⚠️ Could not find original failed track to remove") self.update_failed_matches_button() def find_track_index_in_playlist(self, spotify_track): @@ -4441,6 +4455,10 @@ class DownloadMissingTracksModal(QDialog): # The process_finished signal is still emitted to unlock the main UI. self.process_finished.emit() + + # Request Plex library scan if we have successful downloads + if self.successful_downloads > 0 and hasattr(self, 'parent_sync_page') and self.parent_sync_page.scan_manager: + self.parent_sync_page.scan_manager.request_scan(f"Playlist download completed ({self.successful_downloads} tracks)") # Determine the final message based on success or failure. if self.permanently_failed_tracks: