From cd45ba0b84be3851e53532d6312962bdf3a318bc Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 21 Jul 2025 12:09:27 -0700 Subject: [PATCH] better --- SPOTIFY_MATCHING_SPEC.md | 30 ++--- ui/pages/sync.py | 285 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 294 insertions(+), 21 deletions(-) diff --git a/SPOTIFY_MATCHING_SPEC.md b/SPOTIFY_MATCHING_SPEC.md index 7631e4ad..001a9b58 100644 --- a/SPOTIFY_MATCHING_SPEC.md +++ b/SPOTIFY_MATCHING_SPEC.md @@ -203,23 +203,23 @@ Playlist Track → Plex Check → (Missing) → Soulseek Search → Quality Filt - ✅ Background worker with progress tracking and confidence scoring - ✅ String normalization, similarity scoring, and duration matching -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 +2. **✅ COMPLETED - 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. **⏳ 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 +3. **✅ COMPLETED - 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. **✅ COMPLETED - 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 5. **⏳ PENDING - Download Queue Integration** - Extend downloads.py with minimal custom path support diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 1538f67b..0af6cb13 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -156,6 +156,73 @@ class PlaylistTrackAnalysisWorker(QRunnable): print(f"Error calculating track confidence: {e}") return 0.0 +class TrackDownloadWorkerSignals(QObject): + """Signals for track download worker""" + download_completed = pyqtSignal(int, int, str) # download_index, track_index, download_id + download_failed = pyqtSignal(int, int, str) # download_index, track_index, error_message + +class TrackDownloadWorker(QRunnable): + """Background worker to download individual tracks via Soulseek""" + + def __init__(self, spotify_track, soulseek_client, download_index, track_index): + super().__init__() + self.spotify_track = spotify_track + self.soulseek_client = soulseek_client + self.download_index = download_index + self.track_index = track_index + self.signals = TrackDownloadWorkerSignals() + self._cancelled = False + + def cancel(self): + """Cancel the download operation""" + self._cancelled = True + + def run(self): + """Download the track via Soulseek""" + try: + if self._cancelled or not self.soulseek_client: + return + + # Create search queries - try track name first, then artist + track + track_name = self.spotify_track.name + artist_name = self.spotify_track.artists[0] if self.spotify_track.artists else "" + + search_queries = [] + search_queries.append(track_name) # Try track name only first + if artist_name: + search_queries.append(f"{artist_name} {track_name}") # Then artist + track + + download_id = None + + # Try each search query until we find a download + for query in search_queries: + if self._cancelled: + return + + print(f"🔍 Searching Soulseek: {query}") + + # Use the async method (need to run in sync context) + import asyncio + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + download_id = loop.run_until_complete( + self.soulseek_client.search_and_download_best(query) + ) + if download_id: + break # Success - stop trying other queries + finally: + loop.close() + + if download_id: + self.signals.download_completed.emit(self.download_index, self.track_index, download_id) + else: + self.signals.download_failed.emit(self.download_index, self.track_index, "No search results found") + + except Exception as e: + self.signals.download_failed.emit(self.download_index, self.track_index, str(e)) + class PlaylistLoaderThread(QThread): playlist_loaded = pyqtSignal(object) # Single playlist loading_finished = pyqtSignal(int) # Total count @@ -1572,6 +1639,78 @@ class SyncPage(QWidget): continue # Keep the stretch spacer else: self.playlist_layout.removeItem(item) + + def create_download_progress_bubble(self, progress_info): + """Create a progress bubble for ongoing downloads""" + if hasattr(self, 'progress_bubble'): + # Remove existing bubble + self.progress_bubble.deleteLater() + + # Create bubble widget + self.progress_bubble = QFrame(self) + self.progress_bubble.setFixedSize(300, 80) + self.progress_bubble.setStyleSheet(""" + QFrame { + background-color: #1db954; + border: 2px solid #169441; + border-radius: 15px; + color: #000000; + } + QLabel { + color: #000000; + font-weight: bold; + } + """) + + # Create bubble layout + bubble_layout = QVBoxLayout(self.progress_bubble) + bubble_layout.setContentsMargins(10, 8, 10, 8) + bubble_layout.setSpacing(5) + + # Title + title = QLabel(f"📥 {progress_info['playlist_name']}") + title.setFont(QFont("Arial", 11, QFont.Weight.Bold)) + + # Progress text + if progress_info['analysis_complete']: + progress_text = f"Downloading: {progress_info['download_progress']}/{progress_info['download_total']}" + else: + progress_text = f"Analyzing: {progress_info['analysis_progress']}/{progress_info['total_tracks']}" + + progress_label = QLabel(progress_text) + progress_label.setFont(QFont("Arial", 9)) + + # Click to reopen + click_label = QLabel("Click to view details") + click_label.setFont(QFont("Arial", 8)) + click_label.setStyleSheet("color: #666666;") + + bubble_layout.addWidget(title) + bubble_layout.addWidget(progress_label) + bubble_layout.addWidget(click_label) + + # Position bubble in top-right corner + self.progress_bubble.move(self.width() - 320, 20) + self.progress_bubble.show() + + # Make bubble clickable + self.progress_bubble.mousePressEvent = lambda event: self.reopen_download_modal(progress_info['modal_reference']) + + # Store reference for updates + self.progress_bubble.progress_info = progress_info + + def reopen_download_modal(self, modal_reference): + """Reopen the download modal from the progress bubble""" + if modal_reference and not modal_reference.isVisible(): + # Remove bubble + if hasattr(self, 'progress_bubble'): + self.progress_bubble.deleteLater() + delattr(self, 'progress_bubble') + + # Show modal + modal_reference.show() + modal_reference.activateWindow() + modal_reference.raise_() class DownloadMissingTracksModal(QDialog): @@ -2305,7 +2444,7 @@ class DownloadMissingTracksModal(QDialog): self.analysis_progress.setVisible(False) def start_download_progress(self): - """Start download progress tracking (simulated for now)""" + """Start actual download progress tracking""" print(f"🚀 Starting download progress for {len(self.missing_tracks)} tracks") # Show download progress bar @@ -2313,10 +2452,140 @@ class DownloadMissingTracksModal(QDialog): 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() + # Start real downloads + self.start_soulseek_downloads() + def start_soulseek_downloads(self): + """Start real Soulseek downloads for missing tracks""" + if not self.missing_tracks: + return + + # Get Soulseek client from parent + soulseek_client = getattr(self.parent_page, 'soulseek_client', None) + if not soulseek_client: + QMessageBox.critical(self, "Soulseek Unavailable", + "Soulseek client not available. Please check your configuration.") + return + + # Create download worker + self.download_in_progress = True + self.current_download = 0 + + # Start downloading first track + self.download_next_track() + + def download_next_track(self): + """Download the next missing track""" + if self.current_download >= len(self.missing_tracks): + # All downloads complete + self.on_all_downloads_complete() + return + + track_result = self.missing_tracks[self.current_download] + track = track_result.spotify_track + track_index = self.find_track_index(track) + + print(f"🎵 Downloading track {self.current_download + 1}/{len(self.missing_tracks)}: {track.name}") + + # Update table to show downloading status + if track_index is not None: + downloading_item = QTableWidgetItem("⏬ Downloading") + downloading_item.setFlags(downloading_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + downloading_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.track_table.setItem(track_index, 4, downloading_item) + + # Create download worker + soulseek_client = getattr(self.parent_page, 'soulseek_client', None) + worker = TrackDownloadWorker(track, soulseek_client, self.current_download, track_index) + worker.signals.download_completed.connect(self.on_track_download_complete) + worker.signals.download_failed.connect(self.on_track_download_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: + thread_pool = QThreadPool() + self.fallback_pools.append(thread_pool) + thread_pool.start(worker) + + def find_track_index(self, spotify_track): + """Find the table row index for a given Spotify track""" + for i, playlist_track in enumerate(self.playlist.tracks): + if (playlist_track.name == spotify_track.name and + playlist_track.artists[0] == spotify_track.artists[0]): + return i + return None + + def on_track_download_complete(self, download_index, track_index, download_id): + """Handle successful track download""" + print(f"✅ Download {download_index + 1} completed: {download_id}") + + # Update table row + if track_index is not None: + downloaded_item = QTableWidgetItem("✅ Downloaded") + 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) + + # Continue with next download + self.download_next_track() + + def on_track_download_failed(self, download_index, track_index, error_message): + """Handle failed track download""" + print(f"❌ Download {download_index + 1} failed: {error_message}") + + # Update table row + if track_index is not None: + failed_item = QTableWidgetItem("❌ Failed") + failed_item.setFlags(failed_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + failed_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.track_table.setItem(track_index, 4, failed_item) + + # Update progress and continue + self.current_download += 1 + self.download_progress.setValue(self.current_download) + + # Continue with next download + self.download_next_track() + + def on_all_downloads_complete(self): + """Handle completion of all downloads""" + self.download_in_progress = False + print("🎉 All downloads completed!") + + # Update button text + self.begin_search_btn.setText("Downloads Complete") + + QMessageBox.information(self, "Downloads Complete", + f"Completed downloading {len(self.missing_tracks)} missing tracks!") + + def create_progress_bubble(self): + """Create a progress bubble on the main sync page""" + if hasattr(self.parent_page, 'create_download_progress_bubble'): + # Calculate current progress + total_tracks = len(self.playlist.tracks) + completed_analysis = self.analysis_progress.value() if self.analysis_complete else 0 + completed_downloads = self.download_progress.value() if self.download_in_progress else 0 + + progress_info = { + 'playlist_name': self.playlist.name, + 'total_tracks': total_tracks, + 'analysis_complete': self.analysis_complete, + 'analysis_progress': completed_analysis, + 'download_progress': completed_downloads, + 'download_total': len(self.missing_tracks) if hasattr(self, 'missing_tracks') else 0, + 'modal_reference': self # Keep reference to reopen modal + } + + self.parent_page.create_download_progress_bubble(progress_info) + def simulate_downloads(self): """Simulate download process (placeholder for real implementation)""" from PyQt6.QtCore import QTimer @@ -2364,8 +2633,12 @@ class DownloadMissingTracksModal(QDialog): def on_close_clicked(self): """Handle Close button - closes modal without canceling operations""" - # Don't cancel operations, just close modal - self.reject() # Close modal + # If operations are in progress, create progress bubble + if self.download_in_progress or not self.analysis_complete: + self.create_progress_bubble() + + # Close modal without canceling operations + self.reject() def cancel_operations(self): """Cancel any ongoing operations"""