1683 lines
No EOL
64 KiB
Python
1683 lines
No EOL
64 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
|
|
|
|
@dataclass
|
|
class ArtistMatch:
|
|
"""Represents an artist match with confidence score"""
|
|
artist: Artist
|
|
confidence: float
|
|
match_reason: str = ""
|
|
|
|
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, album: Album, soulseek_client: SoulseekClient):
|
|
super().__init__()
|
|
self.album = album
|
|
self.soulseek_client = soulseek_client
|
|
self._stop_requested = False
|
|
|
|
def stop(self):
|
|
"""Request to stop the search"""
|
|
self._stop_requested = True
|
|
|
|
def run(self):
|
|
try:
|
|
if not self.soulseek_client:
|
|
self.search_failed.emit("Soulseek client not available")
|
|
return
|
|
|
|
# Create search query for the album
|
|
search_query = f'"{self.album.name}"'
|
|
if self.album.artists:
|
|
search_query = f'"{self.album.artists[0]}" "{self.album.name}"'
|
|
|
|
self.search_progress.emit(f"Searching for: {search_query}")
|
|
|
|
# Perform the search
|
|
results = self.soulseek_client.search(search_query)
|
|
|
|
if self._stop_requested:
|
|
return
|
|
|
|
# Filter to album results only
|
|
album_results = []
|
|
if results:
|
|
tracks, albums = results # Unpack the tuple returned by search
|
|
album_results = albums if albums else []
|
|
|
|
# Sort by quality/size
|
|
album_results.sort(key=lambda x: (x.total_size, x.track_count), reverse=True)
|
|
|
|
self.search_results.emit(album_results)
|
|
|
|
except Exception as e:
|
|
if not self._stop_requested:
|
|
self.search_failed.emit(str(e))
|
|
|
|
class PlexLibraryWorker(QThread):
|
|
"""Background worker for checking Plex library"""
|
|
library_checked = pyqtSignal(set) # Set of owned album names
|
|
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})")
|
|
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.search_worker = None
|
|
self.setup_ui()
|
|
self.start_search()
|
|
|
|
def setup_ui(self):
|
|
self.setWindowTitle(f"Download Album: {self.album.name}")
|
|
self.setFixedSize(600, 500)
|
|
self.setStyleSheet("""
|
|
QDialog {
|
|
background: #191414;
|
|
color: #ffffff;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(20, 20, 20, 20)
|
|
layout.setSpacing(16)
|
|
|
|
# Header
|
|
header_label = QLabel(f"Searching for: {self.album.name}")
|
|
header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
|
header_label.setStyleSheet("color: #ffffff; padding: 8px;")
|
|
|
|
artist_label = QLabel(f"By: {', '.join(self.album.artists)}")
|
|
artist_label.setFont(QFont("Arial", 11))
|
|
artist_label.setStyleSheet("color: #b3b3b3; padding: 4px;")
|
|
|
|
# Progress bar
|
|
self.progress_bar = QProgressBar()
|
|
self.progress_bar.setRange(0, 0) # Indeterminate progress
|
|
self.progress_bar.setStyleSheet("""
|
|
QProgressBar {
|
|
border: 1px solid #404040;
|
|
border-radius: 4px;
|
|
background: #282828;
|
|
text-align: center;
|
|
color: #ffffff;
|
|
}
|
|
QProgressBar::chunk {
|
|
background: #1db954;
|
|
border-radius: 3px;
|
|
}
|
|
""")
|
|
|
|
# Status label
|
|
self.status_label = QLabel("Initializing search...")
|
|
self.status_label.setFont(QFont("Arial", 10))
|
|
self.status_label.setStyleSheet("color: #b3b3b3; padding: 4px;")
|
|
|
|
# Results area
|
|
self.results_scroll = QScrollArea()
|
|
self.results_scroll.setWidgetResizable(True)
|
|
self.results_scroll.setStyleSheet("""
|
|
QScrollArea {
|
|
border: 1px solid #404040;
|
|
border-radius: 8px;
|
|
background: #282828;
|
|
}
|
|
QScrollBar:vertical {
|
|
background: #404040;
|
|
width: 12px;
|
|
border-radius: 6px;
|
|
}
|
|
QScrollBar::handle:vertical {
|
|
background: #1db954;
|
|
border-radius: 6px;
|
|
min-height: 20px;
|
|
}
|
|
""")
|
|
|
|
self.results_widget = QWidget()
|
|
self.results_layout = QVBoxLayout(self.results_widget)
|
|
self.results_layout.setSpacing(8)
|
|
self.results_layout.setContentsMargins(12, 12, 12, 12)
|
|
|
|
self.results_scroll.setWidget(self.results_widget)
|
|
|
|
# Buttons
|
|
button_layout = QHBoxLayout()
|
|
|
|
self.cancel_search_btn = QPushButton("Cancel Search")
|
|
self.cancel_search_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: #ff6b6b;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: white;
|
|
font-weight: bold;
|
|
padding: 8px 16px;
|
|
}
|
|
QPushButton:hover {
|
|
background: #ff5252;
|
|
}
|
|
""")
|
|
self.cancel_search_btn.clicked.connect(self.cancel_search)
|
|
|
|
self.download_btn = QPushButton("Download Selected")
|
|
self.download_btn.setEnabled(False)
|
|
self.download_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: #1db954;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: white;
|
|
font-weight: bold;
|
|
padding: 8px 16px;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1ed760;
|
|
}
|
|
QPushButton:disabled {
|
|
background: #404040;
|
|
color: #808080;
|
|
}
|
|
""")
|
|
self.download_btn.clicked.connect(self.download_selected)
|
|
|
|
close_btn = QPushButton("Close")
|
|
close_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: #404040;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: white;
|
|
font-weight: bold;
|
|
padding: 8px 16px;
|
|
}
|
|
QPushButton:hover {
|
|
background: #505050;
|
|
}
|
|
""")
|
|
close_btn.clicked.connect(self.reject)
|
|
|
|
button_layout.addWidget(self.cancel_search_btn)
|
|
button_layout.addStretch()
|
|
button_layout.addWidget(self.download_btn)
|
|
button_layout.addWidget(close_btn)
|
|
|
|
# Add to main layout
|
|
layout.addWidget(header_label)
|
|
layout.addWidget(artist_label)
|
|
layout.addWidget(self.progress_bar)
|
|
layout.addWidget(self.status_label)
|
|
layout.addWidget(self.results_scroll, 1)
|
|
layout.addLayout(button_layout)
|
|
|
|
def start_search(self):
|
|
"""Start the album search"""
|
|
# We need to get the soulseek client from the parent
|
|
parent_page = self.parent()
|
|
if hasattr(parent_page, 'soulseek_client') and parent_page.soulseek_client:
|
|
self.search_worker = AlbumSearchWorker(self.album, 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.finished.connect(self.on_search_finished)
|
|
self.search_worker.start()
|
|
else:
|
|
self.on_search_failed("Soulseek client not available")
|
|
|
|
def cancel_search(self):
|
|
"""Cancel the current search"""
|
|
if self.search_worker:
|
|
self.search_worker.stop()
|
|
self.search_worker.terminate()
|
|
self.search_worker.wait()
|
|
self.reject()
|
|
|
|
def on_search_progress(self, message):
|
|
"""Handle search progress updates"""
|
|
self.status_label.setText(message)
|
|
|
|
def on_search_results(self, album_results):
|
|
"""Handle search results"""
|
|
if not album_results:
|
|
self.status_label.setText("No albums found")
|
|
self.progress_bar.setRange(0, 1)
|
|
self.progress_bar.setValue(1)
|
|
return
|
|
|
|
self.status_label.setText(f"Found {len(album_results)} albums")
|
|
self.progress_bar.setRange(0, 1)
|
|
self.progress_bar.setValue(1)
|
|
|
|
# Display results
|
|
for i, album_result in enumerate(album_results[:10]): # Show top 10 results
|
|
result_item = self.create_result_item(album_result, i)
|
|
self.results_layout.addWidget(result_item)
|
|
|
|
self.results_layout.addStretch()
|
|
self.cancel_search_btn.setText("Cancel")
|
|
|
|
def on_search_failed(self, error):
|
|
"""Handle search failure"""
|
|
self.status_label.setText(f"Search failed: {error}")
|
|
self.progress_bar.setRange(0, 1)
|
|
self.progress_bar.setValue(1)
|
|
self.cancel_search_btn.setText("Close")
|
|
|
|
def on_search_finished(self):
|
|
"""Handle search completion"""
|
|
self.cancel_search_btn.setText("Close")
|
|
|
|
def create_result_item(self, album_result: AlbumResult, index: int):
|
|
"""Create a result item widget"""
|
|
item = QFrame()
|
|
item.setStyleSheet("""
|
|
QFrame {
|
|
background: rgba(40, 40, 40, 0.8);
|
|
border: 1px solid #606060;
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
}
|
|
QFrame:hover {
|
|
background: rgba(50, 50, 50, 0.9);
|
|
border: 1px solid #1db954;
|
|
}
|
|
""")
|
|
item.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
|
|
layout = QHBoxLayout(item)
|
|
layout.setContentsMargins(12, 8, 12, 8)
|
|
layout.setSpacing(12)
|
|
|
|
# Album info
|
|
info_layout = QVBoxLayout()
|
|
|
|
title_label = QLabel(album_result.album_title)
|
|
title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
|
title_label.setStyleSheet("color: #ffffff;")
|
|
|
|
details_label = QLabel(f"By: {album_result.artist} • {album_result.track_count} tracks • {self.format_size(album_result.total_size)}")
|
|
details_label.setFont(QFont("Arial", 9))
|
|
details_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
uploader_label = QLabel(f"Uploader: {album_result.username}")
|
|
uploader_label.setFont(QFont("Arial", 8))
|
|
uploader_label.setStyleSheet("color: #888888;")
|
|
|
|
info_layout.addWidget(title_label)
|
|
info_layout.addWidget(details_label)
|
|
info_layout.addWidget(uploader_label)
|
|
|
|
# Select button
|
|
select_btn = QPushButton("Select")
|
|
select_btn.setFixedWidth(80)
|
|
select_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: #1db954;
|
|
border: none;
|
|
border-radius: 4px;
|
|
color: white;
|
|
font-weight: bold;
|
|
padding: 6px 12px;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1ed760;
|
|
}
|
|
""")
|
|
select_btn.clicked.connect(lambda: self.select_result(album_result))
|
|
|
|
layout.addLayout(info_layout)
|
|
layout.addStretch()
|
|
layout.addWidget(select_btn)
|
|
|
|
return item
|
|
|
|
def format_size(self, size_bytes):
|
|
"""Format file size in human readable format"""
|
|
if size_bytes >= 1024 * 1024 * 1024:
|
|
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
|
elif size_bytes >= 1024 * 1024:
|
|
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
elif size_bytes >= 1024:
|
|
return f"{size_bytes / 1024:.1f} KB"
|
|
else:
|
|
return f"{size_bytes} B"
|
|
|
|
def select_result(self, album_result):
|
|
"""Select an album result"""
|
|
self.selected_album_result = album_result
|
|
self.download_btn.setEnabled(True)
|
|
|
|
# Visual feedback - highlight selected item
|
|
for i in range(self.results_layout.count()):
|
|
item = self.results_layout.itemAt(i)
|
|
if item and item.widget():
|
|
widget = item.widget()
|
|
if hasattr(widget, 'setStyleSheet'):
|
|
if widget.layout() and widget.layout().itemAt(2) and widget.layout().itemAt(2).widget():
|
|
btn = widget.layout().itemAt(2).widget()
|
|
if btn.text() == "Select":
|
|
widget.setStyleSheet("""
|
|
QFrame {
|
|
background: rgba(40, 40, 40, 0.8);
|
|
border: 1px solid #606060;
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
}
|
|
""")
|
|
btn.setText("Select")
|
|
|
|
# Highlight the selected item
|
|
sender = self.sender()
|
|
if sender:
|
|
parent_frame = sender.parent()
|
|
if parent_frame:
|
|
parent_frame.setStyleSheet("""
|
|
QFrame {
|
|
background: rgba(29, 185, 84, 0.3);
|
|
border: 2px solid #1db954;
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
}
|
|
""")
|
|
sender.setText("Selected ✓")
|
|
|
|
def download_selected(self):
|
|
"""Download the selected album"""
|
|
if self.selected_album_result:
|
|
self.album_selected.emit(self.selected_album_result)
|
|
self.accept()
|
|
|
|
def closeEvent(self, event):
|
|
"""Handle dialog close"""
|
|
if self.search_worker:
|
|
self.search_worker.stop()
|
|
self.search_worker.terminate()
|
|
self.search_worker.wait()
|
|
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, parent=None):
|
|
super().__init__(parent)
|
|
|
|
# Core clients
|
|
self.spotify_client = None
|
|
self.plex_client = None
|
|
self.soulseek_client = None
|
|
self.matching_engine = MusicMatchingEngine()
|
|
|
|
# State management
|
|
self.selected_artist = None
|
|
self.current_albums = []
|
|
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...")
|
|
|
|
# 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.check_failed.connect(self.on_plex_library_check_failed)
|
|
self.plex_library_worker.start()
|
|
|
|
def on_plex_library_checked(self, owned_albums):
|
|
"""Handle Plex library check results"""
|
|
print(f"📨 Handler started: {len(owned_albums)} owned albums")
|
|
|
|
if not self.current_albums:
|
|
print("📨 No current albums, skipping update")
|
|
return
|
|
|
|
# Update the status
|
|
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")
|
|
|
|
# Update existing album cards with ownership info
|
|
print(f"🔄 Updating {self.albums_grid_layout.count()} album cards")
|
|
|
|
updated_count = 0
|
|
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 hasattr(album_card, 'update_ownership'):
|
|
is_owned = album_card.album.name in owned_albums
|
|
album_card.update_ownership(is_owned)
|
|
updated_count += 1
|
|
|
|
print(f"✅ Updated {updated_count} album cards successfully")
|
|
|
|
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):
|
|
"""Handle album download request"""
|
|
print(f"Download requested for album: {album.name} by {', '.join(album.artists)}")
|
|
|
|
# Open the album search dialog
|
|
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):
|
|
"""Handle album selection from search dialog"""
|
|
print(f"Selected album for download: {album_result.album_title} by {album_result.artist}")
|
|
|
|
# Start download process for the selected album
|
|
self.start_album_download(album_result)
|
|
|
|
def start_album_download(self, album_result: AlbumResult):
|
|
"""Start downloading the selected album"""
|
|
try:
|
|
if not self.soulseek_client:
|
|
QMessageBox.warning(self, "Error", "Soulseek client not available!")
|
|
return
|
|
|
|
# Create download items for each track in the album
|
|
for track in album_result.tracks:
|
|
# Create a unique download ID
|
|
download_id = f"{album_result.username}_{track.filename}"
|
|
|
|
# Add to download queue (we need to integrate with the main app's download queue)
|
|
self.add_to_download_queue(
|
|
title=track.title or track.filename,
|
|
artist=track.artist or album_result.artist,
|
|
album=album_result.album_title,
|
|
username=album_result.username,
|
|
filename=track.filename,
|
|
file_size=track.size,
|
|
download_id=download_id,
|
|
track_result=track
|
|
)
|
|
|
|
# Show confirmation
|
|
QMessageBox.information(
|
|
self,
|
|
"Download Started",
|
|
f"Started downloading {len(album_result.tracks)} tracks from '{album_result.album_title}'"
|
|
)
|
|
|
|
except Exception as e:
|
|
print(f"Error starting album download: {e}")
|
|
QMessageBox.critical(self, "Download Error", f"Failed to start download: {str(e)}")
|
|
|
|
def add_to_download_queue(self, title, artist, album, username, filename, file_size, download_id, track_result):
|
|
"""Add a track to the download queue - needs integration with main app"""
|
|
# TODO: This needs to integrate with the main application's download system
|
|
# For now, we'll simulate starting the download
|
|
|
|
print(f"Adding to download queue: {title} by {artist}")
|
|
|
|
# We need to access the main app's download manager
|
|
# This would typically be done by emitting a signal that the main app listens to
|
|
# or by accessing the download manager through a parent reference
|
|
|
|
# For now, just start the individual track download
|
|
if self.soulseek_client:
|
|
try:
|
|
# Start the download using the soulseek client
|
|
self.soulseek_client.download(
|
|
username=username,
|
|
filename=filename,
|
|
download_id=download_id
|
|
)
|
|
print(f"Started download: {filename}")
|
|
except Exception as e:
|
|
print(f"Failed to start download for {filename}: {e}")
|
|
|
|
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.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) |