wishlist functionality

This commit is contained in:
Broque Thomas 2025-08-07 23:08:05 -07:00
parent ed91099a1c
commit b5e3c47051
10 changed files with 1321 additions and 13 deletions

Binary file not shown.

274
core/wishlist_service.py Normal file
View file

@ -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

View file

@ -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"""

View file

@ -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():

View file

@ -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'):

View file

@ -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():