soulsync/ui/pages/artists.py
Broque Thomas 347eccf762 good
2025-07-25 21:26:41 -07:00

1655 lines
65 KiB
Python

from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QFrame, QPushButton, QLineEdit, QScrollArea,
QGridLayout, QSizePolicy, QSpacerItem, QApplication,
QDialog, QDialogButtonBox, QProgressBar, QMessageBox)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QThread, QObject, QRunnable, QThreadPool, QPropertyAnimation, QEasingCurve, QRect
from PyQt6.QtGui import QFont, QPixmap, QPainter, QPen, QColor
import functools
import os
import threading
import requests
from typing import List, Optional
from dataclasses import dataclass
# Import core components
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
import asyncio
@dataclass
class ArtistMatch:
"""Represents an artist match with confidence score"""
artist: Artist
confidence: float
match_reason: str = ""
class DownloadCompletionWorkerSignals(QObject):
"""Signals for the download completion worker"""
completed = pyqtSignal(object, str) # download_item, organized_path
error = pyqtSignal(object, str) # download_item, error_message
class DownloadCompletionWorker(QRunnable):
"""Background worker to handle download completion processing without blocking UI"""
def __init__(self, download_item, absolute_file_path, organize_func):
super().__init__()
self.download_item = download_item
self.absolute_file_path = absolute_file_path
self.organize_func = organize_func
self.signals = DownloadCompletionWorkerSignals()
def run(self):
"""Process download completion in background thread"""
try:
print(f"🧵 Background worker processing download...")
# Add a small delay to ensure file is fully written
import time
time.sleep(1)
# Organize the file into Transfer folder structure
organized_path = self.organize_func(self.download_item, self.absolute_file_path)
# Emit completion signal
self.signals.completed.emit(self.download_item, organized_path or self.absolute_file_path)
except Exception as e:
print(f"❌ Error in background worker: {e}")
import traceback
traceback.print_exc()
# Emit error signal
self.signals.error.emit(self.download_item, str(e))
class ImageDownloaderSignals(QObject):
"""Signals for the ImageDownloader worker."""
finished = pyqtSignal(QLabel, QPixmap)
error = pyqtSignal(str)
class ImageDownloader(QRunnable):
"""Worker to download an image in the background."""
def __init__(self, url: str, target_label: QLabel):
super().__init__()
self.signals = ImageDownloaderSignals()
self.url = url
self.target_label = target_label
def run(self):
try:
if not self.url:
self.signals.error.emit("No image URL provided.")
return
response = requests.get(self.url, stream=True, timeout=10)
response.raise_for_status()
pixmap = QPixmap()
pixmap.loadFromData(response.content)
if not pixmap.isNull():
self.signals.finished.emit(self.target_label, pixmap)
else:
self.signals.error.emit("Failed to load image from data.")
except requests.RequestException as e:
self.signals.error.emit(f"Network error downloading image: {e}")
except Exception as e:
self.signals.error.emit(f"Error processing image: {e}")
class ArtistSearchWorker(QThread):
"""Background worker for artist search"""
artists_found = pyqtSignal(list) # List of ArtistMatch objects
search_failed = pyqtSignal(str)
def __init__(self, query: str, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine):
super().__init__()
self.query = query
self.spotify_client = spotify_client
self.matching_engine = matching_engine
def run(self):
try:
# Search for artists using Spotify
artists = self.spotify_client.search_artists(self.query, limit=10)
# Create artist matches with confidence scores
artist_matches = []
for artist in artists:
# Calculate confidence based on name similarity
confidence = self.matching_engine.similarity_score(self.query.lower(), artist.name.lower())
match = ArtistMatch(
artist=artist,
confidence=confidence,
match_reason=f"Name similarity: {confidence:.1%}"
)
artist_matches.append(match)
# Sort by confidence score
artist_matches.sort(key=lambda x: x.confidence, reverse=True)
self.artists_found.emit(artist_matches)
except Exception as e:
self.search_failed.emit(str(e))
class AlbumFetchWorker(QThread):
"""Background worker for fetching artist albums"""
albums_found = pyqtSignal(list, object) # List of albums, selected artist
fetch_failed = pyqtSignal(str)
def __init__(self, artist: Artist, spotify_client: SpotifyClient):
super().__init__()
self.artist = artist
self.spotify_client = spotify_client
def run(self):
try:
print(f"🎵 Fetching albums for artist: {self.artist.name} (ID: {self.artist.id})")
# Use the proper Spotify API method to get albums by artist
albums = self.spotify_client.get_artist_albums(self.artist.id, album_type='album', limit=50)
print(f"📀 Found {len(albums)} albums for {self.artist.name}")
if not albums:
print("⚠️ No albums found, trying with singles included...")
# If no albums found, try including singles
albums = self.spotify_client.get_artist_albums(self.artist.id, album_type='album,single', limit=50)
print(f"📀 Found {len(albums)} items including singles")
# Remove duplicates based on name (case insensitive)
seen_names = set()
unique_albums = []
for album in albums:
album_name_lower = album.name.lower()
if album_name_lower not in seen_names:
seen_names.add(album_name_lower)
unique_albums.append(album)
# Sort by release date (newest first)
unique_albums.sort(key=lambda x: x.release_date if x.release_date else '', reverse=True)
print(f"✅ Returning {len(unique_albums)} unique albums")
self.albums_found.emit(unique_albums, self.artist)
except Exception as e:
error_msg = f"Failed to fetch albums for {self.artist.name}: {str(e)}"
print(f"{error_msg}")
self.fetch_failed.emit(error_msg)
class AlbumSearchWorker(QThread):
"""Background worker for searching albums on Soulseek"""
search_results = pyqtSignal(list) # List of AlbumResult objects
search_failed = pyqtSignal(str)
search_progress = pyqtSignal(str) # Progress messages
def __init__(self, query: str, soulseek_client: SoulseekClient):
super().__init__()
self.query = query
self.soulseek_client = soulseek_client
self._stop_requested = False
def stop(self):
"""Request to stop the search"""
self._stop_requested = True
def run(self):
"""Executes the album search asynchronously."""
loop = None
try:
if not self.soulseek_client:
self.search_failed.emit("Soulseek client not available")
return
# Create a new event loop for this thread to run async operations
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self.search_progress.emit(f"Searching for: {self.query}")
# Perform the async search using the provided query
results = loop.run_until_complete(self.soulseek_client.search(self.query))
if self._stop_requested:
return
# The search method returns a tuple of (tracks, albums)
tracks, albums = results if results else ([], [])
album_results = albums if albums else []
# Sort by a combination of track count and total size for relevance
album_results.sort(key=lambda x: (x.track_count, x.total_size), reverse=True)
self.search_results.emit(album_results)
except Exception as e:
if not self._stop_requested:
import traceback
traceback.print_exc()
self.search_failed.emit(str(e))
finally:
# Ensure the event loop is properly closed
if loop:
try:
loop.close()
except Exception as e:
print(f"Error closing event loop in AlbumSearchWorker: {e}")
class PlexLibraryWorker(QThread):
"""Background worker for checking Plex library"""
library_checked = pyqtSignal(set) # Set of owned album names (final result)
album_matched = pyqtSignal(str) # Individual album match (album name)
check_failed = pyqtSignal(str)
def __init__(self, albums, plex_client, matching_engine):
super().__init__()
self.albums = albums
self.plex_client = plex_client
self.matching_engine = matching_engine
self._stop_requested = False
def stop(self):
"""Request to stop the check"""
self._stop_requested = True
def run(self):
try:
print("🔍 Starting robust Plex album matching...")
owned_albums = set()
if not self.plex_client or not self.plex_client.ensure_connection():
print("⚠️ Plex client not available or not connected")
self.library_checked.emit(owned_albums)
return
if self._stop_requested:
return
print(f"📚 Checking {len(self.albums)} Spotify albums against Plex library...")
# Use robust matching for each album
for i, spotify_album in enumerate(self.albums):
if self._stop_requested:
return
print(f"🎵 Checking album {i+1}/{len(self.albums)}: {spotify_album.name}")
# Create multiple search variations
album_variations = []
# Original name
album_variations.append(spotify_album.name)
# Cleaned name (removes versions, etc.)
cleaned_name = self.matching_engine.clean_album_name(spotify_album.name)
if cleaned_name != spotify_album.name.lower():
album_variations.append(cleaned_name)
# Try different artist combinations
artists_to_try = spotify_album.artists[:2] if spotify_album.artists else [""]
all_plex_matches = []
# Search with different combinations
for artist in artists_to_try:
if self._stop_requested:
return
artist_clean = self.matching_engine.clean_artist(artist) if artist else ""
for album_name in album_variations:
if self._stop_requested:
return
# Search Plex for this combination
print(f" 🔍 Searching Plex: album='{album_name}', artist='{artist_clean}'")
plex_albums = self.plex_client.search_albums(album_name, artist_clean, limit=5)
print(f" 📀 Found {len(plex_albums)} Plex albums")
all_plex_matches.extend(plex_albums)
# Also try album-only search if artist+album didn't work
if not plex_albums and artist_clean:
print(f" 🔍 Trying album-only search: album='{album_name}'")
album_only_results = self.plex_client.search_albums(album_name, "", limit=5)
print(f" 📀 Found {len(album_only_results)} albums (album-only)")
all_plex_matches.extend(album_only_results)
# Remove duplicates based on album ID
unique_matches = {}
for match in all_plex_matches:
unique_matches[match['id']] = match
unique_plex_albums = list(unique_matches.values())
if unique_plex_albums:
# Use robust matching to find best match
best_match, confidence = self.matching_engine.find_best_album_match(
spotify_album, unique_plex_albums
)
if best_match and confidence >= 0.8:
owned_albums.add(spotify_album.name)
print(f"✅ Match found: '{spotify_album.name}' -> '{best_match['title']}' (confidence: {confidence:.2f})")
# Emit individual match for real-time UI update
self.album_matched.emit(spotify_album.name)
else:
print(f"❌ No confident match for '{spotify_album.name}' (best: {confidence:.2f})")
else:
print(f"❌ No Plex candidates found for '{spotify_album.name}'")
print(f"🎯 Final result: {len(owned_albums)} owned albums out of {len(self.albums)}")
print(f"🚀 Emitting signal with owned_albums: {list(owned_albums)}")
self.library_checked.emit(owned_albums)
except Exception as e:
if not self._stop_requested:
error_msg = f"Error checking Plex library: {e}"
print(f"{error_msg}")
self.check_failed.emit(error_msg)
class AlbumSearchDialog(QDialog):
"""Dialog for displaying album search results and allowing selection"""
album_selected = pyqtSignal(object) # AlbumResult object
def __init__(self, album: Album, parent=None):
super().__init__(parent)
self.album = album
self.selected_album_result = None
self.selected_widget = None
self.search_worker = None
self.setup_ui()
self.start_search() # Start automatic search on open
def setup_ui(self):
self.setWindowTitle(f"Download Source for: {self.album.name}")
self.setFixedSize(800, 700)
self.setStyleSheet("""
QDialog { background: #191414; color: #ffffff; }
QScrollArea { border: 1px solid #404040; border-radius: 8px; background: #282828; }
QLineEdit {
background: #333; border: 1px solid #555; border-radius: 4px;
padding: 8px; font-size: 12px;
}
QPushButton {
background-color: #444; border: 1px solid #666; border-radius: 4px;
padding: 8px 12px; font-size: 12px;
}
QPushButton:hover { background-color: #555; }
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(15)
# Header
header_label = QLabel(f"Searching for: {self.album.name} by {', '.join(self.album.artists)}")
header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
# Manual Search Section
search_layout = QHBoxLayout()
self.manual_search_input = QLineEdit()
self.manual_search_input.setPlaceholderText("Refine search: Artist Album Title...")
self.manual_search_input.returnPressed.connect(self.trigger_manual_search)
self.search_cancel_btn = QPushButton("Search")
self.search_cancel_btn.setFixedWidth(120)
self.search_cancel_btn.clicked.connect(self.handle_search_cancel_click)
search_layout.addWidget(self.manual_search_input, 1)
search_layout.addWidget(self.search_cancel_btn)
# Status
self.status_label = QLabel("Initializing search...")
self.status_label.setStyleSheet("color: #b3b3b3;")
# Results Area
self.results_scroll = QScrollArea()
self.results_scroll.setWidgetResizable(True)
self.results_widget = QWidget()
self.results_layout = QVBoxLayout(self.results_widget)
self.results_layout.setSpacing(8)
self.results_layout.setContentsMargins(10, 10, 10, 10)
self.results_layout.addStretch(1)
self.results_scroll.setWidget(self.results_widget)
# Bottom Buttons
button_layout = QHBoxLayout()
self.download_btn = QPushButton("Download Selected")
self.download_btn.setEnabled(False) # Initially disabled
self.download_btn.setStyleSheet("background-color: #1db954; color: black;")
close_btn = QPushButton("Close")
self.download_btn.clicked.connect(self.download_selected)
close_btn.clicked.connect(self.reject)
button_layout.addStretch(1)
button_layout.addWidget(self.download_btn)
button_layout.addWidget(close_btn)
layout.addWidget(header_label)
layout.addLayout(search_layout)
layout.addWidget(self.status_label)
layout.addWidget(self.results_scroll, 1)
layout.addLayout(button_layout)
def handle_search_cancel_click(self):
"""Toggles between starting a search and cancelling an active one."""
if self.search_worker and self.search_worker.isRunning():
self.cancel_search()
else:
self.trigger_manual_search()
def trigger_manual_search(self):
"""Starts a new search using the text from the manual search input."""
query = self.manual_search_input.text().strip()
if query:
self.start_search(query)
def start_search(self, query: Optional[str] = None):
"""
Starts the album search. If a query is provided, it's a manual search.
Otherwise, it constructs an automatic query.
"""
if self.search_worker and self.search_worker.isRunning():
self.search_worker.stop()
self.search_worker.wait()
self.clear_results()
self.download_btn.setEnabled(False)
self.status_label.setText("Searching...")
self.set_search_button_to_cancel(True)
if query is None:
artist_part = self.album.artists[0] if self.album.artists else ""
query = f"{artist_part} {self.album.name}".strip()
self.manual_search_input.setText(query)
parent_page = self.parent()
if hasattr(parent_page, 'soulseek_client') and parent_page.soulseek_client:
self.search_worker = AlbumSearchWorker(query, parent_page.soulseek_client)
self.search_worker.search_results.connect(self.on_search_results)
self.search_worker.search_failed.connect(self.on_search_failed)
self.search_worker.search_progress.connect(self.on_search_progress)
self.search_worker.start()
else:
self.on_search_failed("Soulseek client not available")
def cancel_search(self):
if self.search_worker and self.search_worker.isRunning():
self.search_worker.stop()
self.status_label.setText("Search cancelled.")
self.set_search_button_to_cancel(False)
def on_search_progress(self, message):
self.status_label.setText(message)
def on_search_results(self, album_results):
self.set_search_button_to_cancel(False)
self.clear_results()
if not album_results:
self.status_label.setText("No albums found for this query.")
return
self.status_label.setText(f"Found {len(album_results)} potential albums. Click one to select.")
for album_result in album_results[:25]: # Show top 25
result_item = self.create_result_item(album_result)
self.results_layout.insertWidget(self.results_layout.count() - 1, result_item)
def on_search_failed(self, error):
self.set_search_button_to_cancel(False)
self.status_label.setText(f"Search failed: {error}")
def create_result_item(self, album_result: AlbumResult):
"""Creates a larger, more informative, and clickable result item widget."""
item = QFrame()
item.setFixedHeight(75) # Increased height for better readability
item.setCursor(Qt.CursorShape.PointingHandCursor)
item.setStyleSheet("""
QFrame {
background: rgba(40, 40, 40, 0.8);
border: 1px solid #555;
border-radius: 6px;
}
""")
# Connect the click event for the whole frame
item.mousePressEvent = lambda event: self.select_result(album_result, item)
layout = QHBoxLayout(item)
layout.setContentsMargins(15, 10, 15, 10)
layout.setSpacing(15)
info_layout = QVBoxLayout()
info_layout.setSpacing(4)
title_label = QLabel(f"{album_result.album_title} by {album_result.artist}")
title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
details_text = (f"{album_result.track_count} tracks | "
f"{self.format_size(album_result.total_size)} | "
f"Uploader: {album_result.username}")
details_label = QLabel(details_text)
details_label.setFont(QFont("Arial", 9))
details_label.setStyleSheet("color: #b3b3b3;")
info_layout.addWidget(title_label)
info_layout.addWidget(details_label)
quality_badge = self.create_quality_badge(album_result)
layout.addLayout(info_layout, 1)
layout.addWidget(quality_badge)
return item
def create_quality_badge(self, album_result: AlbumResult):
"""Creates a styled badge for displaying audio quality."""
quality = album_result.dominant_quality.upper()
# Safely calculate average bitrate from the album's tracks
bitrate = 0
if hasattr(album_result, 'tracks') and album_result.tracks:
valid_bitrates = [
track.bitrate for track in album_result.tracks
if hasattr(track, 'bitrate') and track.bitrate
]
if valid_bitrates:
bitrate = sum(valid_bitrates) // len(valid_bitrates)
badge_text = quality
if quality == 'MP3' and bitrate > 0:
badge_text = f"MP3 {bitrate}k"
elif quality == 'VBR':
badge_text = "MP3 VBR"
badge = QLabel(badge_text)
badge.setFixedWidth(80)
badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
badge.setFont(QFont("Arial", 9, QFont.Weight.Bold))
if quality == 'FLAC':
style = "background-color: #4CAF50; color: white; border-radius: 4px; padding: 5px;"
elif bitrate >= 320:
style = "background-color: #2196F3; color: white; border-radius: 4px; padding: 5px;"
elif bitrate >= 192 or quality == 'VBR':
style = "background-color: #FFC107; color: black; border-radius: 4px; padding: 5px;"
else:
style = "background-color: #F44336; color: white; border-radius: 4px; padding: 5px;"
badge.setStyleSheet(style)
return badge
def clear_results(self):
"""Removes all result widgets from the layout, preserving the stretch item."""
self.selected_widget = None # Clear selection
# Iterate backwards to safely remove items while preserving the stretch
for i in reversed(range(self.results_layout.count())):
item = self.results_layout.itemAt(i)
if item.widget():
widget = item.widget()
widget.deleteLater()
def format_size(self, size_bytes):
if size_bytes >= 1024**3: return f"{size_bytes / 1024**3:.1f} GB"
if size_bytes >= 1024**2: return f"{size_bytes / 1024**2:.1f} MB"
return f"{size_bytes / 1024:.1f} KB"
def select_result(self, album_result, selected_item_widget):
"""Handles the selection of a result and provides visual feedback."""
self.selected_album_result = album_result
self.download_btn.setEnabled(True)
# Deselect previous widget
if self.selected_widget:
self.selected_widget.setStyleSheet("""
QFrame { background: rgba(40, 40, 40, 0.8); border: 1px solid #555; border-radius: 6px; }
""")
# Apply selected style to the new widget
selected_item_widget.setStyleSheet("""
QFrame { background: rgba(29, 185, 84, 0.2); border: 1px solid #1db954; border-radius: 6px; }
""")
self.selected_widget = selected_item_widget
def download_selected(self):
if self.selected_album_result:
self.album_selected.emit(self.selected_album_result)
self.accept()
def set_search_button_to_cancel(self, is_searching: bool):
"""Changes the search button's text and style."""
if is_searching:
self.search_cancel_btn.setText("Cancel Search")
self.search_cancel_btn.setStyleSheet("background-color: #F44336; color: white;")
else:
self.search_cancel_btn.setText("Search")
self.search_cancel_btn.setStyleSheet("background-color: #1db954; color: black;")
def closeEvent(self, event):
self.cancel_search()
super().closeEvent(event)
class ArtistResultCard(QFrame):
"""Card widget for displaying artist search results"""
artist_selected = pyqtSignal(object) # Artist object
def __init__(self, artist_match: ArtistMatch, parent=None):
super().__init__(parent)
self.artist_match = artist_match
self.artist = artist_match.artist
self.setup_ui()
self.load_artist_image()
def setup_ui(self):
self.setFixedSize(200, 280)
self.setCursor(Qt.CursorShape.PointingHandCursor)
# Base styling with gradient background
self.setStyleSheet("""
ArtistResultCard {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(45, 45, 45, 0.95),
stop:1 rgba(35, 35, 35, 0.98));
border-radius: 12px;
border: 2px solid rgba(80, 80, 80, 0.4);
}
ArtistResultCard:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(29, 185, 84, 0.2),
stop:1 rgba(24, 156, 71, 0.3));
border: 2px solid rgba(29, 185, 84, 0.8);
}
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# Artist image container
self.image_container = QFrame()
self.image_container.setFixedSize(176, 176)
self.image_container.setStyleSheet("""
QFrame {
background: #404040;
border-radius: 88px;
border: 2px solid #606060;
}
""")
image_layout = QVBoxLayout(self.image_container)
image_layout.setContentsMargins(0, 0, 0, 0)
self.image_label = QLabel()
self.image_label.setFixedSize(172, 172)
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image_label.setStyleSheet("""
QLabel {
background: transparent;
border-radius: 86px;
color: #b3b3b3;
font-size: 48px;
}
""")
self.image_label.setText("🎵")
image_layout.addWidget(self.image_label)
# Artist name
name_label = QLabel(self.artist.name)
name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
name_label.setStyleSheet("color: #ffffff; padding: 4px;")
name_label.setWordWrap(True)
name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Confidence score
confidence_label = QLabel(f"Match: {self.artist_match.confidence:.0%}")
confidence_label.setFont(QFont("Arial", 9))
confidence_label.setStyleSheet("color: #1db954; padding: 2px;")
confidence_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Followers count
followers_text = self.format_followers(self.artist.followers)
followers_label = QLabel(f"{followers_text} followers")
followers_label.setFont(QFont("Arial", 8))
followers_label.setStyleSheet("color: #b3b3b3; padding: 2px;")
followers_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.image_container, 0, Qt.AlignmentFlag.AlignCenter)
layout.addWidget(name_label)
layout.addWidget(confidence_label)
layout.addWidget(followers_label)
layout.addStretch()
def format_followers(self, count: int) -> str:
"""Format follower count in human readable format"""
if count >= 1000000:
return f"{count / 1000000:.1f}M"
elif count >= 1000:
return f"{count / 1000:.1f}K"
else:
return str(count)
def load_artist_image(self):
"""Load artist image in background"""
if self.artist.image_url:
downloader = ImageDownloader(self.artist.image_url, self.image_label)
downloader.signals.finished.connect(self.on_image_loaded)
downloader.signals.error.connect(self.on_image_error)
QThreadPool.globalInstance().start(downloader)
def on_image_loaded(self, label, pixmap):
"""Handle successful image load"""
if label == self.image_label:
# Scale and mask the image to fit the circular container
scaled_pixmap = pixmap.scaled(172, 172, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
# Create circular mask
masked_pixmap = QPixmap(172, 172)
masked_pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(masked_pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setBrush(QColor(255, 255, 255))
painter.setPen(QPen(QColor(255, 255, 255)))
painter.drawEllipse(0, 0, 172, 172)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
painter.drawPixmap(0, 0, scaled_pixmap)
painter.end()
self.image_label.setPixmap(masked_pixmap)
def on_image_error(self, error):
"""Handle image load error"""
print(f"Failed to load artist image: {error}")
def mousePressEvent(self, event):
"""Handle click to select artist"""
if event.button() == Qt.MouseButton.LeftButton:
self.artist_selected.emit(self.artist)
super().mousePressEvent(event)
class AlbumCard(QFrame):
"""Card widget for displaying album information"""
download_requested = pyqtSignal(object) # Album object
def __init__(self, album: Album, is_owned: bool = False, parent=None):
super().__init__(parent)
self.album = album
self.is_owned = is_owned
self.setup_ui()
self.load_album_image()
def setup_ui(self):
self.setFixedSize(180, 240)
self.setStyleSheet("""
AlbumCard {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(40, 40, 40, 0.9),
stop:1 rgba(30, 30, 30, 0.95));
border-radius: 10px;
border: 1px solid rgba(70, 70, 70, 0.5);
}
AlbumCard:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(50, 50, 50, 0.95),
stop:1 rgba(40, 40, 40, 0.98));
border: 1px solid rgba(29, 185, 84, 0.6);
}
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(6)
# Album image container
self.image_container = QFrame()
self.image_container.setFixedSize(164, 164)
self.image_container.setStyleSheet("""
QFrame {
background: #404040;
border-radius: 6px;
border: 1px solid #606060;
}
""")
image_layout = QVBoxLayout(self.image_container)
image_layout.setContentsMargins(0, 0, 0, 0)
self.image_label = QLabel()
self.image_label.setFixedSize(162, 162)
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image_label.setStyleSheet("""
QLabel {
background: transparent;
border-radius: 5px;
color: #b3b3b3;
font-size: 32px;
}
""")
self.image_label.setText("💿")
image_layout.addWidget(self.image_label)
# Overlay for ownership status
self.overlay = QLabel(self.image_container)
self.overlay.setFixedSize(164, 164)
self.overlay.setAlignment(Qt.AlignmentFlag.AlignCenter)
if self.is_owned:
self.overlay.setStyleSheet("""
QLabel {
background: rgba(29, 185, 84, 0.8);
border-radius: 6px;
color: white;
font-size: 24px;
font-weight: bold;
}
""")
self.overlay.setText("")
else:
self.overlay.setStyleSheet("""
QLabel {
background: rgba(0, 0, 0, 0.7);
border-radius: 6px;
color: white;
font-size: 16px;
font-weight: bold;
}
""")
self.overlay.setText("📥\nDownload")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
self.overlay.hide() # Initially hidden, shown on hover
# Permanent ownership indicator (always visible)
self.status_indicator = QLabel(self.image_container)
self.status_indicator.setFixedSize(24, 24)
self.status_indicator.move(140, 8) # Top-right corner
self.status_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.update_status_indicator()
# Album name
album_label = QLabel(self.album.name)
album_label.setFont(QFont("Arial", 9, QFont.Weight.Bold))
album_label.setStyleSheet("color: #ffffff; padding: 2px;")
album_label.setWordWrap(True)
album_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
album_label.setMaximumHeight(32)
# Release year
year_label = QLabel(self.album.release_date[:4] if self.album.release_date else "Unknown")
year_label.setFont(QFont("Arial", 8))
year_label.setStyleSheet("color: #b3b3b3; padding: 1px;")
year_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.image_container, 0, Qt.AlignmentFlag.AlignCenter)
layout.addWidget(album_label)
layout.addWidget(year_label)
layout.addStretch()
def load_album_image(self):
"""Load album image in background"""
if self.album.image_url:
downloader = ImageDownloader(self.album.image_url, self.image_label)
downloader.signals.finished.connect(self.on_image_loaded)
downloader.signals.error.connect(self.on_image_error)
QThreadPool.globalInstance().start(downloader)
def on_image_loaded(self, label, pixmap):
"""Handle successful image load"""
if label == self.image_label:
scaled_pixmap = pixmap.scaled(162, 162, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
self.image_label.setPixmap(scaled_pixmap)
def on_image_error(self, error):
"""Handle image load error"""
print(f"Failed to load album image: {error}")
def enterEvent(self, event):
"""Show overlay on hover"""
self.overlay.show()
super().enterEvent(event)
def leaveEvent(self, event):
"""Hide overlay when not hovering"""
self.overlay.hide()
super().leaveEvent(event)
def update_status_indicator(self):
"""Update the permanent status indicator"""
if self.is_owned:
self.status_indicator.setStyleSheet("""
QLabel {
background: rgba(29, 185, 84, 0.9);
border-radius: 12px;
color: white;
font-size: 14px;
font-weight: bold;
}
""")
self.status_indicator.setText("")
self.status_indicator.setToolTip("Album owned in Plex")
else:
self.status_indicator.setStyleSheet("""
QLabel {
background: rgba(220, 53, 69, 0.8);
border-radius: 12px;
color: white;
font-size: 12px;
font-weight: bold;
}
""")
self.status_indicator.setText("📥")
self.status_indicator.setToolTip("Album available for download")
def update_ownership(self, is_owned: bool):
"""Update ownership status and refresh UI"""
if self.is_owned != is_owned: # Only log if status actually changed
print(f"🔄 '{self.album.name}' ownership: {self.is_owned} -> {is_owned}")
self.is_owned = is_owned
# Update the permanent indicator
self.update_status_indicator()
# Update the hover overlay
if self.is_owned:
self.overlay.setStyleSheet("""
QLabel {
background: rgba(29, 185, 84, 0.8);
border-radius: 6px;
color: white;
font-size: 24px;
font-weight: bold;
}
""")
self.overlay.setText("")
self.overlay.setCursor(Qt.CursorShape.ArrowCursor)
else:
self.overlay.setStyleSheet("""
QLabel {
background: rgba(0, 0, 0, 0.7);
border-radius: 6px;
color: white;
font-size: 16px;
font-weight: bold;
}
""")
self.overlay.setText("📥\nDownload")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
def mousePressEvent(self, event):
"""Handle click for download"""
if event.button() == Qt.MouseButton.LeftButton and not self.is_owned:
self.download_requested.emit(self.album)
super().mousePressEvent(event)
class ArtistsPage(QWidget):
def __init__(self, downloads_page=None, parent=None):
super().__init__(parent)
# Core clients
self.spotify_client = None
self.plex_client = None
self.soulseek_client = None
self.downloads_page = downloads_page # Store reference to DownloadsPage
self.matching_engine = MusicMatchingEngine()
# State management
self.selected_artist = None
self.current_albums = []
self.matched_count = 0
self.artist_search_worker = None
self.album_fetch_worker = None
self.plex_library_worker = None
# UI setup
self.setup_ui()
self.setup_clients()
def setup_clients(self):
"""Initialize client connections"""
try:
self.spotify_client = SpotifyClient()
self.plex_client = PlexClient()
self.soulseek_client = SoulseekClient()
except Exception as e:
print(f"Failed to initialize clients: {e}")
def setup_ui(self):
self.setStyleSheet("""
ArtistsPage {
background: #191414;
}
""")
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(30, 30, 30, 30)
main_layout.setSpacing(20)
# Create main container for dynamic content switching
self.main_container = QWidget()
container_layout = QVBoxLayout(self.main_container)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(0)
# Initial centered search interface
self.search_interface = self.create_search_interface()
container_layout.addWidget(self.search_interface)
# Artist view (initially hidden)
self.artist_view = self.create_artist_view()
self.artist_view.hide()
container_layout.addWidget(self.artist_view)
main_layout.addWidget(self.main_container)
def create_search_interface(self):
"""Create the initial centered search interface"""
widget = QWidget()
layout = QVBoxLayout(widget)
# Add vertical stretch to center content
layout.addStretch(2)
# Title section
title_container = QWidget()
title_layout = QVBoxLayout(title_container)
title_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_layout.setSpacing(10)
title_label = QLabel("Discover Artists")
title_label.setFont(QFont("Arial", 32, QFont.Weight.Bold))
title_label.setStyleSheet("color: #ffffff;")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
subtitle_label = QLabel("Search for any artist to explore their complete discography")
subtitle_label.setFont(QFont("Arial", 16))
subtitle_label.setStyleSheet("color: #b3b3b3;")
subtitle_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_layout.addWidget(title_label)
title_layout.addWidget(subtitle_label)
# Search bar
search_container = QFrame()
search_container.setFixedHeight(80)
search_container.setStyleSheet("""
QFrame {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(50, 50, 50, 0.9),
stop:1 rgba(40, 40, 40, 0.95));
border-radius: 16px;
border: 2px solid rgba(29, 185, 84, 0.3);
}
""")
search_layout = QHBoxLayout(search_container)
search_layout.setContentsMargins(24, 20, 24, 20)
search_layout.setSpacing(16)
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Search for an artist... (e.g., 'The Beatles', 'Taylor Swift')")
self.search_input.setFixedHeight(40)
self.search_input.setStyleSheet("""
QLineEdit {
background: rgba(70, 70, 70, 0.8);
border: 2px solid rgba(100, 100, 100, 0.3);
border-radius: 20px;
padding: 0 20px;
color: #ffffff;
font-size: 16px;
font-weight: 500;
}
QLineEdit:focus {
border: 2px solid rgba(29, 185, 84, 0.8);
background: rgba(80, 80, 80, 0.9);
}
QLineEdit::placeholder {
color: rgba(255, 255, 255, 0.5);
}
""")
self.search_input.returnPressed.connect(self.perform_artist_search)
search_btn = QPushButton("🔍 Search Artists")
search_btn.setFixedHeight(40)
search_btn.setStyleSheet("""
QPushButton {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(29, 185, 84, 1.0),
stop:1 rgba(24, 156, 71, 1.0));
border: none;
border-radius: 20px;
color: #000000;
font-size: 14px;
font-weight: bold;
padding: 0 24px;
}
QPushButton:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(30, 215, 96, 1.0),
stop:1 rgba(26, 174, 81, 1.0));
}
""")
search_btn.clicked.connect(self.perform_artist_search)
search_layout.addWidget(self.search_input)
search_layout.addWidget(search_btn)
# Status label
self.search_status = QLabel("Ready to search")
self.search_status.setFont(QFont("Arial", 12))
self.search_status.setStyleSheet("color: rgba(255, 255, 255, 0.7); padding: 10px;")
self.search_status.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Artist results container (initially hidden)
self.artist_results_container = QFrame()
self.artist_results_container.setStyleSheet("""
QFrame {
background: rgba(30, 30, 30, 0.6);
border-radius: 12px;
border: 1px solid rgba(60, 60, 60, 0.4);
}
""")
self.artist_results_container.hide()
results_layout = QVBoxLayout(self.artist_results_container)
results_layout.setContentsMargins(20, 16, 20, 20)
results_layout.setSpacing(16)
results_header = QLabel("Artist Results")
results_header.setFont(QFont("Arial", 14, QFont.Weight.Bold))
results_header.setStyleSheet("color: #ffffff;")
results_layout.addWidget(results_header)
# Scrollable artist results
self.artist_scroll = QScrollArea()
self.artist_scroll.setWidgetResizable(True)
self.artist_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.artist_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.artist_scroll.setMaximumHeight(400)
self.artist_scroll.setStyleSheet("""
QScrollArea {
border: none;
background: transparent;
}
QScrollBar:vertical {
background: rgba(80, 80, 80, 0.3);
width: 8px;
border-radius: 4px;
}
QScrollBar::handle:vertical {
background: rgba(29, 185, 84, 0.8);
border-radius: 4px;
min-height: 20px;
}
""")
self.artist_results_widget = QWidget()
self.artist_results_layout = QHBoxLayout(self.artist_results_widget)
self.artist_results_layout.setSpacing(16)
self.artist_results_layout.setContentsMargins(0, 0, 0, 0)
self.artist_scroll.setWidget(self.artist_results_widget)
results_layout.addWidget(self.artist_scroll)
# Add everything to main layout
layout.addWidget(title_container)
layout.addSpacing(40)
layout.addWidget(search_container)
layout.addSpacing(20)
layout.addWidget(self.search_status)
layout.addSpacing(20)
layout.addWidget(self.artist_results_container)
layout.addStretch(2)
return widget
def create_artist_view(self):
"""Create the artist view for displaying albums"""
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(20)
# Header with artist info and repositioned search
header = QFrame()
header.setFixedHeight(100)
header.setStyleSheet("""
QFrame {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(40, 40, 40, 0.9),
stop:1 rgba(30, 30, 30, 0.95));
border-radius: 12px;
border: 1px solid rgba(60, 60, 60, 0.4);
}
""")
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(20, 16, 20, 16)
header_layout.setSpacing(20)
# Artist info section
artist_info_layout = QVBoxLayout()
self.artist_name_label = QLabel()
self.artist_name_label.setFont(QFont("Arial", 20, QFont.Weight.Bold))
self.artist_name_label.setStyleSheet("color: #ffffff;")
self.artist_stats_label = QLabel()
self.artist_stats_label.setFont(QFont("Arial", 11))
self.artist_stats_label.setStyleSheet("color: #b3b3b3;")
artist_info_layout.addWidget(self.artist_name_label)
artist_info_layout.addWidget(self.artist_stats_label)
# New search bar (smaller, in header)
self.header_search_input = QLineEdit()
self.header_search_input.setPlaceholderText("Search for another artist...")
self.header_search_input.setFixedHeight(36)
self.header_search_input.setFixedWidth(300)
self.header_search_input.setStyleSheet("""
QLineEdit {
background: rgba(60, 60, 60, 0.8);
border: 1px solid rgba(100, 100, 100, 0.4);
border-radius: 18px;
padding: 0 16px;
color: #ffffff;
font-size: 12px;
}
QLineEdit:focus {
border: 1px solid rgba(29, 185, 84, 0.8);
}
""")
self.header_search_input.returnPressed.connect(self.perform_new_artist_search)
# Back button
back_btn = QPushButton("← Back to Search")
back_btn.setFixedHeight(36)
back_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: 1px solid rgba(29, 185, 84, 0.6);
border-radius: 18px;
color: #1db954;
font-size: 12px;
padding: 0 16px;
}
QPushButton:hover {
background: rgba(29, 185, 84, 0.1);
}
""")
back_btn.clicked.connect(self.return_to_search)
header_layout.addLayout(artist_info_layout)
header_layout.addStretch()
header_layout.addWidget(self.header_search_input)
header_layout.addWidget(back_btn)
# Albums section
albums_container = QFrame()
albums_container.setStyleSheet("""
QFrame {
background: rgba(25, 25, 25, 0.6);
border-radius: 12px;
border: 1px solid rgba(50, 50, 50, 0.4);
}
""")
albums_layout = QVBoxLayout(albums_container)
albums_layout.setContentsMargins(20, 16, 20, 20)
albums_layout.setSpacing(16)
# Albums header
albums_header_layout = QHBoxLayout()
albums_title = QLabel("Albums")
albums_title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
albums_title.setStyleSheet("color: #ffffff;")
self.albums_status = QLabel("Loading albums...")
self.albums_status.setFont(QFont("Arial", 11))
self.albums_status.setStyleSheet("color: #b3b3b3;")
albums_header_layout.addWidget(albums_title)
albums_header_layout.addStretch()
albums_header_layout.addWidget(self.albums_status)
albums_layout.addLayout(albums_header_layout)
# Albums grid
self.albums_scroll = QScrollArea()
self.albums_scroll.setWidgetResizable(True)
self.albums_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.albums_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.albums_scroll.setStyleSheet("""
QScrollArea {
border: none;
background: transparent;
}
QScrollBar:vertical {
background: rgba(80, 80, 80, 0.3);
width: 8px;
border-radius: 4px;
}
QScrollBar::handle:vertical {
background: rgba(29, 185, 84, 0.8);
border-radius: 4px;
min-height: 20px;
}
""")
self.albums_widget = QWidget()
self.albums_grid_layout = QGridLayout(self.albums_widget)
self.albums_grid_layout.setSpacing(16)
self.albums_grid_layout.setContentsMargins(0, 0, 0, 0)
self.albums_scroll.setWidget(self.albums_widget)
albums_layout.addWidget(self.albums_scroll)
layout.addWidget(header)
layout.addWidget(albums_container, 1)
return widget
def perform_artist_search(self):
"""Perform artist search"""
query = self.search_input.text().strip()
if not query:
self.search_status.setText("Please enter an artist name")
self.search_status.setStyleSheet("color: #ff6b6b; padding: 10px;")
return
if not self.spotify_client or not self.spotify_client.is_authenticated():
self.search_status.setText("Spotify not connected")
self.search_status.setStyleSheet("color: #ff6b6b; padding: 10px;")
return
self.search_status.setText("🔍 Searching for artists...")
self.search_status.setStyleSheet("color: #1db954; padding: 10px;")
# Clear previous results
self.clear_artist_results()
# Start search worker
if self.artist_search_worker:
self.artist_search_worker.terminate()
self.artist_search_worker.wait()
self.artist_search_worker = ArtistSearchWorker(query, self.spotify_client, self.matching_engine)
self.artist_search_worker.artists_found.connect(self.on_artists_found)
self.artist_search_worker.search_failed.connect(self.on_artist_search_failed)
self.artist_search_worker.start()
def perform_new_artist_search(self):
"""Perform new artist search from header"""
query = self.header_search_input.text().strip()
if query:
self.search_input.setText(query)
self.return_to_search()
QTimer.singleShot(100, self.perform_artist_search)
def on_artists_found(self, artist_matches):
"""Handle artist search results"""
if not artist_matches:
self.search_status.setText("No artists found")
self.search_status.setStyleSheet("color: #ff6b6b; padding: 10px;")
return
self.search_status.setText(f"Found {len(artist_matches)} artists")
self.search_status.setStyleSheet("color: #1db954; padding: 10px;")
# Display artist results
for artist_match in artist_matches[:10]: # Show top 10 results
card = ArtistResultCard(artist_match)
card.artist_selected.connect(self.on_artist_selected)
self.artist_results_layout.addWidget(card)
self.artist_results_layout.addStretch()
self.artist_results_container.show()
def on_artist_search_failed(self, error):
"""Handle artist search failure"""
self.search_status.setText(f"Search failed: {error}")
self.search_status.setStyleSheet("color: #ff6b6b; padding: 10px;")
def on_artist_selected(self, artist):
"""Handle artist selection"""
self.selected_artist = artist
# Update artist view
self.artist_name_label.setText(artist.name)
self.artist_stats_label.setText(f"{artist.followers:,} followers • {len(artist.genres)} genres")
# Switch to artist view
self.search_interface.hide()
self.artist_view.show()
# Start fetching albums
self.fetch_artist_albums(artist)
def fetch_artist_albums(self, artist):
"""Fetch albums for selected artist"""
self.albums_status.setText("Loading albums...")
# Clear previous albums
self.clear_albums()
# Start album fetch worker
if self.album_fetch_worker:
self.album_fetch_worker.terminate()
self.album_fetch_worker.wait()
self.album_fetch_worker = AlbumFetchWorker(artist, self.spotify_client)
self.album_fetch_worker.albums_found.connect(self.on_albums_found)
self.album_fetch_worker.fetch_failed.connect(self.on_album_fetch_failed)
self.album_fetch_worker.start()
def on_albums_found(self, albums, artist):
"""Handle album fetch results"""
if not albums:
self.albums_status.setText("No albums found")
return
self.current_albums = albums
self.albums_status.setText(f"Found {len(albums)} albums • Checking Plex library...")
# Initialize match counter for real-time updates
self.matched_count = 0
# Display albums immediately (without ownership info)
self.display_albums(albums, set())
# Start Plex library check in background - will update UI when complete
self.start_plex_library_check(albums)
def display_albums(self, albums, owned_albums):
"""Display albums in the grid"""
print(f"🎨 Displaying {len(albums)} albums, {len(owned_albums)} owned")
# Clear existing albums
self.clear_albums()
row, col = 0, 0
max_cols = 5
for album in albums:
is_owned = album.name in owned_albums
card = AlbumCard(album, is_owned)
if not is_owned:
card.download_requested.connect(self.on_album_download_requested)
self.albums_grid_layout.addWidget(card, row, col)
col += 1
if col >= max_cols:
col = 0
row += 1
def start_plex_library_check(self, albums):
"""Start Plex library check in background"""
# Stop any existing Plex worker
if self.plex_library_worker:
self.plex_library_worker.stop()
self.plex_library_worker.terminate()
self.plex_library_worker.wait()
# Start new Plex worker
self.plex_library_worker = PlexLibraryWorker(albums, self.plex_client, self.matching_engine)
self.plex_library_worker.library_checked.connect(self.on_plex_library_checked)
self.plex_library_worker.album_matched.connect(self.on_album_matched)
self.plex_library_worker.check_failed.connect(self.on_plex_library_check_failed)
self.plex_library_worker.start()
def on_plex_library_checked(self, owned_albums):
"""Handle final Plex library check completion"""
print(f"📨 Plex check completed: {len(owned_albums)} total matches")
if not self.current_albums:
print("📨 No current albums, skipping final update")
return
# Update final status message
owned_count = len(owned_albums)
total_count = len(self.current_albums)
missing_count = total_count - owned_count
self.albums_status.setText(f"Found {total_count} albums • {owned_count} owned • {missing_count} available for download")
print(f"✅ Plex check complete: {owned_count}/{total_count} albums owned")
def on_album_matched(self, album_name):
"""Handle individual album match for real-time UI update"""
print(f"🎯 Real-time match: '{album_name}'")
# Update match counter
self.matched_count += 1
# Update status text in real-time
if self.current_albums:
total_count = len(self.current_albums)
remaining_count = total_count - self.matched_count
self.albums_status.setText(f"Found {total_count} albums • {self.matched_count} owned • {remaining_count} checking...")
# Find and update the specific album card
for i in range(self.albums_grid_layout.count()):
item = self.albums_grid_layout.itemAt(i)
if item and item.widget():
album_card = item.widget()
if hasattr(album_card, 'album') and album_card.album.name == album_name:
print(f"🔄 Real-time update: '{album_name}' -> owned")
album_card.update_ownership(True)
break
def on_plex_library_check_failed(self, error):
"""Handle Plex library check failure"""
print(f"Plex library check failed: {error}")
if self.current_albums:
self.albums_status.setText(f"Found {len(self.current_albums)} albums • Plex check failed")
# Display albums without ownership info
self.display_albums(self.current_albums, set())
def on_album_fetch_failed(self, error):
"""Handle album fetch failure"""
self.albums_status.setText(f"Failed to load albums: {error}")
def on_album_download_requested(self, album: Album):
"""Handle album download request from an AlbumCard."""
print(f"Download requested for album: {album.name} by {', '.join(album.artists)}")
# Store the album object that needs to be downloaded
self.album_to_download = album
# Open the album search dialog to find a source on Soulseek
dialog = AlbumSearchDialog(album, self)
dialog.album_selected.connect(self.on_album_selected_for_download)
dialog.exec()
def on_album_selected_for_download(self, album_result: AlbumResult):
"""
Handles album selection from the search dialog and delegates the
matched album download process to the main DownloadsPage.
"""
print(f"Selected album for download: {album_result.album_title} by {album_result.artist}")
if self.downloads_page:
# Delegate to the DownloadsPage to handle the matched download
# This will open the Spotify matching modal and add to the central queue
print("🚀 Delegating to DownloadsPage to start matched album download...")
self.downloads_page.start_matched_album_download(album_result)
else:
QMessageBox.critical(self, "Error", "Downloads page is not connected. Cannot start download.")
def return_to_search(self):
"""Return to search interface"""
# Stop any running workers
self.stop_all_workers()
# Clear state
self.selected_artist = None
self.current_albums = []
self.matched_count = 0
self.header_search_input.clear()
# Clear albums display
self.clear_albums()
# Switch views
self.artist_view.hide()
self.search_interface.show()
def stop_all_workers(self):
"""Stop all background workers"""
if self.artist_search_worker:
self.artist_search_worker.terminate()
self.artist_search_worker.wait()
self.artist_search_worker = None
if self.album_fetch_worker:
self.album_fetch_worker.terminate()
self.album_fetch_worker.wait()
self.album_fetch_worker = None
if self.plex_library_worker:
self.plex_library_worker.stop()
self.plex_library_worker.terminate()
self.plex_library_worker.wait()
self.plex_library_worker = None
def clear_artist_results(self):
"""Clear artist search results"""
while self.artist_results_layout.count() > 0:
item = self.artist_results_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self.artist_results_container.hide()
def clear_albums(self):
"""Clear album display"""
while self.albums_grid_layout.count() > 0:
item = self.albums_grid_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
# Don't clear self.current_albums here - it's needed for Plex updates
def closeEvent(self, event):
"""Handle page close/cleanup"""
self.stop_all_workers()
super().closeEvent(event)