diff --git a/SPOTIFY_MATCHING_SPEC.md b/SPOTIFY_MATCHING_SPEC.md index e7d897a7..7631e4ad 100644 --- a/SPOTIFY_MATCHING_SPEC.md +++ b/SPOTIFY_MATCHING_SPEC.md @@ -192,40 +192,46 @@ Playlist Track → Plex Check → (Missing) → Soulseek Search → Quality Filt - File operation error handling - API rate limiting considerations -### 🆕 NEW FEATURE: DOWNLOAD MISSING TRACKS +### 🆕 NEW FEATURE: ENHANCED DOWNLOAD MISSING TRACKS MODAL -#### 📋 PLANNED IMPLEMENTATION STEPS: +#### 📋 CURRENT IMPLEMENTATION STEPS: -1. **✅ COMPLETED - Hook "Download Missing Tracks" Button** - - ✅ Connect button click to new playlist analysis workflow +1. **✅ COMPLETED - Basic Infrastructure** + - ✅ Hook "Download Missing Tracks" button to workflow - ✅ Implement basic playlist track retrieval from Spotify - - ✅ Add basic progress indication for user feedback - - ✅ Added graceful Plex connection handling + - ✅ Create PlaylistTrackAnalysisWorker for Plex analysis + - ✅ Background worker with progress tracking and confidence scoring + - ✅ String normalization, similarity scoring, and duration matching -2. **✅ COMPLETED - Plex Integration for Track Existence Checking** - - ✅ Implement intelligent track matching against Plex library - - ✅ Use confidence scoring (≥0.8) to determine existing tracks - - ✅ Handle Plex connection failures gracefully (download all tracks) - - ✅ Background worker with progress tracking - - ✅ String normalization and similarity scoring - - ✅ Duration matching with 10% tolerance +2. **🔄 IN PROGRESS - Enhanced Modal Interface** + - Replace simple QMessageBox with sophisticated modal + - Modal closes sync window and opens new interface + - Dashboard with live counters: Total Tracks, Matched Tracks, To Download + - Enhanced track table with Matched and Downloaded status columns + - Dual progress bar system (Plex analysis + Download progress) + - Three-button system: Begin Search, Cancel, Close -3. **🔄 IN PROGRESS - Soulseek Search Integration** +3. **⏳ PENDING - Modal State Persistence** + - Progress bubble system when modal is closed during operations + - Clickable bubble to reopen modal and review progress + - Maintain operation state across modal open/close cycles + +4. **⏳ PENDING - Soulseek Search Integration** - Implement per-track search strategy (track name → artist + track name) - Leverage existing search filtering and quality selection - Use async operations for performance -4. **⏳ PENDING - Download Queue Integration** +5. **⏳ PENDING - Download Queue Integration** - Extend downloads.py with minimal custom path support - Queue missing tracks with proper folder paths - Integrate with existing download progress tracking -5. **⏳ PENDING - Folder Organization** +6. **⏳ PENDING - Folder Organization** - Apply matched download folder structure - Implement album vs single detection per track - Use Spotify metadata for accurate organization -6. **⏳ PENDING - Error Handling & User Feedback** +7. **⏳ PENDING - Error Handling & User Feedback** - Track failed matches for manual review - Provide real-time progress updates - Implement retry logic for API failures diff --git a/ui/pages/sync.py b/ui/pages/sync.py index dde45a32..162d985b 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -2,7 +2,7 @@ from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QPushButton, QListWidget, QListWidgetItem, QProgressBar, QTextEdit, QCheckBox, QComboBox, QScrollArea, QSizePolicy, QMessageBox, QDialog, - QTableWidget, QTableWidgetItem, QHeaderView) + QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve, QRunnable, QThreadPool, QObject from PyQt6.QtGui import QFont from dataclasses import dataclass @@ -605,25 +605,47 @@ class PlaylistDetailsModal(QDialog): def on_download_missing_tracks_clicked(self): """Handle Download Missing Tracks button click""" + print("🔄 Download Missing Tracks button clicked!") + if not self.playlist: + print("❌ No playlist selected") QMessageBox.warning(self, "Error", "No playlist selected") return if not self.playlist.tracks: + print("❌ Playlist tracks not loaded") QMessageBox.warning(self, "Error", "Playlist tracks not loaded") return + print(f"✅ Playlist: {self.playlist.name} with {len(self.playlist.tracks)} tracks") + # Get access to parent's Plex and Soulseek clients through parent reference if not hasattr(self.parent_page, 'plex_client'): + print("❌ Plex client not available") QMessageBox.warning(self, "Service Unavailable", "Plex client not available. Please check your configuration.") return + + print("✅ Plex client available") - # Start the download missing tracks workflow + # Create and show the enhanced download missing tracks modal try: - self.start_playlist_missing_tracks_download() + print("🚀 Creating modal...") + modal = DownloadMissingTracksModal(self.playlist, self.parent_page, self) + print("✅ Modal created successfully") + + # Store modal reference to prevent garbage collection + self.download_modal = modal + + print("🖥️ Showing modal...") + result = modal.exec() + print(f"✅ Modal closed with result: {result}") + except Exception as e: - QMessageBox.critical(self, "Download Error", f"Failed to start download: {str(e)}") + print(f"❌ Exception creating modal: {e}") + import traceback + traceback.print_exc() + QMessageBox.critical(self, "Modal Error", f"Failed to open download modal: {str(e)}") def start_playlist_missing_tracks_download(self): """Start the process of downloading missing tracks from playlist""" @@ -1546,4 +1568,817 @@ class SyncPage(QWidget): elif item.spacerItem(): continue # Keep the stretch spacer else: - self.playlist_layout.removeItem(item) \ No newline at end of file + self.playlist_layout.removeItem(item) + + +class DownloadMissingTracksModal(QDialog): + """Enhanced modal for downloading missing tracks with live progress tracking""" + + def __init__(self, playlist, parent_page, sync_modal): + print(f"🏗️ Initializing DownloadMissingTracksModal...") + super().__init__(sync_modal) # Set sync modal as parent + self.playlist = playlist + self.parent_page = parent_page + self.sync_modal = sync_modal + + # State tracking + self.total_tracks = len(playlist.tracks) + self.matched_tracks_count = 0 + self.tracks_to_download_count = 0 + self.analysis_complete = False + self.download_in_progress = False + + print(f"📊 Total tracks: {self.total_tracks}") + + # Track analysis results + self.analysis_results = [] + self.missing_tracks = [] + + # Worker tracking + self.active_workers = [] + self.fallback_pools = [] + + print("🎨 Setting up UI...") + self.setup_ui() + print("✅ Modal initialization complete") + + def setup_ui(self): + """Set up the enhanced modal UI""" + self.setWindowTitle(f"Download Missing Tracks - {self.playlist.name}") + self.resize(1200, 900) # Larger size + self.setModal(True) + + # Set window flags for proper dialog behavior + self.setWindowFlags(Qt.WindowType.Dialog) + + # Improved dark theme styling + self.setStyleSheet(""" + QDialog { + background-color: #1e1e1e; + color: #ffffff; + } + QLabel { + color: #ffffff; + } + QPushButton { + background-color: #1db954; + color: #000000; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: bold; + padding: 10px 20px; + min-width: 100px; + } + QPushButton:hover { + background-color: #1ed760; + } + QPushButton:disabled { + background-color: #404040; + color: #888888; + } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(25, 25, 25, 25) + main_layout.setSpacing(15) + + # Compact header with dashboard in same row + top_section = self.create_compact_top_section() + main_layout.addWidget(top_section) + + # Progress bars section (compact) + progress_section = self.create_progress_section() + main_layout.addWidget(progress_section) + + # Track table (main focus - takes most space) + table_section = self.create_track_table() + main_layout.addWidget(table_section, stretch=1) # Give it all available space + + # Button controls + button_section = self.create_buttons() + main_layout.addWidget(button_section) + + def create_compact_top_section(self): + """Create compact top section with header and dashboard combined""" + top_frame = QFrame() + top_frame.setStyleSheet(""" + QFrame { + background-color: #2d2d2d; + border: 1px solid #444444; + border-radius: 8px; + padding: 15px; + } + """) + + layout = QVBoxLayout(top_frame) + layout.setSpacing(15) + + # Header row + header_layout = QHBoxLayout() + + # Left side - Title and subtitle + title_section = QVBoxLayout() + title_section.setSpacing(2) + + title = QLabel("Download Missing Tracks") + title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + title.setStyleSheet("color: #1db954;") + + subtitle = QLabel(f"Playlist: {self.playlist.name}") + subtitle.setFont(QFont("Arial", 11)) + subtitle.setStyleSheet("color: #aaaaaa;") + + title_section.addWidget(title) + title_section.addWidget(subtitle) + + # Right side - Dashboard counters (horizontal) + dashboard_layout = QHBoxLayout() + dashboard_layout.setSpacing(20) + + # Total Tracks + self.total_card = self.create_compact_counter_card("📀 Total", str(self.total_tracks), "#1db954") + + # Matched Tracks + self.matched_card = self.create_compact_counter_card("✅ Found", "0", "#4CAF50") + + # To Download + self.download_card = self.create_compact_counter_card("⬇️ Missing", "0", "#ff6b6b") + + dashboard_layout.addWidget(self.total_card) + dashboard_layout.addWidget(self.matched_card) + dashboard_layout.addWidget(self.download_card) + dashboard_layout.addStretch() + + header_layout.addLayout(title_section) + header_layout.addStretch() + header_layout.addLayout(dashboard_layout) + + layout.addLayout(header_layout) + + return top_frame + + def create_dashboard(self): + """Create dashboard with live counters""" + dashboard_frame = QFrame() + dashboard_frame.setStyleSheet(""" + QFrame { + background-color: #404040; + border: 1px solid #555555; + border-radius: 8px; + padding: 15px; + margin-bottom: 5px; + } + """) + + layout = QHBoxLayout(dashboard_frame) + layout.setSpacing(30) + + # Total Tracks + self.total_card = self.create_counter_card("📀 Total Tracks", str(self.total_tracks), "#1db954") + + # Matched Tracks + self.matched_card = self.create_counter_card("✅ Matched", "0", "#1ed760") + + # To Download + self.download_card = self.create_counter_card("⬇️ To Download", "0", "#ff6b6b") + + layout.addWidget(self.total_card) + layout.addWidget(self.matched_card) + layout.addWidget(self.download_card) + layout.addStretch() + + return dashboard_frame + + def create_compact_counter_card(self, title, count, color): + """Create a compact counter card widget""" + card = QFrame() + card.setStyleSheet(f""" + QFrame {{ + background-color: #3a3a3a; + border: 2px solid {color}; + border-radius: 6px; + padding: 8px 12px; + min-width: 80px; + }} + """) + + layout = QVBoxLayout(card) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + count_label = QLabel(count) + count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + count_label.setStyleSheet(f"color: {color}; background: transparent;") + count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + title_label = QLabel(title) + title_label.setFont(QFont("Arial", 9)) + title_label.setStyleSheet("color: #cccccc; background: transparent;") + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + layout.addWidget(count_label) + layout.addWidget(title_label) + + # Store references for updates + if "Total" in title: + self.total_count_label = count_label + elif "Found" in title: + self.matched_count_label = count_label + elif "Missing" in title: + self.download_count_label = count_label + + return card + + def create_counter_card(self, title, count, color): + """Create a counter card widget""" + card = QFrame() + card.setStyleSheet(f""" + QFrame {{ + background-color: #333333; + border: 2px solid {color}; + border-radius: 6px; + padding: 10px; + min-width: 120px; + }} + """) + + layout = QVBoxLayout(card) + layout.setSpacing(5) + + title_label = QLabel(title) + title_label.setFont(QFont("Arial", 10)) + title_label.setStyleSheet("color: #b3b3b3;") + + count_label = QLabel(count) + count_label.setFont(QFont("Arial", 20, QFont.Weight.Bold)) + count_label.setStyleSheet(f"color: {color};") + count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + layout.addWidget(title_label) + layout.addWidget(count_label) + + # Store references for updates + if "Total" in title: + self.total_count_label = count_label + elif "Matched" in title: + self.matched_count_label = count_label + elif "Download" in title: + self.download_count_label = count_label + + return card + + def create_progress_section(self): + """Create compact dual progress bar section""" + progress_frame = QFrame() + progress_frame.setStyleSheet(""" + QFrame { + background-color: #2d2d2d; + border: 1px solid #444444; + border-radius: 8px; + padding: 12px; + } + """) + + layout = QVBoxLayout(progress_frame) + layout.setSpacing(8) + + # Plex Analysis Progress + analysis_container = QVBoxLayout() + analysis_container.setSpacing(4) + + analysis_label = QLabel("🔍 Plex Analysis") + analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) + analysis_label.setStyleSheet("color: #cccccc;") + + self.analysis_progress = QProgressBar() + self.analysis_progress.setFixedHeight(20) + self.analysis_progress.setStyleSheet(""" + QProgressBar { + border: 1px solid #555555; + border-radius: 10px; + text-align: center; + background-color: #444444; + color: #ffffff; + font-size: 11px; + font-weight: bold; + } + QProgressBar::chunk { + background-color: #1db954; + border-radius: 9px; + } + """) + self.analysis_progress.setVisible(False) + + analysis_container.addWidget(analysis_label) + analysis_container.addWidget(self.analysis_progress) + + # Download Progress + download_container = QVBoxLayout() + download_container.setSpacing(4) + + download_label = QLabel("⬇️ Download Progress") + download_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) + download_label.setStyleSheet("color: #cccccc;") + + self.download_progress = QProgressBar() + self.download_progress.setFixedHeight(20) + self.download_progress.setStyleSheet(""" + QProgressBar { + border: 1px solid #555555; + border-radius: 10px; + text-align: center; + background-color: #444444; + color: #ffffff; + font-size: 11px; + font-weight: bold; + } + QProgressBar::chunk { + background-color: #ff6b6b; + border-radius: 9px; + } + """) + self.download_progress.setVisible(False) + + download_container.addWidget(download_label) + download_container.addWidget(self.download_progress) + + layout.addLayout(analysis_container) + layout.addLayout(download_container) + + return progress_frame + + def create_track_table(self): + """Create enhanced track table with Matched/Downloaded columns""" + table_frame = QFrame() + table_frame.setStyleSheet(""" + QFrame { + background-color: #2d2d2d; + border: 1px solid #444444; + border-radius: 8px; + padding: 0px; + } + """) + + layout = QVBoxLayout(table_frame) + layout.setContentsMargins(15, 15, 15, 15) # Internal padding for spacing + layout.setSpacing(10) + + # Table header + header_label = QLabel("📋 Track Analysis") + header_label.setFont(QFont("Arial", 13, QFont.Weight.Bold)) + header_label.setStyleSheet("color: #ffffff; padding: 5px;") + + # Create custom header row instead of table header + custom_header = self.create_custom_header() + + # Create table WITHOUT header + self.track_table = QTableWidget() + self.track_table.setColumnCount(5) + self.track_table.horizontalHeader().setVisible(False) # Hide the problematic header + + # Clean table styling (no header needed now) + self.track_table.setStyleSheet(""" + QTableWidget { + background-color: #3a3a3a; + alternate-background-color: #424242; + selection-background-color: #1db954; + selection-color: #000000; + gridline-color: #555555; + color: #ffffff; + border: 1px solid #555555; + border-top: none; + font-size: 12px; + } + QTableWidget::item { + padding: 12px 8px; + border-bottom: 1px solid #4a4a4a; + } + QTableWidget::item:selected { + background-color: #1db954; + color: #000000; + } + """) + + # Configure column sizes to EXACTLY match custom header + header = self.track_table.horizontalHeader() + + # Set fixed columns first to match header exactly + self.track_table.setColumnWidth(2, 90) # Duration - fixed + self.track_table.setColumnWidth(3, 140) # Matched - fixed + + # Configure resize modes for proper alignment + # Two stretching columns (Track, Artist) and one last section stretch (Downloaded) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) # Track - flexible + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Artist - flexible + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) # Duration - fixed 90px + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Matched - fixed 140px + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive) # Downloaded - will be handled by setStretchLastSection + + # Let the last section (Downloaded) stretch to fill remaining space + header.setStretchLastSection(True) + + # Better table behavior + self.track_table.setAlternatingRowColors(True) + self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.track_table.setShowGrid(True) + self.track_table.setGridStyle(Qt.PenStyle.SolidLine) + + # Set row height for better readability + self.track_table.verticalHeader().setDefaultSectionSize(35) + self.track_table.verticalHeader().setVisible(False) + + # Populate with initial track data + self.populate_track_table() + + layout.addWidget(header_label) + layout.addWidget(custom_header) + layout.addWidget(self.track_table) + + return table_frame + + def create_custom_header(self): + """Create a custom header row with visible labels""" + header_frame = QFrame() + header_frame.setStyleSheet(""" + QFrame { + background-color: #1db954; + border: 1px solid #169441; + border-radius: 6px; + padding: 0px; + margin: 0px; + } + """) + + layout = QHBoxLayout(header_frame) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(1) + + # Create header labels with same proportions as table columns + headers = ["Track", "Artist", "Duration", "Matched", "Downloaded"] + + # Track - stretch + track_label = QLabel("Track") + track_label.setStyleSheet(""" + QLabel { + background-color: transparent; + color: #000000; + font-weight: bold; + font-size: 13px; + padding: 12px 8px; + border-right: 1px solid #169441; + } + """) + track_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Artist - stretch + artist_label = QLabel("Artist") + artist_label.setStyleSheet(""" + QLabel { + background-color: transparent; + color: #000000; + font-weight: bold; + font-size: 13px; + padding: 12px 8px; + border-right: 1px solid #169441; + } + """) + artist_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Duration - fixed width + duration_label = QLabel("Duration") + duration_label.setFixedWidth(90) + duration_label.setStyleSheet(""" + QLabel { + background-color: transparent; + color: #000000; + font-weight: bold; + font-size: 13px; + padding: 12px 8px; + border-right: 1px solid #169441; + } + """) + duration_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Matched - fixed width + matched_label = QLabel("Matched") + matched_label.setFixedWidth(140) + matched_label.setStyleSheet(""" + QLabel { + background-color: transparent; + color: #000000; + font-weight: bold; + font-size: 13px; + padding: 12px 8px; + border-right: 1px solid #169441; + } + """) + matched_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Downloaded - stretch to fill remaining space + downloaded_label = QLabel("Downloaded") + downloaded_label.setStyleSheet(""" + QLabel { + background-color: transparent; + color: #000000; + font-weight: bold; + font-size: 13px; + padding: 12px 8px; + } + """) + downloaded_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + layout.addWidget(track_label, stretch=1) + layout.addWidget(artist_label, stretch=1) + layout.addWidget(duration_label) + layout.addWidget(matched_label) + layout.addWidget(downloaded_label, stretch=1) # Let it stretch to fill remaining space + + return header_frame + + def populate_track_table(self): + """Populate track table with playlist tracks""" + self.track_table.setRowCount(len(self.playlist.tracks)) + + for i, track in enumerate(self.playlist.tracks): + # Track name + track_item = QTableWidgetItem(track.name) + track_item.setFlags(track_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.track_table.setItem(i, 0, track_item) + + # Artist + artist_name = track.artists[0] if track.artists else "Unknown Artist" + artist_item = QTableWidgetItem(artist_name) + artist_item.setFlags(artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.track_table.setItem(i, 1, artist_item) + + # Duration + duration = self.format_duration(track.duration_ms) + duration_item = QTableWidgetItem(duration) + duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + duration_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.track_table.setItem(i, 2, duration_item) + + # Matched status (initially pending) + matched_item = QTableWidgetItem("⏳ Pending") + matched_item.setFlags(matched_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.track_table.setItem(i, 3, matched_item) + + # Downloaded status (initially pending) + downloaded_item = QTableWidgetItem("⏳ Pending") + downloaded_item.setFlags(downloaded_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + downloaded_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.track_table.setItem(i, 4, downloaded_item) + + def format_duration(self, duration_ms): + """Convert milliseconds to MM:SS format""" + seconds = duration_ms // 1000 + minutes = seconds // 60 + seconds = seconds % 60 + return f"{minutes}:{seconds:02d}" + + def create_buttons(self): + """Create improved button section""" + button_frame = QFrame() + button_frame.setStyleSheet(""" + QFrame { + background-color: transparent; + padding: 10px; + } + """) + + layout = QHBoxLayout(button_frame) + layout.setSpacing(15) + layout.setContentsMargins(0, 10, 0, 0) + + # Begin Search button + self.begin_search_btn = QPushButton("Begin Search") + self.begin_search_btn.setFixedSize(160, 40) + self.begin_search_btn.clicked.connect(self.on_begin_search_clicked) + + # Cancel button + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.setFixedSize(110, 40) + self.cancel_btn.setStyleSheet(""" + QPushButton { + background-color: #d32f2f; + color: #ffffff; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: bold; + padding: 10px 20px; + } + QPushButton:hover { + background-color: #f44336; + } + QPushButton:pressed { + background-color: #b71c1c; + } + """) + self.cancel_btn.clicked.connect(self.on_cancel_clicked) + + # Close button + self.close_btn = QPushButton("Close") + self.close_btn.setFixedSize(110, 40) + self.close_btn.setStyleSheet(""" + QPushButton { + background-color: #616161; + color: #ffffff; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: bold; + padding: 10px 20px; + } + QPushButton:hover { + background-color: #757575; + } + QPushButton:pressed { + background-color: #424242; + } + """) + self.close_btn.clicked.connect(self.on_close_clicked) + + layout.addStretch() + layout.addWidget(self.begin_search_btn) + layout.addWidget(self.cancel_btn) + layout.addWidget(self.close_btn) + + return button_frame + + def on_begin_search_clicked(self): + """Handle Begin Search button click - starts Plex analysis""" + self.begin_search_btn.setEnabled(False) + self.begin_search_btn.setText("Searching Plex...") + + # Show and reset analysis progress bar + self.analysis_progress.setVisible(True) + self.analysis_progress.setMaximum(self.total_tracks) + self.analysis_progress.setValue(0) + + # Start Plex analysis + self.start_plex_analysis() + + def start_plex_analysis(self): + """Start Plex analysis using existing worker""" + plex_client = getattr(self.parent_page, 'plex_client', None) + worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, plex_client) + + # Connect signals for live updates + worker.signals.analysis_started.connect(self.on_analysis_started) + worker.signals.track_analyzed.connect(self.on_track_analyzed) + worker.signals.analysis_completed.connect(self.on_analysis_completed) + worker.signals.analysis_failed.connect(self.on_analysis_failed) + + # Track worker for cleanup + self.active_workers.append(worker) + + # Submit to thread pool + if hasattr(self.parent_page, 'thread_pool'): + self.parent_page.thread_pool.start(worker) + else: + # Create fallback thread pool + thread_pool = QThreadPool() + self.fallback_pools.append(thread_pool) + thread_pool.start(worker) + + def on_analysis_started(self, total_tracks): + """Handle analysis start""" + print(f"🔍 Analysis started for {total_tracks} tracks") + + def on_track_analyzed(self, track_index, result): + """Handle individual track analysis completion with live UI updates""" + # Update progress bar + self.analysis_progress.setValue(track_index) + + # Update counters and table + if result.exists_in_plex: + # Track found in Plex + matched_text = f"✅ Found ({result.confidence:.1f})" + self.matched_tracks_count += 1 + self.matched_count_label.setText(str(self.matched_tracks_count)) + else: + # Track missing from Plex - will need download + matched_text = "❌ Missing" + self.tracks_to_download_count += 1 + self.download_count_label.setText(str(self.tracks_to_download_count)) + + # Update table row + matched_item = QTableWidgetItem(matched_text) + matched_item.setFlags(matched_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.track_table.setItem(track_index - 1, 3, matched_item) + + print(f" Track {track_index}: {result.spotify_track.name} - {'Found' if result.exists_in_plex else 'Missing'}") + + def on_analysis_completed(self, results): + """Handle analysis completion""" + self.analysis_complete = True + self.analysis_results = results + self.missing_tracks = [r for r in results if not r.exists_in_plex] + + print(f"✅ Analysis complete: {len(self.missing_tracks)} to download, {self.matched_tracks_count} matched") + + # Update UI + self.begin_search_btn.setText("Analysis Complete") + + if self.missing_tracks: + # Automatically start download progress + self.start_download_progress() + else: + QMessageBox.information(self, "Analysis Complete", + "All tracks already exist in Plex library!\nNo downloads needed.") + + def on_analysis_failed(self, error_message): + """Handle analysis failure""" + print(f"❌ Analysis failed: {error_message}") + QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}") + + # Reset UI + self.begin_search_btn.setEnabled(True) + self.begin_search_btn.setText("Begin Search") + self.analysis_progress.setVisible(False) + + def start_download_progress(self): + """Start download progress tracking (simulated for now)""" + print(f"🚀 Starting download progress for {len(self.missing_tracks)} tracks") + + # Show download progress bar + self.download_progress.setVisible(True) + self.download_progress.setMaximum(len(self.missing_tracks)) + self.download_progress.setValue(0) + + # TODO: Implement actual download integration + # For now, simulate download progress + self.simulate_downloads() + + def simulate_downloads(self): + """Simulate download process (placeholder for real implementation)""" + from PyQt6.QtCore import QTimer + + self.current_download = 0 + self.download_timer = QTimer() + self.download_timer.timeout.connect(self.simulate_next_download) + self.download_timer.start(1500) # Simulate 1.5 seconds per download + + def simulate_next_download(self): + """Simulate next download completion""" + if self.current_download < len(self.missing_tracks): + # Find the track in the table and update its status + missing_result = self.missing_tracks[self.current_download] + + # Find track index in original playlist + track_index = None + for i, track in enumerate(self.playlist.tracks): + if track.id == missing_result.spotify_track.id: + track_index = i + break + + if track_index is not None: + # Update Downloaded column + downloaded_item = QTableWidgetItem("✅ Complete") + downloaded_item.setFlags(downloaded_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + downloaded_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.track_table.setItem(track_index, 4, downloaded_item) + + # Update progress + self.current_download += 1 + self.download_progress.setValue(self.current_download) + + else: + # All downloads complete + self.download_timer.stop() + self.begin_search_btn.setText("Downloads Complete") + QMessageBox.information(self, "Downloads Complete", + f"Successfully downloaded {len(self.missing_tracks)} missing tracks!") + + def on_cancel_clicked(self): + """Handle Cancel button - cancels operations and closes modal""" + self.cancel_operations() + self.reject() # Close modal with cancel result + + def on_close_clicked(self): + """Handle Close button - closes modal without canceling operations""" + # Don't cancel operations, just close modal + self.reject() # Close modal + + def cancel_operations(self): + """Cancel any ongoing operations""" + # Cancel workers + for worker in self.active_workers: + if hasattr(worker, 'cancel'): + worker.cancel() + + # Stop timers + if hasattr(self, 'download_timer'): + self.download_timer.stop() + + print("🛑 Operations cancelled") + + def closeEvent(self, event): + """Handle modal close event""" + # Only cancel if user explicitly clicked Cancel + # For Close button or X button, preserve operations + event.accept() \ No newline at end of file