diff --git a/core/__pycache__/wishlist_service.cpython-312.pyc b/core/__pycache__/wishlist_service.cpython-312.pyc new file mode 100644 index 00000000..b1536d48 Binary files /dev/null and b/core/__pycache__/wishlist_service.cpython-312.pyc differ diff --git a/core/wishlist_service.py b/core/wishlist_service.py new file mode 100644 index 00000000..2ee0d78c --- /dev/null +++ b/core/wishlist_service.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 + +""" +Wishlist Service - High-level service for managing failed download track wishlist +""" + +from typing import List, Dict, Any, Optional +from datetime import datetime +from database.music_database import get_database +from utils.logging_config import get_logger + +logger = get_logger("wishlist_service") + +class WishlistService: + """Service for managing the wishlist of failed download tracks""" + + def __init__(self, database_path: str = "database/music_library.db"): + self.database_path = database_path + self._database = None + + @property + def database(self): + """Get database instance (lazy loading)""" + if self._database is None: + self._database = get_database(self.database_path) + return self._database + + def add_failed_track_from_modal(self, track_info: Dict[str, Any], source_type: str = "unknown", + source_context: Dict[str, Any] = None) -> bool: + """ + Add a failed track from a download modal to the wishlist. + + Args: + track_info: Track info dictionary from modal's permanently_failed_tracks + source_type: Type of source ('playlist', 'album', 'manual') + source_context: Additional context (playlist name, album info, etc.) + """ + try: + # Extract Spotify track data from the track_info structure + spotify_track = self._extract_spotify_track_from_modal_info(track_info) + if not spotify_track: + logger.error(f"Could not extract Spotify track data from modal info") + return False + + # Get failure reason from track_info if available + failure_reason = track_info.get('failure_reason', 'Download failed') + + # Create source info + source_info = source_context or {} + source_info['original_modal_data'] = { + 'download_index': track_info.get('download_index'), + 'table_index': track_info.get('table_index'), + 'candidates': track_info.get('candidates', []) + } + + # Add to wishlist via database + return self.database.add_to_wishlist( + spotify_track_data=spotify_track, + failure_reason=failure_reason, + source_type=source_type, + source_info=source_info + ) + + except Exception as e: + logger.error(f"Error adding failed track to wishlist: {e}") + return False + + def add_spotify_track_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str, + source_type: str = "manual", source_context: Dict[str, Any] = None) -> bool: + """ + Directly add a Spotify track to the wishlist. + + Args: + spotify_track_data: Full Spotify track data dictionary + failure_reason: Reason for the failure + source_type: Source type ('playlist', 'album', 'manual') + source_context: Additional context information + """ + return self.database.add_to_wishlist( + spotify_track_data=spotify_track_data, + failure_reason=failure_reason, + source_type=source_type, + source_info=source_context or {} + ) + + def get_wishlist_tracks_for_download(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: + """ + Get wishlist tracks formatted for the download modal. + Returns tracks in a format similar to playlist tracks for compatibility. + """ + try: + wishlist_tracks = self.database.get_wishlist_tracks(limit=limit) + formatted_tracks = [] + + for wishlist_track in wishlist_tracks: + spotify_data = wishlist_track['spotify_data'] + + # Create a track object similar to what download modals expect + formatted_track = { + 'wishlist_id': wishlist_track['id'], + 'spotify_track_id': wishlist_track['spotify_track_id'], + 'spotify_data': spotify_data, + 'failure_reason': wishlist_track['failure_reason'], + 'retry_count': wishlist_track['retry_count'], + 'date_added': wishlist_track['date_added'], + 'last_attempted': wishlist_track['last_attempted'], + 'source_type': wishlist_track['source_type'], + 'source_info': wishlist_track['source_info'], + + # Format for modal compatibility (similar to Spotify Track objects) + 'id': spotify_data.get('id'), + 'name': spotify_data.get('name', 'Unknown Track'), + 'artists': spotify_data.get('artists', []), + 'album': spotify_data.get('album', {}), + 'duration_ms': spotify_data.get('duration_ms', 0), + 'preview_url': spotify_data.get('preview_url'), + 'external_urls': spotify_data.get('external_urls', {}), + 'popularity': spotify_data.get('popularity', 0) + } + + formatted_tracks.append(formatted_track) + + return formatted_tracks + + except Exception as e: + logger.error(f"Error getting wishlist tracks for download: {e}") + return [] + + def mark_track_download_result(self, spotify_track_id: str, success: bool, error_message: str = None) -> bool: + """ + Mark the result of a download attempt for a wishlist track. + + Args: + spotify_track_id: Spotify track ID + success: Whether the download was successful + error_message: Error message if failed + """ + return self.database.update_wishlist_retry(spotify_track_id, success, error_message) + + def remove_track_from_wishlist(self, spotify_track_id: str) -> bool: + """Remove a track from the wishlist (typically after successful download)""" + return self.database.remove_from_wishlist(spotify_track_id) + + def get_wishlist_count(self) -> int: + """Get the total number of tracks in the wishlist""" + return self.database.get_wishlist_count() + + def clear_wishlist(self) -> bool: + """Clear all tracks from the wishlist""" + return self.database.clear_wishlist() + + def get_wishlist_summary(self) -> Dict[str, Any]: + """Get a summary of the wishlist for dashboard display""" + try: + total_tracks = self.get_wishlist_count() + + if total_tracks == 0: + return { + 'total_tracks': 0, + 'by_source_type': {}, + 'recent_failures': [] + } + + # Get detailed breakdown + wishlist_tracks = self.database.get_wishlist_tracks() + + # Group by source type + by_source_type = {} + recent_failures = [] + + for track in wishlist_tracks: + source_type = track['source_type'] + by_source_type[source_type] = by_source_type.get(source_type, 0) + 1 + + # Keep track of recent failures (last 5) + if len(recent_failures) < 5: + spotify_data = track['spotify_data'] + recent_failures.append({ + 'name': spotify_data.get('name', 'Unknown Track'), + 'artist': spotify_data.get('artists', [{}])[0].get('name', 'Unknown Artist'), + 'failure_reason': track['failure_reason'], + 'retry_count': track['retry_count'], + 'date_added': track['date_added'] + }) + + return { + 'total_tracks': total_tracks, + 'by_source_type': by_source_type, + 'recent_failures': recent_failures + } + + except Exception as e: + logger.error(f"Error getting wishlist summary: {e}") + return { + 'total_tracks': 0, + 'by_source_type': {}, + 'recent_failures': [] + } + + def _extract_spotify_track_from_modal_info(self, track_info: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Extract Spotify track data from modal track_info structure. + Handles different formats from sync.py and artists.py modals. + """ + try: + # Try to find Spotify track data in various locations within track_info + + # Check if we have direct Spotify track reference + if 'spotify_track' in track_info and track_info['spotify_track']: + spotify_track = track_info['spotify_track'] + + # Convert to dictionary if it's an object + if hasattr(spotify_track, '__dict__'): + return self._spotify_track_object_to_dict(spotify_track) + elif isinstance(spotify_track, dict): + return spotify_track + + # Check if we have slskd_result with embedded metadata + if 'slskd_result' in track_info and track_info['slskd_result']: + slskd_result = track_info['slskd_result'] + + # Look for Spotify metadata in the result + if hasattr(slskd_result, 'artist') and hasattr(slskd_result, 'title'): + # Reconstruct basic Spotify track structure + return { + 'id': f"reconstructed_{hash(f'{slskd_result.artist}_{slskd_result.title}')}", + 'name': getattr(slskd_result, 'title', 'Unknown Track'), + 'artists': [{'name': getattr(slskd_result, 'artist', 'Unknown Artist')}], + 'album': {'name': getattr(slskd_result, 'album', 'Unknown Album')}, + 'duration_ms': 0, # Unknown + 'reconstructed': True # Mark as reconstructed data + } + + # If no Spotify data found, try to reconstruct from available info + logger.warning("Could not find Spotify track data in modal info, attempting reconstruction") + return None + + except Exception as e: + logger.error(f"Error extracting Spotify track from modal info: {e}") + return None + + def _spotify_track_object_to_dict(self, spotify_track) -> Dict[str, Any]: + """Convert a Spotify track object to a dictionary""" + try: + return { + 'id': getattr(spotify_track, 'id', None), + 'name': getattr(spotify_track, 'name', 'Unknown Track'), + 'artists': [ + {'name': artist.name if hasattr(artist, 'name') else str(artist)} + for artist in getattr(spotify_track, 'artists', []) + ], + 'album': { + 'name': getattr(spotify_track.album, 'name', 'Unknown Album') + if hasattr(spotify_track, 'album') and spotify_track.album + else 'Unknown Album' + }, + 'duration_ms': getattr(spotify_track, 'duration_ms', 0), + 'preview_url': getattr(spotify_track, 'preview_url', None), + 'external_urls': getattr(spotify_track, 'external_urls', {}), + 'popularity': getattr(spotify_track, 'popularity', 0) + } + except Exception as e: + logger.error(f"Error converting Spotify track object to dict: {e}") + return {} + +# Global singleton instance +_wishlist_service = None + +def get_wishlist_service() -> WishlistService: + """Get the global wishlist service instance""" + global _wishlist_service + if _wishlist_service is None: + _wishlist_service = WishlistService() + return _wishlist_service \ No newline at end of file diff --git a/database/__pycache__/music_database.cpython-312.pyc b/database/__pycache__/music_database.cpython-312.pyc index 234de155..834246cf 100644 Binary files a/database/__pycache__/music_database.cpython-312.pyc and b/database/__pycache__/music_database.cpython-312.pyc differ diff --git a/database/music_database.py b/database/music_database.py index 7c05a4e8..b991bd3a 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -135,10 +135,27 @@ class MusicDatabase: ) """) + # Wishlist table for storing failed download tracks for retry + cursor.execute(""" + CREATE TABLE IF NOT EXISTS wishlist_tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + spotify_track_id TEXT UNIQUE NOT NULL, + spotify_data TEXT NOT NULL, -- JSON of full Spotify track data + failure_reason TEXT, + retry_count INTEGER DEFAULT 0, + last_attempted TIMESTAMP, + date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + source_type TEXT DEFAULT 'unknown', -- 'playlist', 'album', 'manual' + source_info TEXT -- JSON of source context (playlist name, album info, etc.) + ) + """) + # Create indexes for performance cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums (artist_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks (album_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks (artist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_spotify_id ON wishlist_tracks (spotify_track_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_date_added ON wishlist_tracks (date_added)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_name ON artists (name)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)") @@ -1372,6 +1389,158 @@ class MusicDatabase: def get_last_full_refresh(self) -> Optional[str]: """Get the date of the last full refresh""" return self.get_metadata('last_full_refresh') + + # Wishlist management methods + + def add_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str = "Download failed", + source_type: str = "unknown", source_info: Dict[str, Any] = None) -> bool: + """Add a failed track to the wishlist for retry""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + # Use Spotify track ID as unique identifier + track_id = spotify_track_data.get('id') + if not track_id: + logger.error("Cannot add track to wishlist: missing Spotify track ID") + return False + + # Convert data to JSON strings + spotify_json = json.dumps(spotify_track_data) + source_json = json.dumps(source_info or {}) + + cursor.execute(""" + INSERT OR REPLACE INTO wishlist_tracks + (spotify_track_id, spotify_data, failure_reason, source_type, source_info, date_added) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, (track_id, spotify_json, failure_reason, source_type, source_json)) + + conn.commit() + + track_name = spotify_track_data.get('name', 'Unknown Track') + artist_name = spotify_track_data.get('artists', [{}])[0].get('name', 'Unknown Artist') + logger.info(f"Added track to wishlist: '{track_name}' by {artist_name}") + return True + + except Exception as e: + logger.error(f"Error adding track to wishlist: {e}") + return False + + def remove_from_wishlist(self, spotify_track_id: str) -> bool: + """Remove a track from the wishlist (typically after successful download)""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM wishlist_tracks WHERE spotify_track_id = ?", (spotify_track_id,)) + conn.commit() + + if cursor.rowcount > 0: + logger.info(f"Removed track from wishlist: {spotify_track_id}") + return True + else: + logger.debug(f"Track not found in wishlist: {spotify_track_id}") + return False + + except Exception as e: + logger.error(f"Error removing track from wishlist: {e}") + return False + + def get_wishlist_tracks(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: + """Get all tracks in the wishlist, ordered by date added (oldest first for retry priority)""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + query = """ + SELECT id, spotify_track_id, spotify_data, failure_reason, retry_count, + last_attempted, date_added, source_type, source_info + FROM wishlist_tracks + ORDER BY date_added ASC + """ + + if limit: + query += f" LIMIT {limit}" + + cursor.execute(query) + rows = cursor.fetchall() + + wishlist_tracks = [] + for row in rows: + try: + spotify_data = json.loads(row['spotify_data']) + source_info = json.loads(row['source_info']) if row['source_info'] else {} + + wishlist_tracks.append({ + 'id': row['id'], + 'spotify_track_id': row['spotify_track_id'], + 'spotify_data': spotify_data, + 'failure_reason': row['failure_reason'], + 'retry_count': row['retry_count'], + 'last_attempted': row['last_attempted'], + 'date_added': row['date_added'], + 'source_type': row['source_type'], + 'source_info': source_info + }) + except json.JSONDecodeError as e: + logger.error(f"Error parsing wishlist track data: {e}") + continue + + return wishlist_tracks + + except Exception as e: + logger.error(f"Error getting wishlist tracks: {e}") + return [] + + def update_wishlist_retry(self, spotify_track_id: str, success: bool, error_message: str = None) -> bool: + """Update retry count and status for a wishlist track""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + if success: + # Remove from wishlist on success + cursor.execute("DELETE FROM wishlist_tracks WHERE spotify_track_id = ?", (spotify_track_id,)) + else: + # Increment retry count and update failure reason + cursor.execute(""" + UPDATE wishlist_tracks + SET retry_count = retry_count + 1, + last_attempted = CURRENT_TIMESTAMP, + failure_reason = COALESCE(?, failure_reason) + WHERE spotify_track_id = ? + """, (error_message, spotify_track_id)) + + conn.commit() + return cursor.rowcount > 0 + + except Exception as e: + logger.error(f"Error updating wishlist retry status: {e}") + return False + + def get_wishlist_count(self) -> int: + """Get the total number of tracks in the wishlist""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM wishlist_tracks") + result = cursor.fetchone() + return result[0] if result else 0 + except Exception as e: + logger.error(f"Error getting wishlist count: {e}") + return 0 + + def clear_wishlist(self) -> bool: + """Clear all tracks from the wishlist""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM wishlist_tracks") + conn.commit() + logger.info(f"Cleared {cursor.rowcount} tracks from wishlist") + return True + except Exception as e: + logger.error(f"Error clearing wishlist: {e}") + return False def get_database_info(self) -> Dict[str, Any]: """Get comprehensive database information""" diff --git a/ui/pages/__pycache__/artists.cpython-312.pyc b/ui/pages/__pycache__/artists.cpython-312.pyc index d278e664..490dfbc0 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__/dashboard.cpython-312.pyc b/ui/pages/__pycache__/dashboard.cpython-312.pyc index 7c515797..8d0e89d9 100644 Binary files a/ui/pages/__pycache__/dashboard.cpython-312.pyc and b/ui/pages/__pycache__/dashboard.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/sync.cpython-312.pyc b/ui/pages/__pycache__/sync.cpython-312.pyc index 45bd8e50..097819a4 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 3fcef074..ba13cdce 100644 --- a/ui/pages/artists.py +++ b/ui/pages/artists.py @@ -18,9 +18,14 @@ 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.wishlist_service import get_wishlist_service from core.plex_scan_manager import PlexScanManager from database.music_database import get_database +from utils.logging_config import get_logger import asyncio +from datetime import datetime + +logger = get_logger("artists") @dataclass @@ -1745,6 +1750,7 @@ class DownloadMissingAlbumTracksModal(QDialog): self.downloads_page = downloads_page self.plex_client = plex_client self.matching_engine = MusicMatchingEngine() + self.wishlist_service = get_wishlist_service() # State tracking self.total_tracks = len(album.tracks) @@ -2656,9 +2662,47 @@ class DownloadMissingAlbumTracksModal(QDialog): 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)") + # Add permanently failed tracks to wishlist before showing completion message + failed_count = len(self.permanently_failed_tracks) + wishlist_added_count = 0 + + if self.permanently_failed_tracks: + try: + # Add failed tracks to wishlist + source_context = { + 'album_name': getattr(self.album, 'name', 'Unknown Album'), + 'album_id': getattr(self.album, 'id', None), + 'artist_name': getattr(self.album, 'artists', [{}])[0].get('name', 'Unknown Artist') if hasattr(self.album, 'artists') else 'Unknown Artist', + 'added_from': 'artists_page_modal', + 'timestamp': datetime.now().isoformat() + } + + for failed_track_info in self.permanently_failed_tracks: + try: + success = self.wishlist_service.add_failed_track_from_modal( + track_info=failed_track_info, + source_type='album', + source_context=source_context + ) + if success: + wishlist_added_count += 1 + except Exception as e: + logger.error(f"Failed to add album track to wishlist: {e}") + + if wishlist_added_count > 0: + logger.info(f"Added {wishlist_added_count} failed tracks to wishlist from album '{self.album.name}'") + + except Exception as e: + logger.error(f"Error adding failed album tracks to wishlist: {e}") + # Determine the final message based on success or failure if self.permanently_failed_tracks: - final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing album tracks!\n\nYou can now manually correct any failed downloads or close this window." + final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing album tracks!\n\n" + + if wishlist_added_count > 0: + final_message += f"✨ Added {wishlist_added_count} failed track{'s' if wishlist_added_count != 1 else ''} to wishlist for automatic retry.\n\n" + + final_message += "You can also manually correct failed downloads or check the wishlist on the dashboard." # If there are failures, ensure the modal is visible and bring it to the front if self.isHidden(): diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py index dcf91d1b..0691578b 100644 --- a/ui/pages/dashboard.py +++ b/ui/pages/dashboard.py @@ -1,7 +1,8 @@ from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QGridLayout, QScrollArea, QSizePolicy, QPushButton, - QProgressBar, QTextEdit, QSpacerItem, QGroupBox, QFormLayout, QComboBox) -from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject + QProgressBar, QTextEdit, QSpacerItem, QGroupBox, QFormLayout, QComboBox, + QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QMessageBox) +from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject, QRunnable, QThreadPool from PyQt6.QtGui import QFont, QPalette, QColor import time import asyncio @@ -22,10 +23,447 @@ import io from core.matching_engine import MusicMatchingEngine from ui.components.database_updater_widget import DatabaseUpdaterWidget from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker +from core.wishlist_service import get_wishlist_service from utils.logging_config import get_logger logger = get_logger("dashboard") +class DownloadMissingWishlistTracksModal(QDialog): + """Modal for downloading tracks from the wishlist with live progress tracking""" + process_finished = pyqtSignal() + + def __init__(self, wishlist_service, parent_dashboard, downloads_page, spotify_client, plex_client, soulseek_client): + super().__init__(parent_dashboard) + self.wishlist_service = wishlist_service + self.parent_dashboard = parent_dashboard + self.downloads_page = downloads_page + self.spotify_client = spotify_client + self.plex_client = plex_client + self.soulseek_client = soulseek_client + + # Import matching engine + self.matching_engine = MusicMatchingEngine() + + # State tracking + self.wishlist_tracks = [] + self.total_tracks = 0 + self.download_in_progress = False + self.cancel_requested = False + self.active_parallel_downloads = 0 + self.download_queue_index = 0 + self.completed_downloads = 0 + self.successful_downloads = 0 + self.failed_downloads = 0 + + # Track active downloads and failed tracks + self.active_downloads = [] + self.permanently_failed_tracks = [] + + # Parallel search tracking (adapted from sync.py) + self.parallel_search_tracking = {} + + self.setup_ui() + self.load_wishlist_tracks() + + def setup_ui(self): + """Setup the modal UI (simplified version based on sync.py modal)""" + self.setWindowTitle("Download Wishlist Tracks") + self.setMinimumSize(800, 600) + self.setStyleSheet(""" + DownloadMissingWishlistTracksModal { + background: #191414; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # Header + header_label = QLabel("Download Missing Wishlist Tracks") + header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + header_label.setStyleSheet("color: #ffffff;") + + # Info label + self.info_label = QLabel("Loading wishlist tracks...") + self.info_label.setFont(QFont("Arial", 11)) + self.info_label.setStyleSheet("color: #b3b3b3;") + + # Track table (simplified) + self.track_table = QTableWidget() + self.track_table.setColumnCount(4) + self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Retry Count", "Status"]) + self.track_table.horizontalHeader().setStretchLastSection(True) + self.track_table.setStyleSheet(""" + QTableWidget { + background: #282828; + border: 1px solid #404040; + border-radius: 8px; + gridline-color: #404040; + } + QTableWidget::item { + padding: 8px; + border-bottom: 1px solid #404040; + } + QHeaderView::section { + background: #404040; + color: #ffffff; + padding: 8px; + border: none; + font-weight: bold; + } + """) + + # Progress bar + self.download_progress = QProgressBar() + self.download_progress.setVisible(False) + self.download_progress.setStyleSheet(""" + QProgressBar { + border: 1px solid #404040; + border-radius: 8px; + text-align: center; + background: #282828; + } + QProgressBar::chunk { + background: #1db954; + border-radius: 7px; + } + """) + + # Buttons + button_layout = QHBoxLayout() + + self.begin_download_btn = QPushButton("🚀 Begin Downloads") + self.begin_download_btn.setFixedHeight(40) + self.begin_download_btn.clicked.connect(self.start_downloads) + self.begin_download_btn.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 20px; + color: #000000; + font-size: 12px; + font-weight: bold; + padding: 8px 20px; + } + QPushButton:hover { + background: #1ed760; + } + QPushButton:disabled { + background: #404040; + color: #888888; + } + """) + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.setFixedHeight(40) + self.cancel_btn.clicked.connect(self.on_cancel_clicked) + self.cancel_btn.setStyleSheet(""" + QPushButton { + background: #404040; + border: none; + border-radius: 20px; + color: #ffffff; + font-size: 12px; + padding: 8px 20px; + } + QPushButton:hover { + background: #505050; + } + """) + + self.clear_wishlist_btn = QPushButton("🗑️ Clear Wishlist") + self.clear_wishlist_btn.setFixedHeight(40) + self.clear_wishlist_btn.clicked.connect(self.clear_wishlist) + self.clear_wishlist_btn.setStyleSheet(""" + QPushButton { + background: #e22134; + border: none; + border-radius: 20px; + color: #ffffff; + font-size: 12px; + padding: 8px 20px; + } + QPushButton:hover { + background: #ff4757; + } + """) + + button_layout.addStretch() + button_layout.addWidget(self.clear_wishlist_btn) + button_layout.addWidget(self.cancel_btn) + button_layout.addWidget(self.begin_download_btn) + + # Add to layout + layout.addWidget(header_label) + layout.addWidget(self.info_label) + layout.addWidget(self.track_table) + layout.addWidget(self.download_progress) + layout.addLayout(button_layout) + + def load_wishlist_tracks(self): + """Load tracks from wishlist""" + try: + self.wishlist_tracks = self.wishlist_service.get_wishlist_tracks_for_download() + self.total_tracks = len(self.wishlist_tracks) + + if self.total_tracks == 0: + self.info_label.setText("No tracks in wishlist") + self.begin_download_btn.setEnabled(False) + return + + self.info_label.setText(f"Found {self.total_tracks} tracks in wishlist ready for retry") + + # Populate table + self.track_table.setRowCount(self.total_tracks) + + for i, track_data in enumerate(self.wishlist_tracks): + # Track name + self.track_table.setItem(i, 0, QTableWidgetItem(track_data.get('name', 'Unknown Track'))) + + # Artist + artists = track_data.get('artists', []) + artist_name = artists[0].get('name', 'Unknown Artist') if artists else 'Unknown Artist' + self.track_table.setItem(i, 1, QTableWidgetItem(artist_name)) + + # Retry count + retry_count = track_data.get('retry_count', 0) + self.track_table.setItem(i, 2, QTableWidgetItem(str(retry_count))) + + # Status + self.track_table.setItem(i, 3, QTableWidgetItem("Pending")) + + except Exception as e: + logger.error(f"Error loading wishlist tracks: {e}") + self.info_label.setText(f"Error loading tracks: {str(e)}") + + def start_downloads(self): + """Start downloading all wishlist tracks""" + try: + if self.total_tracks == 0: + return + + self.download_in_progress = True + self.cancel_requested = False + self.begin_download_btn.setEnabled(False) + self.download_progress.setVisible(True) + self.download_progress.setMaximum(self.total_tracks) + self.download_progress.setValue(0) + + # Start parallel downloads (simplified approach) + self.active_parallel_downloads = 0 + self.download_queue_index = 0 + self.completed_downloads = 0 + self.successful_downloads = 0 + self.failed_downloads = 0 + + # Initialize tracking + self.active_downloads = [] + self.permanently_failed_tracks = [] + self.parallel_search_tracking = {} + + self.start_next_batch_of_downloads() + + except Exception as e: + logger.error(f"Error starting downloads: {e}") + QMessageBox.critical(self, "Error", f"Failed to start downloads: {str(e)}") + + def start_next_batch_of_downloads(self, max_concurrent=3): + """Start the next batch of downloads up to the concurrent limit""" + while (self.active_parallel_downloads < max_concurrent and + self.download_queue_index < len(self.wishlist_tracks)): + track_data = self.wishlist_tracks[self.download_queue_index] + track_index = self.download_queue_index + + # Update UI + self.track_table.setItem(track_index, 3, QTableWidgetItem("🔍 Searching...")) + + # Start search and download for this track (simplified) + self.search_and_download_track_simple(track_data, self.download_queue_index) + + self.active_parallel_downloads += 1 + self.download_queue_index += 1 + + if (self.download_queue_index >= len(self.wishlist_tracks) and self.active_parallel_downloads == 0): + self.on_all_downloads_complete() + + def search_and_download_track_simple(self, track_data, download_index): + """Simplified search and download for wishlist tracks""" + try: + # Create a simple search worker + artist_name = track_data.get('artists', [{}])[0].get('name', '') if track_data.get('artists') else '' + track_name = track_data.get('name', '') + + if not track_name: + self.on_track_download_failed(download_index, "Missing track name") + return + + # Create search query + query = f"{artist_name} {track_name}".strip() + if not query: + self.on_track_download_failed(download_index, "Cannot create search query") + return + + # Use a simple approach - directly call soulseek search + worker = SimpleWishlistDownloadWorker(self.soulseek_client, query, track_data, download_index) + worker.signals.download_completed.connect(self.on_track_download_completed) + worker.signals.download_failed.connect(self.on_track_download_failed) + + QThreadPool.globalInstance().start(worker) + + except Exception as e: + logger.error(f"Error starting track download: {e}") + self.on_track_download_failed(download_index, str(e)) + + def on_track_download_completed(self, download_index, download_id): + """Handle successful track download""" + try: + track_data = self.wishlist_tracks[download_index] + track_id = track_data.get('spotify_track_id') + + # Update UI + self.track_table.setItem(download_index, 3, QTableWidgetItem("✅ Downloaded")) + + # Mark as successful in wishlist service + if track_id: + self.wishlist_service.mark_track_download_result(track_id, success=True) + + self.successful_downloads += 1 + self.completed_downloads += 1 + self.active_parallel_downloads -= 1 + + # Update progress + self.download_progress.setValue(self.completed_downloads) + + # Continue with next downloads + self.start_next_batch_of_downloads() + + except Exception as e: + logger.error(f"Error handling download completion: {e}") + + def on_track_download_failed(self, download_index, error_message): + """Handle failed track download""" + try: + track_data = self.wishlist_tracks[download_index] + track_id = track_data.get('spotify_track_id') + + # Update UI + self.track_table.setItem(download_index, 3, QTableWidgetItem("❌ Failed")) + + # Mark as failed in wishlist service (increment retry count) + if track_id: + self.wishlist_service.mark_track_download_result(track_id, success=False, error_message=error_message) + + self.failed_downloads += 1 + self.completed_downloads += 1 + self.active_parallel_downloads -= 1 + + # Update progress + self.download_progress.setValue(self.completed_downloads) + + # Continue with next downloads + self.start_next_batch_of_downloads() + + except Exception as e: + logger.error(f"Error handling download failure: {e}") + + def on_all_downloads_complete(self): + """Handle completion of all downloads""" + try: + self.download_in_progress = False + + # Show completion message + message = f"Wishlist processing complete!\n\n" + message += f"Successfully downloaded: {self.successful_downloads}\n" + message += f"Failed: {self.failed_downloads}\n" + message += f"Total processed: {self.completed_downloads}\n\n" + + if self.failed_downloads > 0: + message += "Failed tracks remain in wishlist for future retry." + else: + message += "All tracks downloaded successfully!" + + QMessageBox.information(self, "Downloads Complete", message) + + # Emit signal to update parent + self.process_finished.emit() + + # Close modal + self.accept() + + except Exception as e: + logger.error(f"Error handling downloads completion: {e}") + + def clear_wishlist(self): + """Clear all tracks from wishlist""" + try: + reply = QMessageBox.question( + self, "Clear Wishlist", + "Are you sure you want to clear all tracks from the wishlist?\n\nThis action cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + if self.wishlist_service.clear_wishlist(): + QMessageBox.information(self, "Wishlist Cleared", "All tracks have been removed from the wishlist.") + self.process_finished.emit() # Update parent count + self.accept() # Close modal + else: + QMessageBox.warning(self, "Error", "Failed to clear wishlist.") + except Exception as e: + logger.error(f"Error clearing wishlist: {e}") + QMessageBox.critical(self, "Error", f"Failed to clear wishlist: {str(e)}") + + def on_cancel_clicked(self): + """Handle cancel button""" + self.cancel_requested = True + self.process_finished.emit() + self.reject() + + +class SimpleWishlistDownloadWorker(QRunnable): + """Simple worker to download a single wishlist track""" + + class Signals(QObject): + download_completed = pyqtSignal(int, str) # download_index, download_id + download_failed = pyqtSignal(int, str) # download_index, error_message + + def __init__(self, soulseek_client, query, track_data, download_index): + super().__init__() + self.soulseek_client = soulseek_client + self.query = query + self.track_data = track_data + self.download_index = download_index + self.signals = self.Signals() + + def run(self): + """Run the download""" + try: + # Get quality preference + from config.settings import config_manager + quality_preference = config_manager.get_quality_preference() + + # Use async method in sync context + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + download_id = loop.run_until_complete( + self.soulseek_client.search_and_download_best(self.query, quality_preference) + ) + + if download_id: + self.signals.download_completed.emit(self.download_index, download_id) + else: + self.signals.download_failed.emit(self.download_index, "No search results found") + + finally: + loop.close() + + except Exception as e: + self.signals.download_failed.emit(self.download_index, str(e)) + + class MetadataUpdateWorker(QThread): """Worker thread for updating Plex artist metadata using Spotify data""" progress_updated = pyqtSignal(str, int, int, float) # current_artist, processed, total, percentage @@ -562,9 +1000,9 @@ class DashboardDataProvider(QObject): def set_service_clients(self, spotify_client, plex_client, soulseek_client): self.service_clients = { - 'spotify': spotify_client, - 'plex': plex_client, - 'soulseek': soulseek_client + 'spotify_client': spotify_client, + 'plex_client': plex_client, + 'soulseek_client': soulseek_client } def set_page_references(self, downloads_page, sync_page): @@ -1214,15 +1652,43 @@ class DashboardPage(QWidget): # Initialize list to track active stats workers self._active_stats_workers = [] + # Initialize wishlist service and timers + self.wishlist_service = get_wishlist_service() + + # Timer for updating wishlist button count + self.wishlist_update_timer = QTimer() + self.wishlist_update_timer.timeout.connect(self.update_wishlist_button_count) + self.wishlist_update_timer.start(30000) # Update every 30 seconds + + # Timer for automatic wishlist retry processing + self.wishlist_retry_timer = QTimer() + self.wishlist_retry_timer.timeout.connect(self.process_wishlist_automatically) + self.wishlist_retry_timer.start(3600000) # Process every hour (3600000 ms) + + # Track if automatic processing is currently running + self.auto_processing_wishlist = False + # Load initial database statistics (with delay to avoid startup issues) QTimer.singleShot(1000, self.refresh_database_statistics) + # Load initial wishlist count (with slight delay) + QTimer.singleShot(1500, self.update_wishlist_button_count) - def set_service_clients(self, spotify_client, plex_client, soulseek_client): + def set_service_clients(self, spotify_client, plex_client, soulseek_client, downloads_page=None): """Called from main window to provide service client references""" self.data_provider.set_service_clients(spotify_client, plex_client, soulseek_client) + + # Store service clients for wishlist modal + self.service_clients = { + 'spotify_client': spotify_client, + 'plex_client': plex_client, + 'soulseek_client': soulseek_client, + 'downloads_page': downloads_page + } def set_page_references(self, downloads_page, sync_page): """Called from main window to provide page references for live data""" + self.downloads_page = downloads_page + self.sync_page = sync_page self.data_provider.set_page_references(downloads_page, sync_page) def set_app_start_time(self, start_time): @@ -1302,9 +1768,15 @@ class DashboardPage(QWidget): def create_header(self): header = QWidget() - layout = QVBoxLayout(header) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(5) + main_layout = QHBoxLayout(header) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(20) + + # Left side - Title and subtitle + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + left_layout.setContentsMargins(0, 0, 0, 0) + left_layout.setSpacing(5) # Welcome message welcome_label = QLabel("System Dashboard") @@ -1316,8 +1788,52 @@ class DashboardPage(QWidget): subtitle_label.setFont(QFont("Arial", 14)) subtitle_label.setStyleSheet("color: #b3b3b3;") - layout.addWidget(welcome_label) - layout.addWidget(subtitle_label) + left_layout.addWidget(welcome_label) + left_layout.addWidget(subtitle_label) + + # Right side - Wishlist button + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(0) + + # Spacer to align button with title + right_layout.addStretch() + + # Wishlist button + self.wishlist_button = QPushButton("🎵 Wishlist (0)") + self.wishlist_button.setFixedHeight(45) + self.wishlist_button.setFixedWidth(150) + self.wishlist_button.clicked.connect(self.on_wishlist_button_clicked) + self.wishlist_button.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 22px; + color: #000000; + font-size: 12px; + font-weight: bold; + padding: 8px 16px; + } + QPushButton:hover { + background: #1ed760; + } + QPushButton:pressed { + background: #169c46; + } + QPushButton:disabled { + background: #404040; + color: #666666; + } + """) + + right_layout.addWidget(self.wishlist_button) + right_layout.addStretch() + + # Add to main layout + main_layout.addWidget(left_widget) + main_layout.addStretch() # Push button to the right + main_layout.addWidget(right_widget) return header @@ -1881,6 +2397,13 @@ class DashboardPage(QWidget): def closeEvent(self, event): """Clean up threads when dashboard is closed""" self.cleanup_threads() + + # Stop wishlist timers + if hasattr(self, 'wishlist_update_timer'): + self.wishlist_update_timer.stop() + if hasattr(self, 'wishlist_retry_timer'): + self.wishlist_retry_timer.stop() + super().closeEvent(event) def cleanup_threads(self): @@ -1892,6 +2415,261 @@ class DashboardPage(QWidget): thread.wait(1000) # Wait up to 1 second thread.deleteLater() self.data_provider._test_threads.clear() + + def on_wishlist_button_clicked(self): + """Handle wishlist button click - open wishlist modal""" + try: + summary = self.wishlist_service.get_wishlist_summary() + total_tracks = summary['total_tracks'] + + if total_tracks == 0: + QMessageBox.information(self, "Wishlist", "Your wishlist is empty!\n\nFailed download tracks will be automatically added here for retry.") + return + + # Need to get service clients to pass to modal + if not hasattr(self, 'service_clients'): + QMessageBox.warning(self, "Wishlist", "Service clients not initialized. Please restart the application.") + return + + spotify_client = self.service_clients.get('spotify_client') + plex_client = self.service_clients.get('plex_client') + soulseek_client = self.service_clients.get('soulseek_client') + downloads_page = self.downloads_page + + if not all([spotify_client, plex_client, soulseek_client, downloads_page]): + QMessageBox.warning(self, "Wishlist", "Required services not available. Please check your service connections.") + return + + # Create and show the wishlist download modal + modal = DownloadMissingWishlistTracksModal( + self.wishlist_service, + self, + downloads_page, + spotify_client, + plex_client, + soulseek_client + ) + modal.process_finished.connect(self.update_wishlist_button_count) # Update count when done + modal.exec() + + except Exception as e: + logger.error(f"Error opening wishlist: {e}") + QMessageBox.critical(self, "Error", f"Failed to open wishlist: {str(e)}") + + def update_wishlist_button_count(self): + """Update the wishlist button with current count""" + try: + count = self.wishlist_service.get_wishlist_count() + + if hasattr(self, 'wishlist_button'): + self.wishlist_button.setText(f"🎵 Wishlist ({count})") + + # Enable/disable button based on count + if count == 0: + self.wishlist_button.setStyleSheet(""" + QPushButton { + background: #404040; + border: none; + border-radius: 22px; + color: #888888; + font-size: 12px; + font-weight: bold; + padding: 8px 16px; + } + QPushButton:hover { + background: #505050; + color: #999999; + } + """) + else: + self.wishlist_button.setStyleSheet(""" + QPushButton { + background: #1db954; + border: none; + border-radius: 22px; + color: #000000; + font-size: 12px; + font-weight: bold; + padding: 8px 16px; + } + QPushButton:hover { + background: #1ed760; + } + QPushButton:pressed { + background: #169c46; + } + """) + except Exception as e: + logger.error(f"Error updating wishlist button count: {e}") + + def process_wishlist_automatically(self): + """Automatically process wishlist tracks in the background""" + try: + # Skip if already processing or no service clients available + if self.auto_processing_wishlist: + logger.debug("Wishlist auto-processing already running, skipping") + return + + if not hasattr(self, 'service_clients') or not self.service_clients: + logger.debug("Service clients not available for wishlist auto-processing") + return + + # Check if we have tracks to process + wishlist_count = self.wishlist_service.get_wishlist_count() + if wishlist_count == 0: + logger.debug("No tracks in wishlist for auto-processing") + return + + # Get service clients + spotify_client = self.service_clients.get('spotify_client') + plex_client = self.service_clients.get('plex_client') + soulseek_client = self.service_clients.get('soulseek_client') + downloads_page = self.downloads_page + + if not all([spotify_client, plex_client, soulseek_client, downloads_page]): + logger.warning("Required services not available for wishlist auto-processing") + return + + logger.info(f"Starting automatic wishlist processing for {wishlist_count} tracks") + self.auto_processing_wishlist = True + + # Create and run the background processing worker + worker = AutoWishlistProcessorWorker( + self.wishlist_service, + spotify_client, + plex_client, + soulseek_client, + downloads_page + ) + worker.signals.processing_complete.connect(self.on_auto_wishlist_processing_complete) + worker.signals.processing_error.connect(self.on_auto_wishlist_processing_error) + + # Run in thread pool + QThreadPool.globalInstance().start(worker) + + except Exception as e: + logger.error(f"Error starting automatic wishlist processing: {e}") + self.auto_processing_wishlist = False + + def on_auto_wishlist_processing_complete(self, successful, failed, total): + """Handle completion of automatic wishlist processing""" + try: + self.auto_processing_wishlist = False + + logger.info(f"Automatic wishlist processing complete: {successful} successful, {failed} failed, {total} total") + + # Update button count since tracks may have been removed + self.update_wishlist_button_count() + + # Show toast notification if there were successful downloads + if successful > 0 and hasattr(self, 'toast_manager') and self.toast_manager: + message = f"Found {successful} wishlist track{'s' if successful != 1 else ''} automatically!" + self.toast_manager.success(message) + + except Exception as e: + logger.error(f"Error handling automatic wishlist processing completion: {e}") + + def on_auto_wishlist_processing_error(self, error_message): + """Handle error in automatic wishlist processing""" + try: + self.auto_processing_wishlist = False + logger.error(f"Automatic wishlist processing failed: {error_message}") + except Exception as e: + logger.error(f"Error handling automatic wishlist processing error: {e}") + + +class AutoWishlistProcessorWorker(QRunnable): + """Background worker for automatic wishlist processing""" + + class Signals(QObject): + processing_complete = pyqtSignal(int, int, int) # successful, failed, total + processing_error = pyqtSignal(str) # error_message + + def __init__(self, wishlist_service, spotify_client, plex_client, soulseek_client, downloads_page): + super().__init__() + self.wishlist_service = wishlist_service + self.spotify_client = spotify_client + self.plex_client = plex_client + self.soulseek_client = soulseek_client + self.downloads_page = downloads_page + self.signals = self.Signals() + + def run(self): + """Run automatic wishlist processing""" + try: + # Get quality preference + from config.settings import config_manager + quality_preference = config_manager.get_quality_preference() + + # Get wishlist tracks (limit to prevent overwhelming the system) + wishlist_tracks = self.wishlist_service.get_wishlist_tracks_for_download(limit=10) + + if not wishlist_tracks: + self.signals.processing_complete.emit(0, 0, 0) + return + + total_tracks = len(wishlist_tracks) + successful_downloads = 0 + failed_downloads = 0 + + logger.info(f"Processing {total_tracks} wishlist tracks automatically") + + # Process each track + for track_data in wishlist_tracks: + try: + # Create search query + artist_name = track_data.get('artists', [{}])[0].get('name', '') if track_data.get('artists') else '' + track_name = track_data.get('name', '') + + if not track_name: + failed_downloads += 1 + continue + + query = f"{artist_name} {track_name}".strip() + if not query: + failed_downloads += 1 + continue + + # Attempt download + 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, quality_preference) + ) + + track_id = track_data.get('spotify_track_id') + + if download_id and track_id: + # Mark as successful (removes from wishlist) + self.wishlist_service.mark_track_download_result(track_id, success=True) + successful_downloads += 1 + logger.info(f"Auto-downloaded wishlist track: '{track_name}' by {artist_name}") + else: + # Mark as failed (increment retry count) + if track_id: + self.wishlist_service.mark_track_download_result(track_id, success=False, error_message="No search results found") + failed_downloads += 1 + + finally: + loop.close() + + except Exception as e: + logger.error(f"Error processing wishlist track '{track_name}': {e}") + + # Mark as failed + track_id = track_data.get('spotify_track_id') + if track_id: + self.wishlist_service.mark_track_download_result(track_id, success=False, error_message=str(e)) + failed_downloads += 1 + + # Emit completion + self.signals.processing_complete.emit(successful_downloads, failed_downloads, total_tracks) + + except Exception as e: + logger.error(f"Critical error in automatic wishlist processing: {e}") + self.signals.processing_error.emit(str(e)) # Stop the data provider timers if hasattr(self.data_provider, 'download_stats_timer'): diff --git a/ui/pages/sync.py b/ui/pages/sync.py index cbe6e175..56965c44 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -14,9 +14,13 @@ from core.soulseek_client import TrackResult import re import asyncio from core.matching_engine import MusicMatchingEngine +from core.wishlist_service import get_wishlist_service from ui.components.toast_manager import ToastType from database.music_database import get_database from core.plex_scan_manager import PlexScanManager +from utils.logging_config import get_logger + +logger = get_logger("sync") # Define constants for storage STORAGE_DIR = "storage" @@ -3574,6 +3578,8 @@ class DownloadMissingTracksModal(QDialog): self.parent_sync_page = parent_page # Reference to sync page for scan manager self.downloads_page = downloads_page self.matching_engine = MusicMatchingEngine() + self.wishlist_service = get_wishlist_service() + # State tracking self.total_tracks = len(playlist.tracks) self.matched_tracks_count = 0 @@ -4461,9 +4467,46 @@ class DownloadMissingTracksModal(QDialog): 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)") + # Add permanently failed tracks to wishlist before showing completion message + failed_count = len(self.permanently_failed_tracks) + wishlist_added_count = 0 + + if self.permanently_failed_tracks: + try: + # Add failed tracks to wishlist + source_context = { + 'playlist_name': getattr(self.playlist, 'name', 'Unknown Playlist'), + 'playlist_id': getattr(self.playlist, 'id', None), + 'added_from': 'sync_page_modal', + 'timestamp': datetime.now().isoformat() + } + + for failed_track_info in self.permanently_failed_tracks: + try: + success = self.wishlist_service.add_failed_track_from_modal( + track_info=failed_track_info, + source_type='playlist', + source_context=source_context + ) + if success: + wishlist_added_count += 1 + except Exception as e: + logger.error(f"Failed to add track to wishlist: {e}") + + if wishlist_added_count > 0: + logger.info(f"Added {wishlist_added_count} failed tracks to wishlist from playlist '{self.playlist.name}'") + + except Exception as e: + logger.error(f"Error adding failed tracks to wishlist: {e}") + # Determine the final message based on success or failure. if self.permanently_failed_tracks: - final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\nYou can now manually correct any failed downloads or close this window." + final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\n" + + if wishlist_added_count > 0: + final_message += f"✨ Added {wishlist_added_count} failed track{'s' if wishlist_added_count != 1 else ''} to wishlist for automatic retry.\n\n" + + final_message += "You can also manually correct failed downloads or check the wishlist on the dashboard." # If there are failures, ensure the modal is visible and bring it to the front. if self.isHidden():