Stripped 4,200+ emoji characters from print(), logger calls across 39 Python files. Logs are now clean text — easier to grep, more professional, no encoding issues on terminals without Unicode support. Seasonal config icons preserved for UI display.
5507 lines
251 KiB
Python
5507 lines
251 KiB
Python
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||
QFrame, QPushButton, QLineEdit, QScrollArea,
|
||
QGridLayout, QSizePolicy, QSpacerItem, QApplication,
|
||
QDialog, QDialogButtonBox, QProgressBar, QMessageBox,
|
||
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView)
|
||
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
|
||
import re
|
||
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
|
||
from core.wishlist_service import get_wishlist_service
|
||
from core.plex_scan_manager import PlexScanManager
|
||
from database.music_database import get_database
|
||
from utils.logging_config import get_logger
|
||
import asyncio
|
||
from datetime import datetime
|
||
|
||
logger = get_logger("artists")
|
||
|
||
|
||
@dataclass
|
||
class ArtistMatch:
|
||
"""Represents an artist match with confidence score"""
|
||
artist: Artist
|
||
confidence: float
|
||
match_reason: str = ""
|
||
|
||
@dataclass
|
||
class AlbumOwnershipStatus:
|
||
"""Represents album ownership status with completeness info"""
|
||
album_name: str
|
||
is_owned: bool
|
||
is_complete: bool
|
||
is_nearly_complete: bool
|
||
owned_tracks: int
|
||
expected_tracks: int
|
||
completion_ratio: float
|
||
|
||
@property
|
||
def completion_level(self) -> str:
|
||
"""Get completion level as string"""
|
||
if not self.is_owned:
|
||
return "missing"
|
||
elif self.completion_ratio >= 0.9:
|
||
return "complete"
|
||
elif self.completion_ratio >= 0.8:
|
||
return "nearly_complete"
|
||
else:
|
||
return "partial"
|
||
|
||
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 all releases (albums & singles) for artist: {self.artist.name} (ID: {self.artist.id})")
|
||
|
||
# Always fetch both albums and singles from the Spotify API
|
||
albums = self.spotify_client.get_artist_albums(self.artist.id, album_type='album,single', limit=50)
|
||
|
||
print(f"Found {len(albums)} total releases for {self.artist.name}")
|
||
|
||
# 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 releases")
|
||
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 AlbumStatusProcessingWorkerSignals(QObject):
|
||
"""Signals for the AlbumStatusProcessingWorker"""
|
||
completed = pyqtSignal(list) # List of status update results
|
||
error = pyqtSignal(str) # Error message
|
||
|
||
class AlbumStatusProcessingWorker(QRunnable):
|
||
"""
|
||
Background worker for processing album download status updates.
|
||
Based on the working pattern from downloads.py and sync.py.
|
||
"""
|
||
|
||
def __init__(self, soulseek_client, download_items_data):
|
||
super().__init__()
|
||
self.signals = AlbumStatusProcessingWorkerSignals()
|
||
self.soulseek_client = soulseek_client
|
||
self.download_items_data = download_items_data
|
||
|
||
def run(self):
|
||
"""Process status updates for album downloads in background thread"""
|
||
try:
|
||
import asyncio
|
||
import os
|
||
|
||
# Create new event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
try:
|
||
# Get all current transfers from slskd API
|
||
transfers_data = loop.run_until_complete(
|
||
self.soulseek_client._make_request('GET', 'transfers/downloads')
|
||
)
|
||
|
||
if not transfers_data:
|
||
self.signals.completed.emit([])
|
||
return
|
||
|
||
# Parse transfers into flat list and create lookup dictionary
|
||
all_transfers = []
|
||
transfers_by_id = {}
|
||
|
||
for user_data in transfers_data:
|
||
username = user_data.get('username', '')
|
||
|
||
# Handle files directly under user object (newer API format)
|
||
if 'files' in user_data and isinstance(user_data['files'], list):
|
||
for file_data in user_data['files']:
|
||
file_data['username'] = username
|
||
all_transfers.append(file_data)
|
||
if 'id' in file_data:
|
||
transfers_by_id[file_data['id']] = file_data
|
||
|
||
# Handle files nested in directories (older API format)
|
||
if 'directories' in user_data and isinstance(user_data['directories'], list):
|
||
for directory in user_data['directories']:
|
||
if 'files' in directory and isinstance(directory['files'], list):
|
||
for file_data in directory['files']:
|
||
file_data['username'] = username
|
||
all_transfers.append(file_data)
|
||
if 'id' in file_data:
|
||
transfers_by_id[file_data['id']] = file_data
|
||
|
||
print(f"Album status worker found {len(all_transfers)} total transfers")
|
||
|
||
# Process each download item
|
||
results = []
|
||
used_transfer_ids = set() # Prevent duplicate matching
|
||
|
||
for item_data in self.download_items_data:
|
||
download_id = item_data.get('download_id')
|
||
file_path = item_data.get('file_path', '')
|
||
widget_id = item_data.get('widget_id')
|
||
|
||
print(f"Processing album download: ID={download_id}, file={os.path.basename(file_path)}")
|
||
|
||
matching_transfer = None
|
||
|
||
# Primary matching: by download ID
|
||
if download_id and download_id in transfers_by_id and download_id not in used_transfer_ids:
|
||
matching_transfer = transfers_by_id[download_id]
|
||
used_transfer_ids.add(download_id)
|
||
print(f" ID match found for {download_id}")
|
||
|
||
# Fallback matching: by filename
|
||
elif file_path:
|
||
expected_basename = os.path.basename(file_path).lower()
|
||
for transfer in all_transfers:
|
||
transfer_id = transfer.get('id')
|
||
if transfer_id in used_transfer_ids:
|
||
continue
|
||
|
||
transfer_filename = transfer.get('filename', '')
|
||
transfer_basename = os.path.basename(transfer_filename).lower()
|
||
|
||
if transfer_basename == expected_basename:
|
||
matching_transfer = transfer
|
||
used_transfer_ids.add(transfer_id)
|
||
print(f" Filename match: {expected_basename}")
|
||
# Update download_id if it was missing
|
||
if not download_id:
|
||
download_id = transfer_id
|
||
break
|
||
|
||
# Determine status and create result
|
||
if matching_transfer:
|
||
state = matching_transfer.get('state', '').strip()
|
||
progress = 0.0
|
||
|
||
# Map slskd states to our status system
|
||
if 'Cancelled' in state or 'Canceled' in state:
|
||
new_status = 'cancelled'
|
||
elif 'Failed' in state or 'Errored' in state:
|
||
new_status = 'failed'
|
||
elif 'Completed' in state or 'Succeeded' in state:
|
||
new_status = 'completed'
|
||
progress = 100.0
|
||
elif 'InProgress' in state:
|
||
new_status = 'downloading'
|
||
# Extract progress from state or progress field
|
||
if 'progress' in matching_transfer:
|
||
progress = float(matching_transfer.get('progress', 0.0))
|
||
else:
|
||
# Try to extract from state string
|
||
import re
|
||
progress_match = re.search(r'(\d+(?:\.\d+)?)%', state)
|
||
if progress_match:
|
||
progress = float(progress_match.group(1))
|
||
else:
|
||
new_status = 'queued'
|
||
|
||
result = {
|
||
'widget_id': widget_id,
|
||
'download_id': download_id,
|
||
'status': new_status,
|
||
'progress': progress,
|
||
'state': state,
|
||
'filename': matching_transfer.get('filename', ''),
|
||
'size': matching_transfer.get('size', 0),
|
||
'transferred': matching_transfer.get('bytesTransferred', 0),
|
||
'speed': matching_transfer.get('averageSpeed', 0)
|
||
}
|
||
|
||
print(f" Status: {new_status} ({progress:.1f}%)")
|
||
else:
|
||
# Download not found in API - increment missing count
|
||
api_missing_count = item_data.get('api_missing_count', 0) + 1
|
||
|
||
if api_missing_count >= 3:
|
||
# Grace period exceeded - mark as failed
|
||
new_status = 'failed'
|
||
print(f" Download missing from API (failed after 3 checks)")
|
||
else:
|
||
# Still in grace period
|
||
new_status = 'missing'
|
||
print(f" Download missing from API (attempt {api_missing_count}/3)")
|
||
|
||
result = {
|
||
'widget_id': widget_id,
|
||
'download_id': download_id,
|
||
'status': new_status,
|
||
'api_missing_count': api_missing_count,
|
||
'progress': 0.0
|
||
}
|
||
|
||
results.append(result)
|
||
|
||
print(f"Album status worker completed: {len(results)} results")
|
||
self.signals.completed.emit(results)
|
||
|
||
finally:
|
||
loop.close()
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
self.signals.error.emit(f"Album status processing failed: {str(e)}")
|
||
|
||
|
||
|
||
class SinglesEPsLibraryWorker(QThread):
|
||
"""Background worker for checking singles and EPs using track-level matching"""
|
||
check_completed = pyqtSignal(dict) # Dict of release_name -> AlbumOwnershipStatus
|
||
release_matched = pyqtSignal(str, object) # release_name, AlbumOwnershipStatus
|
||
check_failed = pyqtSignal(str)
|
||
|
||
def __init__(self, releases, matching_engine):
|
||
super().__init__()
|
||
self.releases = releases
|
||
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 track-level matching for singles and EPs...")
|
||
release_statuses = {} # release_name -> AlbumOwnershipStatus
|
||
|
||
# Get database instance
|
||
db = get_database()
|
||
|
||
if self._stop_requested:
|
||
return
|
||
|
||
print(f"Checking {len(self.releases)} singles/EPs against database...")
|
||
|
||
for i, release in enumerate(self.releases):
|
||
if self._stop_requested:
|
||
return
|
||
|
||
print(f"Checking release {i+1}/{len(self.releases)}: {release.name} ({release.total_tracks} tracks)")
|
||
|
||
if release.total_tracks == 1:
|
||
# SINGLE: Use track-level matching
|
||
status = self._check_single_ownership(release, db)
|
||
else:
|
||
# EP: Use track-by-track matching for completion percentage
|
||
status = self._check_ep_ownership(release, db)
|
||
|
||
release_statuses[release.name] = status
|
||
|
||
# Emit individual match for real-time UI update
|
||
self.release_matched.emit(release.name, status)
|
||
|
||
print(f"Singles/EPs check complete: {len(release_statuses)} releases processed")
|
||
self.check_completed.emit(release_statuses)
|
||
|
||
except Exception as e:
|
||
error_msg = f"Error checking singles/EPs library: {e}"
|
||
print(f"{error_msg}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
self.check_failed.emit(error_msg)
|
||
|
||
def _check_single_ownership(self, single_release, db):
|
||
"""Check if a single track exists anywhere in the library"""
|
||
try:
|
||
# For singles, we need to get the track info from Spotify first
|
||
# Since the release object might not have track details
|
||
from core.spotify_client import SpotifyClient
|
||
spotify_client = SpotifyClient()
|
||
|
||
if not spotify_client.is_authenticated():
|
||
# Fallback: use release name as track name
|
||
track_name = single_release.name
|
||
artist_name = single_release.artists[0] if single_release.artists else ""
|
||
else:
|
||
try:
|
||
# Get full album data to get track info
|
||
album_data = spotify_client.get_album(single_release.id)
|
||
if album_data and album_data.get('tracks') and album_data['tracks']:
|
||
# Handle different track data formats from Spotify API
|
||
tracks_data = album_data['tracks']
|
||
if isinstance(tracks_data, dict) and 'items' in tracks_data and tracks_data['items']:
|
||
first_track = tracks_data['items'][0] # Paginated response
|
||
elif isinstance(tracks_data, list) and tracks_data:
|
||
first_track = tracks_data[0] # Direct list
|
||
else:
|
||
raise Exception("No track data in expected format")
|
||
|
||
track_name = first_track['name']
|
||
artist_name = first_track['artists'][0]['name'] if first_track['artists'] else single_release.artists[0]
|
||
else:
|
||
# Fallback
|
||
track_name = single_release.name
|
||
artist_name = single_release.artists[0] if single_release.artists else ""
|
||
except Exception as e:
|
||
print(f" Debug single track fetch error: {e}")
|
||
if album_data and 'tracks' in album_data:
|
||
print(f" Debug: tracks data type = {type(album_data['tracks'])}")
|
||
# Fallback if Spotify call fails
|
||
track_name = single_release.name
|
||
artist_name = single_release.artists[0] if single_release.artists else ""
|
||
|
||
print(f" Searching for single track: '{track_name}' by '{artist_name}'")
|
||
|
||
# Search for the track anywhere in the library (active server only)
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7, server_source=active_server)
|
||
|
||
if db_track and confidence >= 0.7:
|
||
print(f" Single found: '{track_name}' in album '{db_track.album_title}' (confidence: {confidence:.2f})")
|
||
|
||
# For singles, if we find the track, it's "complete"
|
||
return AlbumOwnershipStatus(
|
||
album_name=single_release.name,
|
||
is_owned=True,
|
||
is_complete=True,
|
||
is_nearly_complete=False,
|
||
owned_tracks=1,
|
||
expected_tracks=1,
|
||
completion_ratio=1.0
|
||
)
|
||
else:
|
||
print(f" Single not found: '{track_name}'")
|
||
return AlbumOwnershipStatus(
|
||
album_name=single_release.name,
|
||
is_owned=False,
|
||
is_complete=False,
|
||
is_nearly_complete=False,
|
||
owned_tracks=0,
|
||
expected_tracks=1,
|
||
completion_ratio=0.0
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f" Error checking single '{single_release.name}': {e}")
|
||
return AlbumOwnershipStatus(
|
||
album_name=single_release.name,
|
||
is_owned=False,
|
||
is_complete=False,
|
||
is_nearly_complete=False,
|
||
owned_tracks=0,
|
||
expected_tracks=1,
|
||
completion_ratio=0.0
|
||
)
|
||
|
||
def _check_ep_ownership(self, ep_release, db):
|
||
"""Check EP ownership by checking individual tracks"""
|
||
try:
|
||
# Get EP tracks from Spotify
|
||
from core.spotify_client import SpotifyClient
|
||
spotify_client = SpotifyClient()
|
||
|
||
if not spotify_client.is_authenticated():
|
||
print(f" Spotify not available, cannot check EP tracks for '{ep_release.name}'")
|
||
return AlbumOwnershipStatus(
|
||
album_name=ep_release.name,
|
||
is_owned=False,
|
||
is_complete=False,
|
||
is_nearly_complete=False,
|
||
owned_tracks=0,
|
||
expected_tracks=ep_release.total_tracks,
|
||
completion_ratio=0.0
|
||
)
|
||
|
||
try:
|
||
album_data = spotify_client.get_album(ep_release.id)
|
||
if not album_data or not album_data.get('tracks'):
|
||
raise Exception("No track data available")
|
||
|
||
# Handle different track data formats from Spotify API
|
||
tracks_data = album_data['tracks']
|
||
if isinstance(tracks_data, dict) and 'items' in tracks_data:
|
||
tracks = tracks_data['items'] # Paginated response
|
||
elif isinstance(tracks_data, list):
|
||
tracks = tracks_data # Direct list
|
||
else:
|
||
raise Exception(f"Unexpected tracks data format: {type(tracks_data)}")
|
||
|
||
except Exception as e:
|
||
print(f" Could not fetch EP tracks for '{ep_release.name}': {e}")
|
||
if album_data and 'tracks' in album_data:
|
||
print(f" Debug: tracks data type = {type(album_data['tracks'])}")
|
||
if hasattr(album_data['tracks'], '__len__') and len(album_data['tracks']) > 0:
|
||
print(f" Debug: first item type = {type(album_data['tracks'][0])}")
|
||
return AlbumOwnershipStatus(
|
||
album_name=ep_release.name,
|
||
is_owned=False,
|
||
is_complete=False,
|
||
is_nearly_complete=False,
|
||
owned_tracks=0,
|
||
expected_tracks=ep_release.total_tracks,
|
||
completion_ratio=0.0
|
||
)
|
||
|
||
print(f" Checking {len(tracks)} tracks in EP '{ep_release.name}'")
|
||
|
||
owned_tracks = 0
|
||
expected_tracks = len(tracks)
|
||
|
||
for track in tracks:
|
||
if self._stop_requested:
|
||
break
|
||
|
||
track_name = track['name']
|
||
artist_name = track['artists'][0]['name'] if track['artists'] else (ep_release.artists[0] if ep_release.artists else "")
|
||
|
||
# Search for this track (active server only)
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7, server_source=active_server)
|
||
|
||
if db_track and confidence >= 0.7:
|
||
owned_tracks += 1
|
||
print(f" Track found: '{track_name}'")
|
||
else:
|
||
print(f" Track missing: '{track_name}'")
|
||
|
||
completion_ratio = owned_tracks / max(expected_tracks, 1)
|
||
is_complete = completion_ratio >= 0.9
|
||
is_nearly_complete = completion_ratio >= 0.8 and completion_ratio < 0.9
|
||
is_owned = owned_tracks > 0
|
||
|
||
print(f" EP '{ep_release.name}': {owned_tracks}/{expected_tracks} tracks ({int(completion_ratio * 100)}%)")
|
||
|
||
return AlbumOwnershipStatus(
|
||
album_name=ep_release.name,
|
||
is_owned=is_owned,
|
||
is_complete=is_complete,
|
||
is_nearly_complete=is_nearly_complete,
|
||
owned_tracks=owned_tracks,
|
||
expected_tracks=expected_tracks,
|
||
completion_ratio=completion_ratio
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f" Error checking EP '{ep_release.name}': {e}")
|
||
return AlbumOwnershipStatus(
|
||
album_name=ep_release.name,
|
||
is_owned=False,
|
||
is_complete=False,
|
||
is_nearly_complete=False,
|
||
owned_tracks=0,
|
||
expected_tracks=ep_release.total_tracks,
|
||
completion_ratio=0.0
|
||
)
|
||
|
||
class DatabaseLibraryWorker(QThread):
|
||
"""Background worker for checking database library with completeness info (replaces PlexLibraryWorker)"""
|
||
library_checked = pyqtSignal(dict) # Dict of album_name -> AlbumOwnershipStatus
|
||
album_matched = pyqtSignal(str, object) # album_name, AlbumOwnershipStatus
|
||
check_failed = pyqtSignal(str)
|
||
|
||
def __init__(self, albums, matching_engine):
|
||
super().__init__()
|
||
self.albums = albums
|
||
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 database album matching with completeness checking...")
|
||
album_statuses = {} # album_name -> AlbumOwnershipStatus
|
||
|
||
# Get database instance
|
||
db = get_database()
|
||
|
||
# Get active server for filtering
|
||
try:
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
print(f"Checking albums against {active_server.upper()} library only")
|
||
except Exception as e:
|
||
print(f"Could not get active server, defaulting to 'plex': {e}")
|
||
active_server = 'plex'
|
||
|
||
if self._stop_requested:
|
||
return
|
||
|
||
print(f"Checking {len(self.albums)} Spotify albums against local database...")
|
||
|
||
# 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 [""]
|
||
if "Korn" in artists_to_try:
|
||
artists_to_try.append("KoЯn")
|
||
best_album = None
|
||
best_confidence = 0.0
|
||
best_owned_tracks = 0
|
||
best_expected_tracks = 0
|
||
best_is_complete = False
|
||
|
||
# Get expected track count from Spotify
|
||
expected_track_count = getattr(spotify_album, 'total_tracks', None)
|
||
|
||
# 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 database for this combination with completeness info
|
||
print(f" Searching database: album='{album_name}', artist='{artist_clean}'")
|
||
db_album, confidence, owned_tracks, expected_tracks, is_complete, *_ = db.check_album_exists_with_completeness(
|
||
album_name, artist_clean, expected_track_count, confidence_threshold=0.7, server_source=active_server
|
||
)
|
||
|
||
if db_album and confidence > best_confidence:
|
||
best_album = db_album
|
||
best_confidence = confidence
|
||
best_owned_tracks = owned_tracks
|
||
best_expected_tracks = expected_tracks
|
||
best_is_complete = is_complete
|
||
print(f" Found database match with confidence {confidence:.2f} ({owned_tracks}/{expected_tracks} tracks)")
|
||
|
||
# If we have a very confident match, we can stop searching for this album
|
||
if confidence >= 0.95:
|
||
break
|
||
|
||
# Backup search with original uncleaned artist name
|
||
if not db_album and artist and artist != artist_clean:
|
||
print(f" Backup search with original artist: album='{album_name}', artist='{artist}'")
|
||
db_album_backup, confidence_backup, owned_backup, expected_backup, complete_backup, *_ = db.check_album_exists_with_completeness(
|
||
album_name, artist, expected_track_count, confidence_threshold=0.7, server_source=active_server
|
||
)
|
||
|
||
if db_album_backup and confidence_backup > best_confidence:
|
||
best_album = db_album_backup
|
||
best_confidence = confidence_backup
|
||
best_owned_tracks = owned_backup
|
||
best_expected_tracks = expected_backup
|
||
best_is_complete = complete_backup
|
||
print(f" Found backup match with confidence {confidence_backup:.2f} ({owned_backup}/{expected_backup} tracks)")
|
||
|
||
# Additional fallback: remove commas
|
||
if not db_album_backup and ',' in artist:
|
||
artist_no_comma = artist.replace(',', '').strip()
|
||
artist_no_comma = ' '.join(artist_no_comma.split())
|
||
print(f" Comma-removal fallback: album='{album_name}', artist='{artist_no_comma}'")
|
||
db_album_comma, confidence_comma, owned_comma, expected_comma, complete_comma, *_ = db.check_album_exists_with_completeness(
|
||
album_name, artist_no_comma, expected_track_count, confidence_threshold=0.7, server_source=active_server
|
||
)
|
||
|
||
if db_album_comma and confidence_comma > best_confidence:
|
||
best_album = db_album_comma
|
||
best_confidence = confidence_comma
|
||
best_owned_tracks = owned_comma
|
||
best_expected_tracks = expected_comma
|
||
best_is_complete = complete_comma
|
||
print(f" Found comma-removal match with confidence {confidence_comma:.2f} ({owned_comma}/{expected_comma} tracks)")
|
||
|
||
# If we found a very confident match, stop searching other artists
|
||
if best_confidence >= 0.95:
|
||
break
|
||
|
||
# Create ownership status
|
||
if best_album and best_confidence >= 0.8:
|
||
completion_ratio = best_owned_tracks / max(best_expected_tracks, 1)
|
||
is_nearly_complete = completion_ratio >= 0.8 and completion_ratio < 0.9
|
||
status = AlbumOwnershipStatus(
|
||
album_name=spotify_album.name,
|
||
is_owned=True,
|
||
is_complete=best_is_complete,
|
||
is_nearly_complete=is_nearly_complete,
|
||
owned_tracks=best_owned_tracks,
|
||
expected_tracks=best_expected_tracks,
|
||
completion_ratio=completion_ratio
|
||
)
|
||
album_statuses[spotify_album.name] = status
|
||
|
||
# Log detailed result
|
||
if best_is_complete:
|
||
print(f"Complete album: '{spotify_album.name}' -> '{best_album.title}' ({best_owned_tracks}/{best_expected_tracks} tracks)")
|
||
elif is_nearly_complete:
|
||
print(f"Nearly complete album: '{spotify_album.name}' -> '{best_album.title}' ({best_owned_tracks}/{best_expected_tracks} tracks)")
|
||
else:
|
||
print(f"Partial album: '{spotify_album.name}' -> '{best_album.title}' ({best_owned_tracks}/{best_expected_tracks} tracks)")
|
||
|
||
# Emit individual match for real-time UI update
|
||
self.album_matched.emit(spotify_album.name, status)
|
||
else:
|
||
# Create status for missing album
|
||
status = AlbumOwnershipStatus(
|
||
album_name=spotify_album.name,
|
||
is_owned=False,
|
||
is_complete=False,
|
||
is_nearly_complete=False,
|
||
owned_tracks=0,
|
||
expected_tracks=expected_track_count or 0,
|
||
completion_ratio=0.0
|
||
)
|
||
album_statuses[spotify_album.name] = status
|
||
|
||
if best_album:
|
||
print(f"No confident match for '{spotify_album.name}' (best: {best_confidence:.2f})")
|
||
else:
|
||
print(f"No database candidates found for '{spotify_album.name}'")
|
||
|
||
# Count results for summary
|
||
complete_count = sum(1 for status in album_statuses.values() if status.is_complete)
|
||
nearly_complete_count = sum(1 for status in album_statuses.values() if status.is_nearly_complete)
|
||
partial_count = sum(1 for status in album_statuses.values() if status.is_owned and not status.is_complete and not status.is_nearly_complete)
|
||
missing_count = sum(1 for status in album_statuses.values() if not status.is_owned)
|
||
|
||
print(f"Final result: {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial, {missing_count} missing out of {len(self.albums)} albums")
|
||
print(f"Emitting detailed album statuses")
|
||
self.library_checked.emit(album_statuses)
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
error_msg = f"Error checking database library: {e}"
|
||
print(f"{error_msg}")
|
||
self.check_failed.emit(error_msg)
|
||
|
||
|
||
# Keep the old class name as an alias for backward compatibility
|
||
PlexLibraryWorker = DatabaseLibraryWorker
|
||
|
||
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()
|
||
self.check_watchlist_status()
|
||
|
||
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)
|
||
|
||
# Watchlist eye indicator (positioned absolutely in top-right corner)
|
||
self.watchlist_indicator = QLabel(self)
|
||
self.watchlist_indicator.setText("")
|
||
self.watchlist_indicator.setFont(QFont("Arial", 14))
|
||
self.watchlist_indicator.setFixedSize(24, 24)
|
||
self.watchlist_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.watchlist_indicator.setStyleSheet("""
|
||
QLabel {
|
||
background: rgba(29, 185, 84, 0.9);
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(29, 185, 84, 1);
|
||
color: white;
|
||
}
|
||
""")
|
||
self.watchlist_indicator.move(168, 8) # Position in top-right corner
|
||
self.watchlist_indicator.hide() # Hidden by default
|
||
|
||
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 check_watchlist_status(self):
|
||
"""Check if this artist is in the watchlist and show eye indicator"""
|
||
try:
|
||
database = get_database()
|
||
is_watching = database.is_artist_in_watchlist(self.artist.id)
|
||
|
||
if is_watching:
|
||
self.watchlist_indicator.show()
|
||
else:
|
||
self.watchlist_indicator.hide()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error checking watchlist status for artist {self.artist.name}: {e}")
|
||
self.watchlist_indicator.hide()
|
||
|
||
def refresh_watchlist_status(self):
|
||
"""Refresh the watchlist indicator (call this when watchlist changes)"""
|
||
self.check_watchlist_status()
|
||
|
||
def mousePressEvent(self, event):
|
||
"""Handle click to select artist"""
|
||
try:
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
self.artist_selected.emit(self.artist)
|
||
super().mousePressEvent(event)
|
||
except RuntimeError as e:
|
||
# Qt object has been deleted, ignore the event silently
|
||
print(f"ArtistCard object deleted during mouse event: {e}")
|
||
pass
|
||
|
||
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.ownership_status = None # Will store AlbumOwnershipStatus
|
||
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(45, 45, 50, 0.95),
|
||
stop:0.5 rgba(35, 35, 40, 0.97),
|
||
stop:1 rgba(28, 28, 33, 0.99));
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(80, 80, 85, 0.4);
|
||
}
|
||
AlbumCard:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(55, 55, 60, 0.98),
|
||
stop:0.5 rgba(45, 45, 50, 0.99),
|
||
stop:1 rgba(38, 38, 43, 1.0));
|
||
border: 1px solid rgba(29, 185, 84, 0.8);
|
||
}
|
||
""")
|
||
|
||
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)
|
||
|
||
# Set up initial overlay appearance (will be updated by update_ownership)
|
||
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("Loading...")
|
||
self.overlay.hide() # Initially hidden, shown on hover
|
||
|
||
# Download progress overlay (shown during downloads)
|
||
self.progress_overlay = QLabel(self.image_container)
|
||
self.progress_overlay.setFixedSize(164, 164)
|
||
self.progress_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.progress_overlay.setStyleSheet("""
|
||
QLabel {
|
||
background: rgba(0, 0, 0, 0.8);
|
||
border-radius: 6px;
|
||
color: white;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
padding: 8px;
|
||
}
|
||
""")
|
||
self.progress_overlay.hide() # Initially hidden
|
||
|
||
# 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()
|
||
|
||
# Initialize overlay text based on current ownership status
|
||
self._refresh_overlay_text()
|
||
|
||
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"""
|
||
try:
|
||
if hasattr(self, 'overlay') and self.overlay:
|
||
self.overlay.show()
|
||
self.overlay.raise_() # Bring to front
|
||
except (RuntimeError, AttributeError):
|
||
# Object has been deleted or is invalid, skip
|
||
pass
|
||
super().enterEvent(event)
|
||
|
||
def leaveEvent(self, event):
|
||
"""Hide overlay when not hovering"""
|
||
try:
|
||
if hasattr(self, 'overlay') and self.overlay:
|
||
self.overlay.hide()
|
||
except (RuntimeError, AttributeError):
|
||
# Object has been deleted or is invalid, skip
|
||
pass
|
||
super().leaveEvent(event)
|
||
|
||
def _refresh_overlay_text(self):
|
||
"""Refresh overlay text based on current ownership status"""
|
||
if self.is_owned:
|
||
if self.ownership_status and self.ownership_status.is_complete:
|
||
# Complete album (90%+) - green checkmark overlay
|
||
self.overlay.setStyleSheet("""
|
||
QLabel {
|
||
background: rgba(29, 185, 84, 0.8);
|
||
border-radius: 6px;
|
||
color: white;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
}
|
||
""")
|
||
self.overlay.setText("Complete\nVerify tracks")
|
||
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
elif self.ownership_status and self.ownership_status.is_nearly_complete:
|
||
# Nearly complete album (80-89%) - blue overlay
|
||
self.overlay.setStyleSheet("""
|
||
QLabel {
|
||
background: rgba(13, 110, 253, 0.8);
|
||
border-radius: 6px;
|
||
color: white;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
}
|
||
""")
|
||
percentage = int(self.ownership_status.completion_ratio * 100)
|
||
missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks
|
||
self.overlay.setText(f"◐ Nearly Complete\n({percentage}%)\nGet {missing_tracks} missing")
|
||
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
elif self.ownership_status:
|
||
# Partial album (<80%) - yellow warning overlay
|
||
self.overlay.setStyleSheet("""
|
||
QLabel {
|
||
background: rgba(255, 193, 7, 0.8);
|
||
border-radius: 6px;
|
||
color: #212529;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
}
|
||
""")
|
||
percentage = int(self.ownership_status.completion_ratio * 100)
|
||
missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks
|
||
self.overlay.setText(f"Partial\n({percentage}%)\nGet {missing_tracks} missing")
|
||
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
else:
|
||
# Legacy complete album - green checkmark overlay
|
||
self.overlay.setStyleSheet("""
|
||
QLabel {
|
||
background: rgba(29, 185, 84, 0.8);
|
||
border-radius: 6px;
|
||
color: white;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
}
|
||
""")
|
||
self.overlay.setText("Complete\nVerify tracks")
|
||
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
else:
|
||
# Missing album - download overlay
|
||
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("Missing\n(0%)\nDownload")
|
||
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
|
||
def update_status_indicator(self):
|
||
"""Update the permanent status indicator"""
|
||
if self.is_owned:
|
||
if self.ownership_status and self.ownership_status.is_complete:
|
||
# Complete album (90%+) - green checkmark
|
||
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(f"Complete album - {self.ownership_status.owned_tracks}/{self.ownership_status.expected_tracks} tracks ({int(self.ownership_status.completion_ratio * 100)}%)")
|
||
elif self.ownership_status and self.ownership_status.is_nearly_complete:
|
||
# Nearly complete album (80-89%) - blue half-circle
|
||
self.status_indicator.setStyleSheet("""
|
||
QLabel {
|
||
background: rgba(13, 110, 253, 0.9);
|
||
border-radius: 12px;
|
||
color: white;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
}
|
||
""")
|
||
self.status_indicator.setText("◐")
|
||
percentage = int(self.ownership_status.completion_ratio * 100)
|
||
missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks
|
||
self.status_indicator.setToolTip(f"Nearly complete - {self.ownership_status.owned_tracks}/{self.ownership_status.expected_tracks} tracks ({percentage}%) • {missing_tracks} missing")
|
||
elif self.ownership_status and not self.ownership_status.is_complete and not self.ownership_status.is_nearly_complete:
|
||
# Partial album (<80%) - yellow warning
|
||
self.status_indicator.setStyleSheet("""
|
||
QLabel {
|
||
background: rgba(255, 193, 7, 0.9);
|
||
border-radius: 12px;
|
||
color: #212529;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
}
|
||
""")
|
||
self.status_indicator.setText("")
|
||
percentage = int(self.ownership_status.completion_ratio * 100)
|
||
self.status_indicator.setToolTip(f"Partial album - {self.ownership_status.owned_tracks}/{self.ownership_status.expected_tracks} tracks ({percentage}%)")
|
||
else:
|
||
# Fallback for legacy owned albums without detailed status
|
||
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 library")
|
||
else:
|
||
# Missing album - red download icon
|
||
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, ownership_info):
|
||
"""Update ownership status and refresh UI - supports bool or AlbumOwnershipStatus"""
|
||
if isinstance(ownership_info, bool):
|
||
# Legacy support for simple boolean
|
||
is_owned = ownership_info
|
||
self.ownership_status = None
|
||
else:
|
||
# New detailed ownership status
|
||
is_owned = ownership_info.is_owned
|
||
self.ownership_status = ownership_info
|
||
|
||
if self.is_owned != is_owned: # Only log if status actually changed
|
||
if self.ownership_status:
|
||
print(f"'{self.album.name}' ownership: {self.is_owned} -> {is_owned} (complete: {self.ownership_status.is_complete})")
|
||
else:
|
||
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
|
||
self._refresh_overlay_text()
|
||
|
||
def set_download_in_progress(self):
|
||
"""Set album card to download in progress state"""
|
||
# Hide hover overlay and show progress overlay
|
||
try:
|
||
if hasattr(self, 'overlay') and self.overlay:
|
||
self.overlay.hide()
|
||
except (RuntimeError, AttributeError):
|
||
# Object has been deleted or is invalid, skip
|
||
pass
|
||
|
||
try:
|
||
if hasattr(self, 'progress_overlay') and self.progress_overlay and not self.progress_overlay.isNull():
|
||
self.progress_overlay.setText("\nPreparing...")
|
||
self.progress_overlay.show()
|
||
except (RuntimeError, AttributeError):
|
||
# Object has been deleted or is invalid, skip
|
||
pass
|
||
|
||
# Update status indicator
|
||
try:
|
||
if hasattr(self, 'status_indicator') and self.status_indicator:
|
||
self.status_indicator.setStyleSheet("""
|
||
QLabel {
|
||
background: rgba(255, 193, 7, 0.9);
|
||
border-radius: 12px;
|
||
color: white;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
""")
|
||
self.status_indicator.setText("")
|
||
self.status_indicator.setToolTip("Album downloading...")
|
||
except (RuntimeError, AttributeError):
|
||
# Object has been deleted or is invalid, skip
|
||
pass
|
||
|
||
def update_download_progress(self, completed_tracks: int, total_tracks: int, percentage: int):
|
||
"""Update download progress display"""
|
||
try:
|
||
progress_text = f"Downloading\n{completed_tracks}/{total_tracks} tracks\n{percentage}%"
|
||
if hasattr(self, 'progress_overlay') and self.progress_overlay:
|
||
self.progress_overlay.setText(progress_text)
|
||
self.progress_overlay.show()
|
||
except (RuntimeError, AttributeError):
|
||
# Progress overlay has been deleted, skip
|
||
pass
|
||
|
||
# Update status indicator with progress
|
||
try:
|
||
if hasattr(self, 'status_indicator') and self.status_indicator:
|
||
self.status_indicator.setText(f"{percentage}%")
|
||
self.status_indicator.setToolTip(f"Downloading: {completed_tracks}/{total_tracks} tracks ({percentage}%)")
|
||
except (RuntimeError, AttributeError):
|
||
# Status indicator has been deleted, skip
|
||
pass
|
||
|
||
def set_download_completed(self):
|
||
"""Set album card to download completed state"""
|
||
try:
|
||
# Hide progress overlay if it still exists
|
||
if hasattr(self, 'progress_overlay') and self.progress_overlay is not None:
|
||
try:
|
||
self.progress_overlay.hide()
|
||
except RuntimeError:
|
||
# Widget has been deleted, ignore
|
||
pass
|
||
|
||
# Update to owned state
|
||
self.update_ownership(True)
|
||
|
||
# Show completion message briefly if overlay still exists
|
||
if hasattr(self, 'progress_overlay') and self.progress_overlay is not None:
|
||
try:
|
||
self.progress_overlay.setText("\nCompleted!")
|
||
self.progress_overlay.setStyleSheet("""
|
||
QLabel {
|
||
background: rgba(29, 185, 84, 0.9);
|
||
border-radius: 6px;
|
||
color: white;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
padding: 8px;
|
||
}
|
||
""")
|
||
self.progress_overlay.show()
|
||
|
||
# Hide completion message after 3 seconds
|
||
QTimer.singleShot(3000, lambda: self.safe_hide_overlay())
|
||
except RuntimeError:
|
||
# Widget has been deleted, ignore
|
||
pass
|
||
|
||
except Exception as e:
|
||
print(f"Error in set_download_completed: {e}")
|
||
# Still try to update ownership even if overlay fails
|
||
try:
|
||
self.update_ownership(True)
|
||
except:
|
||
pass
|
||
|
||
def safe_hide_overlay(self):
|
||
"""Safely hide the progress overlay with error checking"""
|
||
try:
|
||
if hasattr(self, 'progress_overlay') and self.progress_overlay is not None:
|
||
self.progress_overlay.hide()
|
||
except RuntimeError:
|
||
# Widget has been deleted, ignore
|
||
pass
|
||
|
||
def mousePressEvent(self, event):
|
||
"""Handle click for download"""
|
||
try:
|
||
# Don't allow downloads if already downloading
|
||
if (event.button() == Qt.MouseButton.LeftButton and
|
||
not self.progress_overlay.isVisible()):
|
||
print(f"Album card clicked: {self.album.name} (owned: {self.is_owned})")
|
||
self.download_requested.emit(self.album)
|
||
super().mousePressEvent(event)
|
||
except RuntimeError as e:
|
||
# Qt object has been deleted, ignore the event silently
|
||
print(f"AlbumCard object deleted during mouse event: {e}")
|
||
pass
|
||
|
||
class DownloadMissingAlbumTracksModal(QDialog):
|
||
"""Enhanced modal for downloading missing album tracks with live progress tracking"""
|
||
process_finished = pyqtSignal()
|
||
|
||
def __init__(self, album, album_card, parent_page, downloads_page, media_client, server_type):
|
||
super().__init__(parent_page)
|
||
self.album = album
|
||
self.album_card = album_card
|
||
self.parent_page = parent_page
|
||
self.parent_artists_page = parent_page # Reference to artists page for scan manager
|
||
self.downloads_page = downloads_page
|
||
self.media_client = media_client
|
||
self.server_type = server_type
|
||
self.matching_engine = MusicMatchingEngine()
|
||
self.wishlist_service = get_wishlist_service()
|
||
|
||
# State tracking
|
||
self.total_tracks = len(album.tracks)
|
||
self.matched_tracks_count = 0
|
||
self.tracks_to_download_count = 0
|
||
self.downloaded_tracks_count = 0
|
||
self.analysis_complete = False
|
||
|
||
# Initialize attributes to prevent crash on close
|
||
self.download_in_progress = False
|
||
self.cancel_requested = False
|
||
self.permanently_failed_tracks = []
|
||
self.cancelled_tracks = set() # Track indices of cancelled tracks
|
||
|
||
print(f"Total album tracks: {self.total_tracks}")
|
||
|
||
# Track analysis results
|
||
self.analysis_results = []
|
||
self.missing_tracks = []
|
||
|
||
# Worker tracking
|
||
self.active_workers = []
|
||
self.fallback_pools = []
|
||
|
||
# Status Polling
|
||
self.download_status_pool = QThreadPool()
|
||
self.download_status_pool.setMaxThreadCount(1)
|
||
self._is_status_update_running = False
|
||
|
||
self.download_status_timer = QTimer(self)
|
||
self.download_status_timer.timeout.connect(self.poll_all_download_statuses)
|
||
self.download_status_timer.start(2000)
|
||
|
||
self.active_downloads = []
|
||
|
||
print("Setting up album modal UI...")
|
||
self.setup_ui()
|
||
print("Album modal initialization complete")
|
||
|
||
def generate_smart_search_queries(self, artist_name, track_name):
|
||
"""Generate smart search query variations with album-in-title detection"""
|
||
# Create a mock spotify track object for the matching engine
|
||
class MockSpotifyTrack:
|
||
def __init__(self, name, artists, album=None):
|
||
self.name = name
|
||
self.artists = artists if isinstance(artists, list) else [artists] if artists else []
|
||
self.album = album
|
||
|
||
# Pass album information if we're in the context of an album
|
||
album_name = getattr(self, 'album', None)
|
||
album_title = album_name.name if hasattr(album_name, 'name') else str(album_name) if album_name else None
|
||
|
||
mock_track = MockSpotifyTrack(track_name, [artist_name] if artist_name else [], album_title)
|
||
|
||
# Use the enhanced matching engine to generate queries
|
||
queries = self.matching_engine.generate_download_queries(mock_track)
|
||
|
||
# Add some legacy fallback queries for compatibility
|
||
legacy_queries = []
|
||
|
||
# Add first word of artist approach (legacy compatibility)
|
||
if artist_name:
|
||
artist_words = artist_name.split()
|
||
if artist_words:
|
||
first_word = artist_words[0]
|
||
if first_word.lower() == 'the' and len(artist_words) > 1:
|
||
first_word = artist_words[1]
|
||
|
||
if len(first_word) > 1:
|
||
legacy_queries.append(f"{track_name} {first_word}".strip())
|
||
|
||
# Add track-only query
|
||
legacy_queries.append(track_name.strip())
|
||
|
||
# Combine enhanced queries with legacy fallbacks
|
||
all_queries = queries + legacy_queries
|
||
|
||
# Remove duplicates while preserving order
|
||
unique_queries = []
|
||
seen = set()
|
||
for query in all_queries:
|
||
if query and query.lower() not in seen:
|
||
unique_queries.append(query)
|
||
seen.add(query.lower())
|
||
|
||
print(f"Generated {len(unique_queries)} smart queries for '{track_name}' (enhanced with album detection)")
|
||
for i, query in enumerate(unique_queries):
|
||
print(f" {i+1}. '{query}'")
|
||
|
||
return unique_queries
|
||
|
||
def setup_ui(self):
|
||
"""Set up the enhanced modal UI"""
|
||
self.setWindowTitle(f"Download Missing Tracks - {self.album.name}")
|
||
self.resize(1200, 900)
|
||
self.setWindowFlags(Qt.WindowType.Window)
|
||
|
||
self.setStyleSheet("""
|
||
QDialog { background-color: #1e1e1e; color: #ffffff; }
|
||
QLabel { color: #ffffff; }
|
||
QPushButton {
|
||
background-color: #1db954; color: #000000; border: none;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 10px 20px; min-width: 100px;
|
||
}
|
||
QPushButton:hover { background-color: #1ed760; }
|
||
QPushButton:disabled { background-color: #404040; color: #888888; }
|
||
""")
|
||
|
||
main_layout = QVBoxLayout(self)
|
||
main_layout.setContentsMargins(25, 25, 25, 25)
|
||
main_layout.setSpacing(15)
|
||
|
||
top_section = self.create_compact_top_section()
|
||
main_layout.addWidget(top_section)
|
||
|
||
progress_section = self.create_progress_section()
|
||
main_layout.addWidget(progress_section)
|
||
|
||
table_section = self.create_track_table()
|
||
main_layout.addWidget(table_section, stretch=1)
|
||
|
||
button_section = self.create_buttons()
|
||
main_layout.addWidget(button_section)
|
||
|
||
def create_compact_top_section(self):
|
||
"""Create compact top section with header and dashboard combined"""
|
||
top_frame = QFrame()
|
||
top_frame.setStyleSheet("""
|
||
QFrame {
|
||
background-color: #2d2d2d; border: 1px solid #444444;
|
||
border-radius: 8px; padding: 15px;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(top_frame)
|
||
layout.setSpacing(15)
|
||
|
||
header_layout = QHBoxLayout()
|
||
title_section = QVBoxLayout()
|
||
title_section.setSpacing(2)
|
||
|
||
title = QLabel("Download Missing Album Tracks")
|
||
title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
title.setStyleSheet("color: #1db954;")
|
||
|
||
subtitle = QLabel(f"Album: {self.album.name} by {', '.join(self.album.artists)}")
|
||
subtitle.setFont(QFont("Arial", 11))
|
||
subtitle.setStyleSheet("color: #aaaaaa;")
|
||
|
||
title_section.addWidget(title)
|
||
title_section.addWidget(subtitle)
|
||
|
||
dashboard_layout = QHBoxLayout()
|
||
dashboard_layout.setSpacing(20)
|
||
|
||
self.total_card = self.create_compact_counter_card("Total", str(self.total_tracks), "#1db954")
|
||
self.matched_card = self.create_compact_counter_card("Found", "0", "#4CAF50")
|
||
self.download_card = self.create_compact_counter_card("⬇️ Missing", "0", "#ff6b6b")
|
||
self.downloaded_card = self.create_compact_counter_card("Downloaded", "0", "#4CAF50")
|
||
|
||
dashboard_layout.addWidget(self.total_card)
|
||
dashboard_layout.addWidget(self.matched_card)
|
||
dashboard_layout.addWidget(self.download_card)
|
||
dashboard_layout.addWidget(self.downloaded_card)
|
||
dashboard_layout.addStretch()
|
||
|
||
header_layout.addLayout(title_section)
|
||
header_layout.addStretch()
|
||
header_layout.addLayout(dashboard_layout)
|
||
|
||
layout.addLayout(header_layout)
|
||
return top_frame
|
||
|
||
def create_compact_counter_card(self, title, count, color):
|
||
"""Create a compact counter card widget"""
|
||
card = QFrame()
|
||
card.setStyleSheet(f"""
|
||
QFrame {{
|
||
background-color: #3a3a3a; border: 2px solid {color};
|
||
border-radius: 6px; padding: 8px 12px; min-width: 80px;
|
||
}}
|
||
""")
|
||
|
||
layout = QVBoxLayout(card)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(2)
|
||
|
||
count_label = QLabel(count)
|
||
count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
count_label.setStyleSheet(f"color: {color}; background: transparent;")
|
||
count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
title_label = QLabel(title)
|
||
title_label.setFont(QFont("Arial", 9))
|
||
title_label.setStyleSheet("color: #cccccc; background: transparent;")
|
||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
layout.addWidget(count_label)
|
||
layout.addWidget(title_label)
|
||
|
||
if "Total" in title: self.total_count_label = count_label
|
||
elif "Found" in title: self.matched_count_label = count_label
|
||
elif "Missing" in title: self.download_count_label = count_label
|
||
elif "Downloaded" in title: self.downloaded_count_label = count_label
|
||
|
||
return card
|
||
|
||
def create_progress_section(self):
|
||
"""Create compact dual progress bar section"""
|
||
progress_frame = QFrame()
|
||
progress_frame.setStyleSheet("""
|
||
QFrame {
|
||
background-color: #2d2d2d; border: 1px solid #444444;
|
||
border-radius: 8px; padding: 12px;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(progress_frame)
|
||
layout.setSpacing(8)
|
||
|
||
analysis_container = QVBoxLayout()
|
||
analysis_container.setSpacing(4)
|
||
|
||
analysis_label = QLabel("Plex Analysis")
|
||
analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
||
analysis_label.setStyleSheet("color: #cccccc;")
|
||
|
||
self.analysis_progress = QProgressBar()
|
||
self.analysis_progress.setFixedHeight(20)
|
||
self.analysis_progress.setStyleSheet("""
|
||
QProgressBar {
|
||
border: 1px solid #555555; border-radius: 10px; text-align: center;
|
||
background-color: #444444; color: #ffffff; font-size: 11px; font-weight: bold;
|
||
}
|
||
QProgressBar::chunk { background-color: #1db954; border-radius: 9px; }
|
||
""")
|
||
self.analysis_progress.setVisible(False)
|
||
|
||
analysis_container.addWidget(analysis_label)
|
||
analysis_container.addWidget(self.analysis_progress)
|
||
|
||
download_container = QVBoxLayout()
|
||
download_container.setSpacing(4)
|
||
|
||
download_label = QLabel("⬇️ Download Progress")
|
||
download_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
||
download_label.setStyleSheet("color: #cccccc;")
|
||
|
||
self.download_progress = QProgressBar()
|
||
self.download_progress.setFixedHeight(20)
|
||
self.download_progress.setStyleSheet("""
|
||
QProgressBar {
|
||
border: 1px solid #555555; border-radius: 10px; text-align: center;
|
||
background-color: #444444; color: #ffffff; font-size: 11px; font-weight: bold;
|
||
}
|
||
QProgressBar::chunk { background-color: #ff6b6b; border-radius: 9px; }
|
||
""")
|
||
self.download_progress.setVisible(False)
|
||
|
||
download_container.addWidget(download_label)
|
||
download_container.addWidget(self.download_progress)
|
||
|
||
layout.addLayout(analysis_container)
|
||
layout.addLayout(download_container)
|
||
|
||
return progress_frame
|
||
|
||
def create_track_table(self):
|
||
"""Create enhanced track table"""
|
||
table_frame = QFrame()
|
||
table_frame.setStyleSheet("""
|
||
QFrame {
|
||
background-color: #2d2d2d; border: 1px solid #444444;
|
||
border-radius: 8px; padding: 0px;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(table_frame)
|
||
layout.setContentsMargins(15, 15, 15, 15)
|
||
layout.setSpacing(10)
|
||
|
||
header_label = QLabel("Album Track Analysis")
|
||
header_label.setFont(QFont("Arial", 13, QFont.Weight.Bold))
|
||
header_label.setStyleSheet("color: #ffffff; padding: 5px;")
|
||
|
||
self.track_table = QTableWidget()
|
||
self.track_table.setColumnCount(6)
|
||
self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Duration", "Matched", "Status", "Cancel"])
|
||
self.track_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||
self.track_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive)
|
||
self.track_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive)
|
||
self.track_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed)
|
||
self.track_table.setColumnWidth(2, 90)
|
||
self.track_table.setColumnWidth(3, 140)
|
||
self.track_table.setColumnWidth(5, 70)
|
||
|
||
self.track_table.setStyleSheet("""
|
||
QTableWidget {
|
||
background-color: #3a3a3a; alternate-background-color: #424242;
|
||
selection-background-color: #1db954; selection-color: #000000;
|
||
gridline-color: #555555; color: #ffffff; border: 1px solid #555555;
|
||
font-size: 12px;
|
||
}
|
||
QHeaderView::section {
|
||
background-color: #1db954; color: #000000; font-weight: bold;
|
||
font-size: 13px; padding: 12px 8px; border: none;
|
||
}
|
||
QTableWidget::item { padding: 12px 8px; border-bottom: 1px solid #4a4a4a; }
|
||
""")
|
||
|
||
self.track_table.setAlternatingRowColors(True)
|
||
self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
self.track_table.verticalHeader().setDefaultSectionSize(50)
|
||
self.track_table.verticalHeader().setVisible(False)
|
||
|
||
self.populate_track_table()
|
||
|
||
layout.addWidget(header_label)
|
||
layout.addWidget(self.track_table)
|
||
|
||
return table_frame
|
||
|
||
def populate_track_table(self):
|
||
"""Populate track table with album tracks"""
|
||
# Filter out invalid tracks before populating table
|
||
valid_tracks = []
|
||
for track in self.album.tracks:
|
||
if self.is_valid_track(track):
|
||
valid_tracks.append(track)
|
||
else:
|
||
print(f"Skipping invalid track: name='{getattr(track, 'name', 'None')}', artists={getattr(track, 'artists', 'None')}, duration={getattr(track, 'duration_ms', 'None')}")
|
||
|
||
# Update album tracks to only include valid ones
|
||
self.album.tracks = valid_tracks
|
||
self.total_tracks = len(valid_tracks)
|
||
|
||
self.track_table.setRowCount(len(valid_tracks))
|
||
for i, track in enumerate(valid_tracks):
|
||
# Use defensive get methods for track data
|
||
track_name = getattr(track, 'name', '') or 'Unknown Track'
|
||
artist_name = track.artists[0] if track.artists else "Unknown Artist"
|
||
duration_ms = getattr(track, 'duration_ms', 0) or 0
|
||
|
||
self.track_table.setItem(i, 0, QTableWidgetItem(track_name))
|
||
self.track_table.setItem(i, 1, QTableWidgetItem(artist_name))
|
||
duration = self.format_duration(duration_ms)
|
||
duration_item = QTableWidgetItem(duration)
|
||
duration_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.track_table.setItem(i, 2, duration_item)
|
||
matched_item = QTableWidgetItem("Pending")
|
||
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.track_table.setItem(i, 3, matched_item)
|
||
status_item = QTableWidgetItem("—")
|
||
status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.track_table.setItem(i, 4, status_item)
|
||
|
||
# Create empty container for cancel button (will be populated later for missing tracks only)
|
||
container = QWidget()
|
||
container.setStyleSheet("background: transparent;")
|
||
layout = QVBoxLayout(container)
|
||
layout.setContentsMargins(5, 5, 5, 5)
|
||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
self.track_table.setCellWidget(i, 5, container)
|
||
|
||
for col in range(5):
|
||
self.track_table.item(i, col).setFlags(self.track_table.item(i, col).flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
|
||
def is_valid_track(self, track) -> bool:
|
||
"""Check if a track has valid data for display and download"""
|
||
# Check if track has a valid name
|
||
track_name = getattr(track, 'name', None)
|
||
if not track_name or track_name.strip() == '':
|
||
return False
|
||
|
||
# Check if track has valid artists
|
||
artists = getattr(track, 'artists', None)
|
||
if not artists or len(artists) == 0:
|
||
return False
|
||
|
||
# Check if track has valid duration (allow 0 duration but not None/missing attribute)
|
||
duration_ms = getattr(track, 'duration_ms', None)
|
||
if duration_ms is None:
|
||
return False
|
||
|
||
# Allow 0 duration (some tracks like intros can be very short)
|
||
# Only reject if the duration attribute is completely missing
|
||
|
||
return True
|
||
|
||
def format_duration(self, duration_ms):
|
||
"""Convert milliseconds to MM:SS format"""
|
||
seconds = duration_ms // 1000
|
||
return f"{seconds // 60}:{seconds % 60:02d}"
|
||
|
||
def add_cancel_button_to_row(self, row):
|
||
"""Add cancel button to a specific row (only for missing tracks)"""
|
||
container = self.track_table.cellWidget(row, 5)
|
||
if container and container.layout().count() == 0: # Only add if container is empty
|
||
cancel_button = QPushButton("×")
|
||
cancel_button.setFixedSize(20, 20)
|
||
cancel_button.setMinimumSize(20, 20)
|
||
cancel_button.setMaximumSize(20, 20)
|
||
cancel_button.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #dc3545;
|
||
color: white;
|
||
border: 1px solid #c82333;
|
||
border-radius: 3px;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
padding: 0px;
|
||
margin: 0px;
|
||
text-align: center;
|
||
min-width: 20px;
|
||
max-width: 20px;
|
||
width: 20px;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: #c82333;
|
||
border-color: #bd2130;
|
||
}
|
||
QPushButton:pressed {
|
||
background-color: #bd2130;
|
||
border-color: #b21f2d;
|
||
}
|
||
QPushButton:disabled {
|
||
background-color: #28a745;
|
||
color: white;
|
||
border-color: #1e7e34;
|
||
}
|
||
""")
|
||
cancel_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
cancel_button.clicked.connect(lambda checked, row_idx=row: self.cancel_track(row_idx))
|
||
|
||
layout = container.layout()
|
||
layout.addWidget(cancel_button)
|
||
|
||
def hide_cancel_button_for_row(self, row):
|
||
"""Hide cancel button for a specific row (when track is downloaded)"""
|
||
container = self.track_table.cellWidget(row, 5)
|
||
if container:
|
||
layout = container.layout()
|
||
if layout and layout.count() > 0:
|
||
cancel_button = layout.itemAt(0).widget()
|
||
if cancel_button:
|
||
cancel_button.setVisible(False)
|
||
print(f"🫥 Hidden cancel button for downloaded track at row {row}")
|
||
|
||
def cancel_track(self, row):
|
||
"""Cancel a specific track - works at any phase"""
|
||
# Get cancel button and disable it
|
||
container = self.track_table.cellWidget(row, 5)
|
||
if container:
|
||
layout = container.layout()
|
||
if layout and layout.count() > 0:
|
||
cancel_button = layout.itemAt(0).widget()
|
||
if cancel_button:
|
||
cancel_button.setEnabled(False)
|
||
cancel_button.setText("")
|
||
|
||
# Update status to cancelled
|
||
self.track_table.setItem(row, 4, QTableWidgetItem("Cancelled"))
|
||
|
||
# Add to cancelled tracks set
|
||
if not hasattr(self, 'cancelled_tracks'):
|
||
self.cancelled_tracks = set()
|
||
self.cancelled_tracks.add(row)
|
||
|
||
track = self.album.tracks[row]
|
||
print(f"Track cancelled: {track.name} (row {row})")
|
||
|
||
# If downloads are active, also handle active download cancellation
|
||
download_index = None
|
||
|
||
# Check active_downloads list
|
||
if hasattr(self, 'active_downloads'):
|
||
for download in self.active_downloads:
|
||
if download.get('table_index') == row:
|
||
download_index = download.get('download_index', row)
|
||
print(f"Found active download {download_index} for cancelled track")
|
||
break
|
||
|
||
# Check parallel_search_tracking for download index
|
||
if download_index is None and hasattr(self, 'parallel_search_tracking'):
|
||
for idx, track_info in self.parallel_search_tracking.items():
|
||
if track_info.get('table_index') == row:
|
||
download_index = idx
|
||
print(f"Found parallel tracking {download_index} for cancelled track")
|
||
break
|
||
|
||
# If we found an active download, trigger completion to free up the worker
|
||
if download_index is not None and hasattr(self, 'on_parallel_track_completed'):
|
||
print(f"Triggering completion for active download {download_index}")
|
||
self.on_parallel_track_completed(download_index, success=False)
|
||
|
||
def create_buttons(self):
|
||
"""Create improved button section"""
|
||
button_frame = QFrame(styleSheet="background-color: transparent; padding: 10px;")
|
||
layout = QHBoxLayout(button_frame)
|
||
layout.setSpacing(15)
|
||
layout.setContentsMargins(0, 10, 0, 0)
|
||
|
||
self.correct_failed_btn = QPushButton("Correct Failed Matches")
|
||
self.correct_failed_btn.setFixedWidth(220)
|
||
self.correct_failed_btn.setStyleSheet("""
|
||
QPushButton { background-color: #ffc107; color: #000000; border-radius: 20px; font-weight: bold; }
|
||
QPushButton:hover { background-color: #ffca28; }
|
||
""")
|
||
self.correct_failed_btn.clicked.connect(self.on_correct_failed_matches_clicked)
|
||
self.correct_failed_btn.hide()
|
||
|
||
self.begin_search_btn = QPushButton("Begin Search")
|
||
self.begin_search_btn.setFixedSize(160, 40)
|
||
self.begin_search_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #1db954; color: #000000; border: none;
|
||
border-radius: 20px; font-size: 14px; font-weight: bold;
|
||
}
|
||
QPushButton:hover { background-color: #1ed760; }
|
||
""")
|
||
self.begin_search_btn.clicked.connect(self.on_begin_search_clicked)
|
||
|
||
self.cancel_btn = QPushButton("Cancel")
|
||
self.cancel_btn.setFixedSize(110, 40)
|
||
self.cancel_btn.setStyleSheet("""
|
||
QPushButton { background-color: #d32f2f; color: #ffffff; border-radius: 20px;}
|
||
QPushButton:hover { background-color: #f44336; }
|
||
""")
|
||
self.cancel_btn.clicked.connect(self.on_cancel_clicked)
|
||
self.cancel_btn.hide()
|
||
|
||
self.close_btn = QPushButton("Close")
|
||
self.close_btn.setFixedSize(110, 40)
|
||
self.close_btn.setStyleSheet("""
|
||
QPushButton { background-color: #616161; color: #ffffff; border-radius: 20px;}
|
||
QPushButton:hover { background-color: #757575; }
|
||
""")
|
||
self.close_btn.clicked.connect(self.on_close_clicked)
|
||
|
||
layout.addStretch()
|
||
layout.addWidget(self.begin_search_btn)
|
||
layout.addWidget(self.cancel_btn)
|
||
layout.addWidget(self.correct_failed_btn)
|
||
layout.addWidget(self.close_btn)
|
||
|
||
return button_frame
|
||
|
||
def on_begin_search_clicked(self):
|
||
"""Handle Begin Search button click - starts Plex analysis"""
|
||
# Trigger UI updates on album card
|
||
try:
|
||
if self.album_card and hasattr(self.album_card, 'set_download_in_progress'):
|
||
self.album_card.set_download_in_progress()
|
||
except (RuntimeError, AttributeError):
|
||
# Album card object has been deleted, skip UI update
|
||
print("Album card object deleted, skipping progress update")
|
||
pass
|
||
|
||
self.begin_search_btn.hide()
|
||
self.cancel_btn.show()
|
||
self.analysis_progress.setVisible(True)
|
||
self.analysis_progress.setMaximum(self.total_tracks)
|
||
self.analysis_progress.setValue(0)
|
||
self.download_in_progress = True
|
||
self.start_plex_analysis()
|
||
|
||
def start_plex_analysis(self):
|
||
"""Start database analysis for album tracks (server-aware)"""
|
||
from ui.pages.sync import PlaylistTrackAnalysisWorker
|
||
worker = PlaylistTrackAnalysisWorker(self.album.tracks, self.media_client)
|
||
worker.signals.analysis_started.connect(self.on_analysis_started)
|
||
worker.signals.track_analyzed.connect(self.on_track_analyzed)
|
||
worker.signals.analysis_completed.connect(self.on_analysis_completed)
|
||
worker.signals.analysis_failed.connect(self.on_analysis_failed)
|
||
self.active_workers.append(worker)
|
||
QThreadPool.globalInstance().start(worker)
|
||
|
||
def on_analysis_started(self, total_tracks):
|
||
print(f"Album analysis started for {total_tracks} tracks")
|
||
|
||
def on_track_analyzed(self, track_index, result):
|
||
"""Handle individual track analysis completion with live UI updates"""
|
||
self.analysis_progress.setValue(track_index)
|
||
row_index = track_index - 1
|
||
if result.exists_in_plex:
|
||
matched_text = f"Found ({result.confidence:.1f})"
|
||
self.matched_tracks_count += 1
|
||
self.matched_count_label.setText(str(self.matched_tracks_count))
|
||
else:
|
||
matched_text = "Missing"
|
||
self.tracks_to_download_count += 1
|
||
self.download_count_label.setText(str(self.tracks_to_download_count))
|
||
# Add cancel button for missing tracks only
|
||
self.add_cancel_button_to_row(row_index)
|
||
self.track_table.setItem(row_index, 3, QTableWidgetItem(matched_text))
|
||
|
||
def on_analysis_completed(self, results):
|
||
"""Handle analysis completion"""
|
||
self.analysis_complete = True
|
||
self.analysis_results = results
|
||
self.missing_tracks = [r for r in results if not r.exists_in_plex]
|
||
print(f"Album analysis complete: {len(self.missing_tracks)} to download")
|
||
if self.missing_tracks:
|
||
self.start_download_progress()
|
||
else:
|
||
self.download_in_progress = False
|
||
self.cancel_btn.hide()
|
||
try:
|
||
self.process_finished.emit()
|
||
except RuntimeError as e:
|
||
print(f"Modal object deleted during analysis complete signal: {e}")
|
||
QMessageBox.information(self, "Analysis Complete", "All album tracks already exist in Plex! No downloads needed.")
|
||
# Close with accept since all tracks are already available (success case)
|
||
self.accept()
|
||
|
||
def on_analysis_failed(self, error_message):
|
||
print(f"Album analysis failed: {error_message}")
|
||
QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze album tracks: {error_message}")
|
||
self.cancel_btn.hide()
|
||
self.begin_search_btn.show()
|
||
|
||
def start_download_progress(self):
|
||
"""Start actual download progress tracking"""
|
||
self.download_progress.setVisible(True)
|
||
self.download_progress.setMaximum(len(self.missing_tracks))
|
||
self.download_progress.setValue(0)
|
||
self.start_parallel_downloads()
|
||
|
||
def start_parallel_downloads(self):
|
||
"""Start multiple track downloads in parallel for better performance"""
|
||
self.active_parallel_downloads = 0
|
||
self.download_queue_index = 0
|
||
self.failed_downloads = 0
|
||
self.completed_downloads = 0
|
||
self.successful_downloads = 0
|
||
self.start_next_batch_of_downloads()
|
||
|
||
def start_next_batch_of_downloads(self, max_concurrent=3):
|
||
"""Start the next batch of downloads up to the concurrent limit"""
|
||
while (self.active_parallel_downloads < max_concurrent and
|
||
self.download_queue_index < len(self.missing_tracks)):
|
||
track_result = self.missing_tracks[self.download_queue_index]
|
||
track = track_result.spotify_track
|
||
track_index = self.find_track_index_in_album(track)
|
||
|
||
# Skip if track was cancelled
|
||
if hasattr(self, 'cancelled_tracks') and track_index in self.cancelled_tracks:
|
||
print(f"Skipping cancelled track at index {track_index}: {track.name}")
|
||
self.download_queue_index += 1
|
||
self.completed_downloads += 1
|
||
continue
|
||
|
||
self.track_table.setItem(track_index, 4, QTableWidgetItem("Searching..."))
|
||
self.search_and_download_track_parallel(track, self.download_queue_index, track_index)
|
||
self.active_parallel_downloads += 1
|
||
self.download_queue_index += 1
|
||
|
||
if (self.download_queue_index >= len(self.missing_tracks) and self.active_parallel_downloads == 0):
|
||
self.on_all_downloads_complete()
|
||
|
||
def search_and_download_track_parallel(self, spotify_track, download_index, track_index):
|
||
"""Search for track and download via infrastructure path - PARALLEL VERSION"""
|
||
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
||
search_queries = self.generate_smart_search_queries(artist_name, spotify_track.name)
|
||
self.start_track_search_with_queries_parallel(spotify_track, search_queries, track_index, track_index, download_index)
|
||
|
||
def start_track_search_with_queries_parallel(self, spotify_track, search_queries, track_index, table_index, download_index):
|
||
"""Start track search with parallel completion handling"""
|
||
if not hasattr(self, 'parallel_search_tracking'):
|
||
self.parallel_search_tracking = {}
|
||
|
||
self.parallel_search_tracking[download_index] = {
|
||
'spotify_track': spotify_track, 'track_index': track_index,
|
||
'table_index': table_index, 'download_index': download_index,
|
||
'completed': False, 'used_sources': set(), 'candidates': [], 'retry_count': 0
|
||
}
|
||
self.start_search_worker_parallel(search_queries, spotify_track, track_index, table_index, 0, download_index)
|
||
|
||
def start_search_worker_parallel(self, queries, spotify_track, track_index, table_index, query_index, download_index):
|
||
"""Start search worker with parallel completion handling."""
|
||
if query_index >= len(queries):
|
||
self.on_parallel_track_failed(download_index, "All search strategies failed")
|
||
return
|
||
|
||
query = queries[query_index]
|
||
worker = self.ParallelSearchWorker(self.parent_page.soulseek_client, query)
|
||
|
||
worker.signals.search_completed.connect(
|
||
lambda r, q: self.on_search_query_completed_parallel(r, queries, spotify_track, track_index, table_index, query_index, q, download_index)
|
||
)
|
||
worker.signals.search_failed.connect(
|
||
lambda q, e: self.on_search_query_completed_parallel([], queries, spotify_track, track_index, table_index, query_index, q, download_index)
|
||
)
|
||
QThreadPool.globalInstance().start(worker)
|
||
|
||
def on_search_query_completed_parallel(self, results, queries, spotify_track, track_index, table_index, query_index, query, download_index):
|
||
"""Handle completion of a parallel search query. If it fails, trigger the next query."""
|
||
if hasattr(self, 'cancel_requested') and self.cancel_requested: return
|
||
|
||
valid_candidates = self.get_valid_candidates(results, spotify_track, query)
|
||
|
||
if valid_candidates:
|
||
# Cache the candidates for future retries
|
||
self.parallel_search_tracking[download_index]['candidates'] = valid_candidates
|
||
best_match = valid_candidates[0]
|
||
self.start_validated_download_parallel(best_match, spotify_track, track_index, table_index, download_index)
|
||
return
|
||
|
||
next_query_index = query_index + 1
|
||
if next_query_index < len(queries):
|
||
self.start_search_worker_parallel(queries, spotify_track, track_index, table_index, next_query_index, download_index)
|
||
else:
|
||
self.on_parallel_track_failed(download_index, f"No valid results after trying all {len(queries)} queries.")
|
||
|
||
def start_validated_download_parallel(self, slskd_result, spotify_metadata, track_index, table_index, download_index):
|
||
"""Start download with validated metadata"""
|
||
track_info = self.parallel_search_tracking[download_index]
|
||
|
||
# Reset state if this track was previously marked as completed (for retries)
|
||
if track_info.get('completed', False):
|
||
print(f"Resetting state for manually retried track (index: {download_index}).")
|
||
track_info['completed'] = False
|
||
|
||
if self.failed_downloads > 0:
|
||
self.failed_downloads -= 1
|
||
|
||
self.active_parallel_downloads += 1
|
||
|
||
if self.completed_downloads > 0:
|
||
self.completed_downloads -= 1
|
||
|
||
# Add the new download source to used sources to prevent retrying with same user/file
|
||
source_key = f"{getattr(slskd_result, 'username', 'unknown')}_{slskd_result.filename}"
|
||
track_info['used_sources'].add(source_key)
|
||
|
||
# Update UI to show the new download has been queued
|
||
spotify_based_result = self.create_spotify_based_search_result_from_validation(slskd_result, spotify_metadata)
|
||
self.track_table.setItem(table_index, 4, QTableWidgetItem("... Queued"))
|
||
|
||
# Start the actual download process
|
||
self.start_matched_download_via_infrastructure_parallel(spotify_based_result, track_index, table_index, download_index)
|
||
|
||
def find_existing_download_for_track(self, spotify_based_result):
|
||
"""Find existing download item in queue that matches this track"""
|
||
if not self.downloads_page or not hasattr(self.downloads_page, 'download_queue'):
|
||
return None
|
||
|
||
target_title = spotify_based_result.title if hasattr(spotify_based_result, 'title') else spotify_based_result.filename
|
||
target_artist = spotify_based_result.artist if hasattr(spotify_based_result, 'artist') else ""
|
||
|
||
# Check active queue for existing downloads
|
||
if hasattr(self.downloads_page.download_queue, 'active_queue'):
|
||
for item in self.downloads_page.download_queue.active_queue.download_items:
|
||
# Match by title and artist similarity
|
||
if (hasattr(item, 'title') and hasattr(item, 'artist') and
|
||
item.title.lower().strip() == target_title.lower().strip()):
|
||
# For better matching, also check artist if available
|
||
if target_artist and hasattr(item, 'artist'):
|
||
if target_artist.lower() in item.artist.lower() or item.artist.lower() in target_artist.lower():
|
||
return item
|
||
else:
|
||
return item # Match by title only if no artist info
|
||
return None
|
||
|
||
def cancel_existing_download(self, download_item):
|
||
"""Cancel an existing download item"""
|
||
if download_item and hasattr(download_item, 'cancel_download'):
|
||
print(f"Cancelling existing queued download: '{download_item.title}' by {download_item.artist}")
|
||
download_item.cancel_download()
|
||
return True
|
||
return False
|
||
|
||
def start_matched_download_via_infrastructure_parallel(self, spotify_based_result, track_index, table_index, download_index):
|
||
"""Start infrastructure download with parallel completion tracking"""
|
||
try:
|
||
# Check for existing download and cancel if found
|
||
existing_download = self.find_existing_download_for_track(spotify_based_result)
|
||
if existing_download:
|
||
print(f"Found existing download for '{spotify_based_result.title}', canceling before retry...")
|
||
self.cancel_existing_download(existing_download)
|
||
|
||
artist = type('Artist', (), {'name': spotify_based_result.artist})()
|
||
download_item = self.downloads_page._start_download_with_artist(spotify_based_result, artist)
|
||
|
||
if download_item:
|
||
self.active_downloads.append({
|
||
'download_index': download_index, 'track_index': track_index,
|
||
'table_index': table_index, 'download_id': download_item.download_id,
|
||
'slskd_result': spotify_based_result, 'candidates': self.parallel_search_tracking[download_index]['candidates']
|
||
})
|
||
else:
|
||
self.on_parallel_track_failed(download_index, "Failed to start download")
|
||
except Exception as e:
|
||
self.on_parallel_track_failed(download_index, str(e))
|
||
|
||
def poll_all_download_statuses(self):
|
||
"""Poll download statuses for active downloads"""
|
||
if self._is_status_update_running or not self.active_downloads:
|
||
return
|
||
self._is_status_update_running = True
|
||
|
||
# Create a snapshot of data needed by the worker thread
|
||
items_to_check = []
|
||
for d in self.active_downloads:
|
||
if d.get('slskd_result') and hasattr(d['slskd_result'], 'filename'):
|
||
items_to_check.append({
|
||
'widget_id': d['download_index'],
|
||
'download_id': d.get('download_id'),
|
||
'file_path': d['slskd_result'].filename,
|
||
'api_missing_count': d.get('api_missing_count', 0)
|
||
})
|
||
|
||
if not items_to_check:
|
||
self._is_status_update_running = False
|
||
return
|
||
|
||
# Import the worker from sync.py
|
||
from ui.pages.sync import SyncStatusProcessingWorker
|
||
worker = SyncStatusProcessingWorker(
|
||
self.parent_page.soulseek_client,
|
||
items_to_check
|
||
)
|
||
|
||
worker.signals.completed.connect(self._handle_processed_status_updates)
|
||
worker.signals.error.connect(lambda e: print(f"Album Status Worker Error: {e}"))
|
||
self.download_status_pool.start(worker)
|
||
|
||
def _handle_processed_status_updates(self, results):
|
||
"""Handle status updates from the background worker and trigger retry logic"""
|
||
import time
|
||
|
||
# Create a lookup for faster access to active download items
|
||
active_downloads_map = {d['download_index']: d for d in self.active_downloads}
|
||
|
||
for result in results:
|
||
download_index = result['widget_id']
|
||
new_status = result['status']
|
||
|
||
download_info = active_downloads_map.get(download_index)
|
||
if not download_info:
|
||
continue
|
||
|
||
# Update the main download_info object with the latest missing count
|
||
if 'api_missing_count' in result:
|
||
download_info['api_missing_count'] = result['api_missing_count']
|
||
|
||
# Update the download_id if the worker found a match by filename
|
||
if result.get('transfer_id') and download_info.get('download_id') != result['transfer_id']:
|
||
print(f"ℹ️ Corrected download ID for '{download_info['slskd_result'].filename}'")
|
||
download_info['download_id'] = result['transfer_id']
|
||
|
||
# Handle terminal states (completed, failed, cancelled)
|
||
if new_status in ['failed', 'cancelled']:
|
||
if download_info in self.active_downloads:
|
||
self.active_downloads.remove(download_info)
|
||
self.retry_parallel_download_with_fallback(download_info)
|
||
|
||
elif new_status == 'completed':
|
||
if download_info in self.active_downloads:
|
||
self.active_downloads.remove(download_info)
|
||
self.on_parallel_track_completed(download_index, success=True)
|
||
|
||
# Handle transient states (downloading, queued)
|
||
elif new_status == 'downloading':
|
||
progress = result.get('progress', 0)
|
||
self.track_table.setItem(download_info['table_index'], 4, QTableWidgetItem(f"⏬ Downloading ({progress}%)"))
|
||
|
||
# Reset queue timer if it exists
|
||
if 'queued_start_time' in download_info:
|
||
del download_info['queued_start_time']
|
||
|
||
# Add timeout for downloads stuck at 0%
|
||
if progress < 1:
|
||
if 'downloading_start_time' not in download_info:
|
||
download_info['downloading_start_time'] = time.time()
|
||
# 90-second timeout for being stuck at 0%
|
||
elif time.time() - download_info['downloading_start_time'] > 90:
|
||
print(f"Download for '{download_info['slskd_result'].filename}' is stuck at 0%. Cancelling and retrying.")
|
||
# Cancel the old download before retry
|
||
self.cancel_download_before_retry(download_info)
|
||
if download_info in self.active_downloads:
|
||
self.active_downloads.remove(download_info)
|
||
self.retry_parallel_download_with_fallback(download_info)
|
||
else:
|
||
# Progress is being made, reset the timer
|
||
if 'downloading_start_time' in download_info:
|
||
del download_info['downloading_start_time']
|
||
|
||
elif new_status == 'queued':
|
||
self.track_table.setItem(download_info['table_index'], 4, QTableWidgetItem("... Queued"))
|
||
# Start a timer to detect if it's stuck in queue
|
||
if 'queued_start_time' not in download_info:
|
||
download_info['queued_start_time'] = time.time()
|
||
elif time.time() - download_info['queued_start_time'] > 90: # 90-second timeout
|
||
print(f"Download for '{download_info['slskd_result'].filename}' is stuck in queue. Cancelling and retrying.")
|
||
# Cancel the old download before retry
|
||
self.cancel_download_before_retry(download_info)
|
||
if download_info in self.active_downloads:
|
||
self.active_downloads.remove(download_info)
|
||
self.retry_parallel_download_with_fallback(download_info)
|
||
|
||
self._is_status_update_running = False
|
||
|
||
def cancel_download_before_retry(self, download_info):
|
||
"""Cancel the current download before retrying with alternative source"""
|
||
try:
|
||
slskd_result = download_info.get('slskd_result')
|
||
if not slskd_result:
|
||
print("No slskd_result found in download_info for cancellation")
|
||
return
|
||
|
||
# Extract download details for cancellation
|
||
download_id = download_info.get('download_id')
|
||
username = getattr(slskd_result, 'username', None)
|
||
|
||
if download_id and username:
|
||
print(f"Cancelling timed-out album download: {download_id} from {username}")
|
||
|
||
# Use asyncio to call the async cancel method
|
||
import asyncio
|
||
try:
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
success = loop.run_until_complete(
|
||
self.soulseek_client.cancel_download(download_id, username, remove=False)
|
||
)
|
||
if success:
|
||
print(f"Successfully cancelled album download {download_id}")
|
||
else:
|
||
print(f"Failed to cancel album download {download_id}")
|
||
finally:
|
||
loop.close()
|
||
else:
|
||
print(f"Missing download_id ({download_id}) or username ({username}) for album cancellation")
|
||
|
||
except Exception as e:
|
||
print(f"Error cancelling album download: {e}")
|
||
|
||
def retry_parallel_download_with_fallback(self, failed_download_info):
|
||
"""Retries a failed download by selecting the next-best cached candidate"""
|
||
download_index = failed_download_info['download_index']
|
||
track_info = self.parallel_search_tracking[download_index]
|
||
|
||
track_info['retry_count'] += 1
|
||
if track_info['retry_count'] > 2: # Max 3 attempts total (1 initial + 2 retries)
|
||
self.on_parallel_track_failed(download_index, "All retries failed.")
|
||
return
|
||
|
||
candidates = failed_download_info.get('candidates', [])
|
||
used_sources = track_info.get('used_sources', set())
|
||
|
||
next_candidate = None
|
||
for candidate in candidates:
|
||
source_key = f"{getattr(candidate, 'username', 'unknown')}_{candidate.filename}"
|
||
if source_key not in used_sources:
|
||
next_candidate = candidate
|
||
break
|
||
|
||
if not next_candidate:
|
||
self.on_parallel_track_failed(download_index, "No alternative sources in cache")
|
||
return
|
||
|
||
print(f"Retrying album download {download_index + 1} with next candidate: {next_candidate.filename}")
|
||
self.track_table.setItem(failed_download_info['table_index'], 4, QTableWidgetItem(f"Retrying ({track_info['retry_count']})..."))
|
||
|
||
self.start_validated_download_parallel(
|
||
next_candidate, track_info['spotify_track'], track_info['track_index'],
|
||
track_info['table_index'], download_index
|
||
)
|
||
|
||
def on_parallel_track_completed(self, download_index, success):
|
||
"""Handle completion of a parallel track download"""
|
||
if not hasattr(self, 'parallel_search_tracking'):
|
||
print(f"parallel_search_tracking not initialized yet, skipping completion for download {download_index}")
|
||
return
|
||
track_info = self.parallel_search_tracking.get(download_index)
|
||
if not track_info or track_info.get('completed', False): return
|
||
|
||
track_info['completed'] = True
|
||
if success:
|
||
self.track_table.setItem(track_info['table_index'], 4, QTableWidgetItem("Downloaded"))
|
||
# Hide cancel button since track is now downloaded
|
||
self.hide_cancel_button_for_row(track_info['table_index'])
|
||
self.downloaded_tracks_count += 1
|
||
self.downloaded_count_label.setText(str(self.downloaded_tracks_count))
|
||
self.successful_downloads += 1
|
||
else:
|
||
# Check if track was cancelled (don't overwrite cancelled status)
|
||
table_index = track_info['table_index']
|
||
current_status = self.track_table.item(table_index, 4)
|
||
if current_status and "Cancelled" in current_status.text():
|
||
print(f"Track {download_index} was cancelled - preserving cancelled status")
|
||
else:
|
||
self.track_table.setItem(table_index, 4, QTableWidgetItem("Failed"))
|
||
if track_info not in self.permanently_failed_tracks:
|
||
self.permanently_failed_tracks.append(track_info)
|
||
self.update_failed_matches_button()
|
||
self.failed_downloads += 1
|
||
|
||
self.completed_downloads += 1
|
||
self.active_parallel_downloads -= 1
|
||
self.download_progress.setValue(self.completed_downloads)
|
||
|
||
# FIX: Use QTimer.singleShot to avoid deep recursion on rapid failures.
|
||
# This schedules the next batch to start after the current call stack unwinds.
|
||
QTimer.singleShot(0, self.start_next_batch_of_downloads)
|
||
|
||
def on_parallel_track_failed(self, download_index, reason):
|
||
"""Handle failure of a parallel track download"""
|
||
print(f"Album parallel download {download_index + 1} failed: {reason}")
|
||
self.on_parallel_track_completed(download_index, False)
|
||
|
||
def update_failed_matches_button(self):
|
||
"""Shows, hides, and updates the counter on the 'Correct Failed Matches' button"""
|
||
count = len(self.permanently_failed_tracks)
|
||
if count > 0:
|
||
self.correct_failed_btn.setText(f"Correct {count} Failed Match{'es' if count > 1 else ''}")
|
||
self.correct_failed_btn.show()
|
||
else:
|
||
self.correct_failed_btn.hide()
|
||
|
||
def find_track_index_in_album(self, spotify_track):
|
||
"""Find the table row index for a given Spotify track"""
|
||
for i, album_track in enumerate(self.album.tracks):
|
||
if album_track.id == spotify_track.id:
|
||
return i
|
||
return None
|
||
|
||
def on_all_downloads_complete(self):
|
||
"""Handle completion of all downloads"""
|
||
self.download_in_progress = False
|
||
print("All album downloads completed!")
|
||
self.cancel_btn.hide()
|
||
|
||
# Emit process_finished signal to unlock UI
|
||
try:
|
||
self.process_finished.emit()
|
||
except RuntimeError as e:
|
||
print(f"Modal object deleted during downloads complete signal: {e}")
|
||
|
||
# Request Plex library scan if we have successful downloads
|
||
if self.successful_downloads > 0 and hasattr(self, 'parent_artists_page') and self.parent_artists_page.scan_manager:
|
||
album_name = getattr(self.album, 'name', 'Unknown Album')
|
||
self.parent_artists_page.scan_manager.request_scan(f"Album download completed: {album_name} ({self.successful_downloads} tracks)")
|
||
|
||
# Add cancelled tracks that were missing from Plex to permanently_failed_tracks for wishlist inclusion
|
||
if hasattr(self, 'cancelled_tracks') and hasattr(self, 'missing_tracks'):
|
||
for cancelled_row in self.cancelled_tracks:
|
||
# Check if this cancelled track was actually missing from Plex
|
||
cancelled_track = self.album.tracks[cancelled_row]
|
||
missing_track_result = None
|
||
|
||
# Find the corresponding missing track result
|
||
for missing_result in self.missing_tracks:
|
||
if missing_result.spotify_track.id == cancelled_track.id:
|
||
missing_track_result = missing_result
|
||
break
|
||
|
||
# Only add to wishlist if track was actually missing from Plex AND not successfully downloaded
|
||
if missing_track_result:
|
||
# Check if track was successfully downloaded (don't add downloaded tracks to wishlist)
|
||
status_item = self.track_table.item(cancelled_row, 4)
|
||
current_status = status_item.text() if status_item else ""
|
||
|
||
if "Downloaded" in current_status:
|
||
print(f"Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist addition")
|
||
else:
|
||
cancelled_track_info = {
|
||
'download_index': cancelled_row,
|
||
'table_index': cancelled_row,
|
||
'track': cancelled_track,
|
||
'track_name': cancelled_track.name,
|
||
'artist_name': cancelled_track.artists[0] if cancelled_track.artists else "Unknown",
|
||
'retry_count': 0,
|
||
'spotify_track': missing_track_result.spotify_track # Include the spotify track for wishlist
|
||
}
|
||
# Check if not already in permanently_failed_tracks
|
||
if not any(t.get('table_index') == cancelled_row for t in self.permanently_failed_tracks):
|
||
self.permanently_failed_tracks.append(cancelled_track_info)
|
||
print(f"Added cancelled missing track {cancelled_track.name} to failed list for wishlist")
|
||
else:
|
||
print(f"Cancelled track {cancelled_track.name} was not missing from Plex, skipping wishlist addition")
|
||
|
||
# Add permanently failed tracks to wishlist before showing completion message
|
||
failed_count = len(self.permanently_failed_tracks)
|
||
wishlist_added_count = 0
|
||
|
||
# DEBUG: Log failed tracks details
|
||
logger.info(f"DEBUG: Processing {failed_count} failed tracks from album modal")
|
||
for i, track_info in enumerate(self.permanently_failed_tracks):
|
||
logger.info(f"DEBUG: Failed track {i+1}: keys={list(track_info.keys())}")
|
||
if 'spotify_track' in track_info:
|
||
st = track_info['spotify_track']
|
||
logger.info(f"DEBUG: Spotify track: {getattr(st, 'name', 'NO_NAME')} by {getattr(st, 'artists', 'NO_ARTISTS')}")
|
||
|
||
if self.permanently_failed_tracks:
|
||
try:
|
||
# Add failed tracks to wishlist
|
||
# Handle artist name safely - could be string or dict
|
||
artist_name = 'Unknown Artist'
|
||
if hasattr(self.album, 'artists') and self.album.artists:
|
||
first_artist = self.album.artists[0]
|
||
if isinstance(first_artist, str):
|
||
artist_name = first_artist
|
||
elif isinstance(first_artist, dict):
|
||
artist_name = first_artist.get('name', 'Unknown Artist')
|
||
else:
|
||
artist_name = str(first_artist)
|
||
|
||
source_context = {
|
||
'album_name': getattr(self.album, 'name', 'Unknown Album'),
|
||
'album_id': getattr(self.album, 'id', None),
|
||
'artist_name': artist_name,
|
||
'added_from': 'artists_page_modal',
|
||
'timestamp': datetime.now().isoformat()
|
||
}
|
||
|
||
logger.info(f"DEBUG: Source context: {source_context}")
|
||
|
||
for i, failed_track_info in enumerate(self.permanently_failed_tracks):
|
||
try:
|
||
logger.info(f"DEBUG: Attempting to add track {i+1} to wishlist...")
|
||
success = self.wishlist_service.add_failed_track_from_modal(
|
||
track_info=failed_track_info,
|
||
source_type='album',
|
||
source_context=source_context
|
||
)
|
||
logger.info(f"DEBUG: Track {i+1} add result: {success}")
|
||
if success:
|
||
wishlist_added_count += 1
|
||
else:
|
||
logger.warning(f"DEBUG: Track {i+1} was NOT added to wishlist (returned False)")
|
||
except Exception as e:
|
||
logger.error(f"Failed to add album track {i+1} to wishlist: {e}")
|
||
import traceback
|
||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||
|
||
if wishlist_added_count > 0:
|
||
logger.info(f"Added {wishlist_added_count} failed tracks to wishlist from album '{self.album.name}'")
|
||
else:
|
||
logger.warning(f"NO TRACKS were added to wishlist despite {failed_count} failed tracks!")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error adding failed album tracks to wishlist: {e}")
|
||
import traceback
|
||
logger.error(f"Full outer traceback: {traceback.format_exc()}")
|
||
|
||
# Determine the final message based on success or failure
|
||
if self.permanently_failed_tracks:
|
||
final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing album tracks!\n\n"
|
||
|
||
if wishlist_added_count > 0:
|
||
final_message += f"Added {wishlist_added_count} failed track{'s' if wishlist_added_count != 1 else ''} to wishlist for automatic retry.\n\n"
|
||
|
||
final_message += "You can also manually correct failed downloads or check the wishlist on the dashboard."
|
||
|
||
# If there are failures, ensure the modal is visible and bring it to the front
|
||
if self.isHidden():
|
||
self.show()
|
||
self.activateWindow()
|
||
self.raise_()
|
||
|
||
# Show the message but DO NOT close the modal
|
||
QMessageBox.information(self, "Downloads Complete", final_message)
|
||
|
||
else:
|
||
final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing album tracks!\n\nAll tracks were downloaded successfully!"
|
||
|
||
# Show the success message
|
||
QMessageBox.information(self, "Downloads Complete", final_message)
|
||
|
||
# FIX: Only accept and close the modal on full success
|
||
self.accept()
|
||
|
||
def get_valid_candidates(self, results, spotify_track, query):
|
||
"""Score and filter search results, then perform strict artist verification"""
|
||
if not results:
|
||
return []
|
||
|
||
# Get initial confident matches with version-aware scoring
|
||
initial_candidates = self.matching_engine.find_best_slskd_matches_enhanced(spotify_track, results)
|
||
|
||
if not initial_candidates:
|
||
print(f"No initial candidates found for '{spotify_track.name}' from query '{query}'.")
|
||
return []
|
||
|
||
print(f"Found {len(initial_candidates)} initial candidates for '{spotify_track.name}'. Now verifying artist...")
|
||
|
||
# Perform strict artist verification on the initial candidates
|
||
verified_candidates = []
|
||
spotify_artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
||
|
||
# Robust normalization for both artist name and file path
|
||
normalized_spotify_artist = re.sub(r'[^a-zA-Z0-9]', '', spotify_artist_name).lower()
|
||
|
||
for candidate in initial_candidates:
|
||
# The 'filename' from Soulseek includes the full folder path
|
||
slskd_full_path = candidate.filename
|
||
|
||
# Apply the same robust normalization to the Soulseek path
|
||
normalized_slskd_path = re.sub(r'[^a-zA-Z0-9]', '', slskd_full_path).lower()
|
||
|
||
# Check if the cleaned artist's name is in the cleaned folder path
|
||
if normalized_spotify_artist in normalized_slskd_path:
|
||
print(f"Artist '{spotify_artist_name}' VERIFIED in path: '{slskd_full_path}'")
|
||
verified_candidates.append(candidate)
|
||
else:
|
||
print(f"Artist '{spotify_artist_name}' NOT found in path: '{slskd_full_path}'. Discarding candidate.")
|
||
|
||
if verified_candidates:
|
||
# Apply quality profile filtering before returning
|
||
if hasattr(self.parent_artists_page, 'soulseek_client'):
|
||
quality_filtered = self.parent_artists_page.soulseek_client.filter_results_by_quality_preference(
|
||
verified_candidates
|
||
)
|
||
|
||
if quality_filtered:
|
||
verified_candidates = quality_filtered
|
||
print(f"Applied quality profile filtering: {len(verified_candidates)} candidates remain")
|
||
else:
|
||
print(f"Quality profile filtering removed all candidates, keeping originals")
|
||
|
||
best_confidence = verified_candidates[0].confidence
|
||
best_version = getattr(verified_candidates[0], 'version_type', 'unknown')
|
||
best_quality = getattr(verified_candidates[0], 'quality', 'unknown')
|
||
print(f"Found {len(verified_candidates)} VERIFIED matches for '{spotify_track.name}'. Best: {best_confidence:.2f} ({best_version}, {best_quality.upper()})")
|
||
|
||
# Log version breakdown for debugging
|
||
version_counts = {}
|
||
for candidate in verified_candidates[:5]: # Show top 5
|
||
version = getattr(candidate, 'version_type', 'unknown')
|
||
version_counts[version] = version_counts.get(version, 0) + 1
|
||
penalty = getattr(candidate, 'version_penalty', 0.0)
|
||
quality = getattr(candidate, 'quality', 'unknown')
|
||
bitrate_info = f" {candidate.bitrate}kbps" if hasattr(candidate, 'bitrate') and candidate.bitrate else ""
|
||
print(f" {candidate.confidence:.2f} - {version} ({quality.upper()}{bitrate_info}) (penalty: {penalty:.2f}) - {candidate.filename[:100]}...")
|
||
|
||
else:
|
||
print(f"No verified matches found for '{spotify_track.name}' after checking file paths.")
|
||
|
||
return verified_candidates
|
||
|
||
def create_spotify_based_search_result_from_validation(self, slskd_result, spotify_metadata):
|
||
"""Create SpotifyBasedSearchResult from validation results"""
|
||
class SpotifyBasedSearchResult:
|
||
def __init__(self):
|
||
self.filename = getattr(slskd_result, 'filename', f"{spotify_metadata.name}.flac")
|
||
self.username = getattr(slskd_result, 'username', 'unknown')
|
||
self.size = getattr(slskd_result, 'size', 0)
|
||
self.quality = getattr(slskd_result, 'quality', 'flac')
|
||
self.artist = spotify_metadata.artists[0] if spotify_metadata.artists else "Unknown"
|
||
self.title = spotify_metadata.name
|
||
self.album = spotify_metadata.album
|
||
return SpotifyBasedSearchResult()
|
||
|
||
# Inner class for the search worker
|
||
class ParallelSearchWorker(QRunnable):
|
||
def __init__(self, soulseek_client, query):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self.query = query
|
||
self.signals = self.create_signals()
|
||
|
||
def create_signals(self):
|
||
class Signals(QObject):
|
||
search_completed = pyqtSignal(list, str)
|
||
search_failed = pyqtSignal(str, str)
|
||
return Signals()
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
search_result = loop.run_until_complete(self.soulseek_client.search(self.query))
|
||
results_list = search_result[0] if isinstance(search_result, tuple) and search_result else []
|
||
|
||
# Check if signals object is still valid before emitting
|
||
try:
|
||
self.signals.search_completed.emit(results_list, self.query)
|
||
except RuntimeError:
|
||
# Qt objects deleted during shutdown, ignore
|
||
logger.debug(f"Search completed for '{self.query}' but UI already closed")
|
||
|
||
except Exception as e:
|
||
try:
|
||
self.signals.search_failed.emit(self.query, str(e))
|
||
except RuntimeError:
|
||
# Qt objects deleted during shutdown, ignore
|
||
logger.debug(f"Search failed for '{self.query}' but UI already closed: {e}")
|
||
finally:
|
||
if loop: loop.close()
|
||
|
||
def on_cancel_clicked(self):
|
||
"""Handle Cancel button"""
|
||
try:
|
||
self.cancel_operations()
|
||
self.process_finished.emit()
|
||
self.reject()
|
||
except RuntimeError as e:
|
||
print(f"Modal object deleted during cancel: {e}")
|
||
pass
|
||
|
||
def on_close_clicked(self):
|
||
"""Handle Close button"""
|
||
try:
|
||
if self.cancel_requested or not self.download_in_progress:
|
||
self.cancel_operations()
|
||
self.process_finished.emit()
|
||
self.reject()
|
||
except RuntimeError as e:
|
||
print(f"Modal object deleted during close: {e}")
|
||
pass
|
||
|
||
def cancel_operations(self):
|
||
"""Cancel any ongoing operations"""
|
||
print("Cancelling album download operations...")
|
||
self.cancel_requested = True
|
||
|
||
# Stop workers
|
||
for worker in self.active_workers:
|
||
if hasattr(worker, 'cancel'):
|
||
worker.cancel()
|
||
self.active_workers.clear()
|
||
|
||
# Stop polling
|
||
self.download_status_timer.stop()
|
||
print("Album modal operations cancelled successfully.")
|
||
|
||
def on_correct_failed_matches_clicked(self):
|
||
"""Handle failed matches correction using ManualMatchModal from sync.py"""
|
||
if not self.permanently_failed_tracks:
|
||
return
|
||
|
||
# Import the ManualMatchModal from sync.py
|
||
from ui.pages.sync import ManualMatchModal
|
||
|
||
manual_modal = ManualMatchModal(self)
|
||
manual_modal.track_resolved.connect(self.on_manual_match_resolved)
|
||
manual_modal.exec()
|
||
|
||
def on_manual_match_resolved(self, resolved_track_info):
|
||
"""Handle a track being successfully resolved by the ManualMatchModal"""
|
||
print(f"Manual match resolved (Artists) - download_index: {resolved_track_info.get('download_index')}, table_index: {resolved_track_info.get('table_index')}")
|
||
original_failed_track = next((t for t in self.permanently_failed_tracks if t['download_index'] == resolved_track_info['download_index']), None)
|
||
if original_failed_track:
|
||
self.permanently_failed_tracks.remove(original_failed_track)
|
||
print(f"Removed track from permanently_failed_tracks (Artists) - remaining: {len(self.permanently_failed_tracks)}")
|
||
else:
|
||
print("Could not find original failed track to remove (Artists)")
|
||
self.update_failed_matches_button()
|
||
|
||
class ArtistsPage(QWidget):
|
||
database_updated_externally = pyqtSignal()
|
||
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.all_releases = [] # Store all releases (albums + singles + eps)
|
||
self.albums_only = [] # Store only studio albums
|
||
self.singles_and_eps = [] # Store singles and EPs
|
||
self.matched_count = 0
|
||
self.artist_search_worker = None
|
||
self.album_fetch_worker = None
|
||
self.plex_library_worker = None
|
||
self.singles_eps_worker = None
|
||
|
||
# Album download tracking
|
||
self.album_downloads = {} # {album_id: {total_tracks: X, completed_tracks: Y, active_downloads: [download_ids], album_card: card_ref}}
|
||
self.completed_downloads = set() # Track downloads that have been completed (to handle cleanup)
|
||
self.download_status_timer = QTimer(self)
|
||
self.download_status_timer.timeout.connect(self.poll_album_download_statuses)
|
||
self.download_status_timer.start(2000) # Poll every 2 seconds (consistent with sync.py)
|
||
self.download_status_pool = QThreadPool()
|
||
|
||
# Initialize unified media scan manager (will be set when clients are connected)
|
||
self.scan_manager = None
|
||
self.download_status_pool.setMaxThreadCount(1) # One worker at a time to avoid conflicts
|
||
self._is_status_update_running = False
|
||
|
||
# Album download session management
|
||
self.active_album_sessions = {} # {album_id: {'modal': modal_ref, 'album_with_tracks': album_obj}}
|
||
|
||
# UI setup
|
||
self.setup_ui()
|
||
self.setup_clients()
|
||
|
||
def set_toast_manager(self, toast_manager):
|
||
"""Set the toast manager for showing notifications"""
|
||
self.toast_manager = toast_manager
|
||
|
||
def _on_media_scan_completed(self):
|
||
"""Callback triggered when media scan completes - start automatic incremental database update"""
|
||
try:
|
||
# Import here to avoid circular imports
|
||
from database import get_database
|
||
from core.database_update_worker import DatabaseUpdateWorker
|
||
from config.settings import config_manager
|
||
|
||
# Get the active media client
|
||
active_server = config_manager.get_active_media_server()
|
||
if active_server == "jellyfin":
|
||
media_client = getattr(self, 'jellyfin_client', None)
|
||
else:
|
||
media_client = getattr(self, 'plex_client', None)
|
||
|
||
# Check if we should run incremental update
|
||
if not media_client or not media_client.is_connected():
|
||
logger.debug(f"{active_server.upper()} not connected - skipping automatic database update")
|
||
return
|
||
|
||
# Check if database has a previous full refresh
|
||
database = get_database()
|
||
last_full_refresh = database.get_last_full_refresh()
|
||
if not last_full_refresh:
|
||
logger.info("No previous full refresh found - skipping automatic incremental update")
|
||
return
|
||
|
||
# Check if database has sufficient content
|
||
try:
|
||
stats = database.get_database_info()
|
||
track_count = stats.get('tracks', 0)
|
||
|
||
if track_count < 100:
|
||
logger.info(f"Database has only {track_count} tracks - skipping automatic incremental update")
|
||
return
|
||
except Exception as e:
|
||
logger.warning(f"Could not check database stats - skipping automatic update: {e}")
|
||
return
|
||
|
||
# All conditions met - start incremental update
|
||
logger.info(f"Starting automatic incremental database update after {active_server.upper()} scan")
|
||
self._start_automatic_incremental_update()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in media scan completion callback: {e}")
|
||
|
||
def _start_automatic_incremental_update(self):
|
||
"""Start the automatic incremental database update"""
|
||
try:
|
||
from core.database_update_worker import DatabaseUpdateWorker
|
||
|
||
# Avoid duplicate workers
|
||
if hasattr(self, '_auto_database_worker') and self._auto_database_worker and self._auto_database_worker.isRunning():
|
||
logger.debug("Automatic database update already running")
|
||
return
|
||
|
||
# Create worker for incremental update only
|
||
self._auto_database_worker = DatabaseUpdateWorker(
|
||
self.media_client,
|
||
"database/music_library.db",
|
||
full_refresh=False # Always incremental for automatic updates
|
||
)
|
||
|
||
# Connect completion signal to log result
|
||
self._auto_database_worker.finished.connect(self._on_auto_update_finished)
|
||
self._auto_database_worker.error.connect(self._on_auto_update_error)
|
||
|
||
# Start the update
|
||
self._auto_database_worker.start()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error starting automatic incremental update: {e}")
|
||
|
||
def _on_auto_update_finished(self, total_artists, total_albums, total_tracks, successful, failed):
|
||
"""Handle completion of automatic database update"""
|
||
try:
|
||
if successful > 0:
|
||
logger.info(f"Automatic database update completed: {successful} items processed successfully")
|
||
else:
|
||
logger.info("Automatic database update completed - no new content found")
|
||
|
||
# Emit the signal to notify the dashboard to refresh its statistics
|
||
self.database_updated_externally.emit()
|
||
logger.info("Emitted signal to refresh dashboard database statistics after auto update")
|
||
|
||
# Clean up the worker
|
||
if hasattr(self, '_auto_database_worker'):
|
||
self._auto_database_worker.deleteLater()
|
||
delattr(self, '_auto_database_worker')
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling automatic update completion: {e}")
|
||
|
||
def _on_auto_update_error(self, error_message):
|
||
"""Handle error in automatic database update"""
|
||
logger.warning(f"Automatic database update encountered an error: {error_message}")
|
||
|
||
# Clean up the worker
|
||
if hasattr(self, '_auto_database_worker'):
|
||
self._auto_database_worker.deleteLater()
|
||
delattr(self, '_auto_database_worker')
|
||
|
||
def setup_clients(self):
|
||
"""Initialize client connections"""
|
||
try:
|
||
from config.settings import config_manager
|
||
|
||
self.spotify_client = SpotifyClient()
|
||
self.plex_client = PlexClient()
|
||
|
||
# Add Jellyfin client for multi-server support
|
||
from core.jellyfin_client import JellyfinClient
|
||
self.jellyfin_client = JellyfinClient()
|
||
|
||
# Set up unified media client based on active server
|
||
active_server = config_manager.get_active_media_server()
|
||
if active_server == "plex":
|
||
self.media_client = self.plex_client
|
||
self.server_type = "plex"
|
||
else: # jellyfin
|
||
self.media_client = self.jellyfin_client
|
||
self.server_type = "jellyfin"
|
||
|
||
self.soulseek_client = SoulseekClient()
|
||
|
||
# --- FIX: Ensure the soulseek_client uses the download path from config ---
|
||
download_path = config_manager.get('soulseek.download_path')
|
||
if download_path and hasattr(self.soulseek_client, 'download_path'):
|
||
self.soulseek_client.download_path = download_path
|
||
print(f"Set soulseek_client download path for ArtistsPage to: {download_path}")
|
||
# --- END FIX ---
|
||
|
||
# Initialize unified media scan manager now that clients are available
|
||
try:
|
||
from core.media_scan_manager import MediaScanManager
|
||
self.scan_manager = MediaScanManager(delay_seconds=60)
|
||
# Add automatic incremental database update after scan completion
|
||
self.scan_manager.add_scan_completion_callback(self._on_media_scan_completed)
|
||
print("MediaScanManager initialized for ArtistsPage")
|
||
except Exception as e:
|
||
print(f"Failed to initialize MediaScanManager: {e}")
|
||
|
||
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.ScrollBarAlwaysOff)
|
||
self.artist_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
self.artist_scroll.setFixedHeight(320) # Fixed height to accommodate artist cards
|
||
|
||
# Enable horizontal scrolling with mouse wheel
|
||
def wheelEvent(event):
|
||
# Convert vertical wheel scrolling to horizontal scrolling
|
||
if event.angleDelta().y() != 0:
|
||
horizontal_scroll = self.artist_scroll.horizontalScrollBar()
|
||
current_value = horizontal_scroll.value()
|
||
# Scroll by artist card width (200px + spacing)
|
||
scroll_amount = -event.angleDelta().y() // 120 * 220 # Each wheel step scrolls ~1 card
|
||
horizontal_scroll.setValue(current_value + scroll_amount)
|
||
event.accept()
|
||
else:
|
||
QScrollArea.wheelEvent(self.artist_scroll, event)
|
||
|
||
self.artist_scroll.wheelEvent = wheelEvent
|
||
self.artist_scroll.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
QScrollBar:horizontal {
|
||
background: rgba(80, 80, 80, 0.3);
|
||
height: 8px;
|
||
border-radius: 4px;
|
||
}
|
||
QScrollBar::handle:horizontal {
|
||
background: rgba(29, 185, 84, 0.8);
|
||
border-radius: 4px;
|
||
min-width: 20px;
|
||
}
|
||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||
border: none;
|
||
background: none;
|
||
}
|
||
""")
|
||
|
||
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(50, 50, 55, 0.95),
|
||
stop:0.3 rgba(42, 42, 47, 0.97),
|
||
stop:0.7 rgba(35, 35, 40, 0.98),
|
||
stop:1 rgba(28, 28, 33, 0.99));
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(80, 80, 85, 0.3);
|
||
}
|
||
""")
|
||
|
||
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", 24, QFont.Weight.Bold))
|
||
self.artist_name_label.setStyleSheet("""
|
||
color: #ffffff;
|
||
letter-spacing: 1px;
|
||
background: transparent;
|
||
border: none;
|
||
""")
|
||
|
||
self.artist_stats_label = QLabel()
|
||
self.artist_stats_label.setFont(QFont("Arial", 12))
|
||
self.artist_stats_label.setStyleSheet("""
|
||
color: #c8c8c8;
|
||
opacity: 0.9;
|
||
background: transparent;
|
||
border: none;
|
||
""")
|
||
|
||
artist_info_layout.addWidget(self.artist_name_label)
|
||
artist_info_layout.addWidget(self.artist_stats_label)
|
||
|
||
# Watchlist button
|
||
self.watchlist_button = QPushButton("Add to Watchlist")
|
||
self.watchlist_button.setFixedHeight(36)
|
||
self.watchlist_button.setFixedWidth(140)
|
||
self.watchlist_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.15),
|
||
stop:1 rgba(20, 160, 70, 0.1));
|
||
border: 1px solid rgba(29, 185, 84, 0.6);
|
||
border-radius: 18px;
|
||
color: #1db954;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
padding: 0 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.25),
|
||
stop:1 rgba(20, 160, 70, 0.18));
|
||
border: 1px solid rgba(29, 185, 84, 0.8);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(20, 160, 70, 0.3),
|
||
stop:1 rgba(29, 185, 84, 0.25));
|
||
}
|
||
QPushButton:disabled {
|
||
background: rgba(80, 80, 85, 0.3);
|
||
border: 1px solid rgba(80, 80, 85, 0.5);
|
||
color: rgba(150, 150, 155, 0.7);
|
||
}
|
||
""")
|
||
self.watchlist_button.clicked.connect(self.toggle_watchlist)
|
||
|
||
# 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: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(70, 70, 75, 0.9),
|
||
stop:1 rgba(55, 55, 60, 0.95));
|
||
border: 1px solid rgba(120, 120, 125, 0.4);
|
||
border-radius: 18px;
|
||
padding: 0 16px;
|
||
color: #ffffff;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QLineEdit:focus {
|
||
border: 1px solid rgba(29, 185, 84, 0.8);
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(75, 75, 80, 0.95),
|
||
stop:1 rgba(60, 60, 65, 1.0));
|
||
}
|
||
QLineEdit::placeholder {
|
||
color: rgba(200, 200, 200, 0.7);
|
||
}
|
||
""")
|
||
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: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.12),
|
||
stop:1 rgba(20, 160, 70, 0.08));
|
||
border: 1px solid rgba(29, 185, 84, 0.6);
|
||
border-radius: 18px;
|
||
color: #1db954;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
padding: 0 16px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.2),
|
||
stop:1 rgba(20, 160, 70, 0.15));
|
||
border: 1px solid rgba(29, 185, 84, 0.8);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(20, 160, 70, 0.25),
|
||
stop:1 rgba(29, 185, 84, 0.2));
|
||
}
|
||
""")
|
||
back_btn.clicked.connect(self.return_to_search)
|
||
|
||
header_layout.addLayout(artist_info_layout)
|
||
header_layout.addWidget(self.watchlist_button)
|
||
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 with filter toggle buttons
|
||
albums_header_layout = QHBoxLayout()
|
||
|
||
# Left side: Title and filter buttons
|
||
title_and_filters_layout = QHBoxLayout()
|
||
title_and_filters_layout.setSpacing(15)
|
||
|
||
albums_title = QLabel("Releases")
|
||
albums_title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
albums_title.setStyleSheet("color: #ffffff;")
|
||
|
||
# Toggle buttons for filtering
|
||
self.current_filter = "albums" # Default filter
|
||
|
||
self.albums_button = QPushButton("Albums")
|
||
self.albums_button.setCheckable(True)
|
||
self.albums_button.setChecked(True)
|
||
self.albums_button.clicked.connect(lambda: self.set_filter("albums"))
|
||
|
||
self.singles_eps_button = QPushButton("Singles & EPs")
|
||
self.singles_eps_button.setCheckable(True)
|
||
self.singles_eps_button.clicked.connect(lambda: self.set_filter("singles_eps"))
|
||
|
||
# Style the toggle buttons
|
||
toggle_button_style = """
|
||
QPushButton {
|
||
background-color: rgba(255, 255, 255, 0.1);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
border-radius: 12px;
|
||
padding: 6px 12px;
|
||
color: #b3b3b3;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
QPushButton:checked {
|
||
background-color: rgba(29, 185, 84, 0.8);
|
||
border-color: rgba(29, 185, 84, 1.0);
|
||
color: #ffffff;
|
||
}
|
||
QPushButton:hover:!checked {
|
||
background-color: rgba(255, 255, 255, 0.15);
|
||
color: #ffffff;
|
||
}
|
||
"""
|
||
|
||
self.albums_button.setStyleSheet(toggle_button_style)
|
||
self.singles_eps_button.setStyleSheet(toggle_button_style)
|
||
|
||
title_and_filters_layout.addWidget(albums_title)
|
||
title_and_filters_layout.addWidget(self.albums_button)
|
||
title_and_filters_layout.addWidget(self.singles_eps_button)
|
||
|
||
self.albums_status = QLabel("Loading releases...")
|
||
self.albums_status.setFont(QFont("Arial", 11))
|
||
self.albums_status.setStyleSheet("color: #b3b3b3;")
|
||
|
||
albums_header_layout.addLayout(title_and_filters_layout)
|
||
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 set_filter(self, filter_type):
|
||
"""Handle filter toggle button clicks"""
|
||
self.current_filter = filter_type
|
||
|
||
# Update button states
|
||
self.albums_button.setChecked(filter_type == "albums")
|
||
self.singles_eps_button.setChecked(filter_type == "singles_eps")
|
||
|
||
# Filter and display appropriate releases
|
||
if self.all_releases: # Only filter if we have data
|
||
self.filter_and_display_releases()
|
||
|
||
def classify_releases(self, releases):
|
||
"""Classify releases into albums, singles, and EPs"""
|
||
albums = []
|
||
singles = []
|
||
eps = []
|
||
|
||
for release in releases:
|
||
if release.album_type == 'album':
|
||
albums.append(release)
|
||
elif release.album_type == 'single':
|
||
if release.total_tracks == 1:
|
||
singles.append(release)
|
||
else: # 2+ tracks = EP
|
||
eps.append(release)
|
||
|
||
return albums, singles, eps
|
||
|
||
def filter_and_display_releases(self):
|
||
"""Filter releases based on current filter and display them"""
|
||
if self.current_filter == "albums":
|
||
releases_to_show = self.albums_only
|
||
status_text = f"Found {len(releases_to_show)} albums"
|
||
else: # singles_eps
|
||
releases_to_show = self.singles_and_eps
|
||
singles_count = len([r for r in releases_to_show if r.total_tracks == 1])
|
||
eps_count = len([r for r in releases_to_show if r.total_tracks > 1])
|
||
status_text = f"Found {singles_count} singles, {eps_count} EPs"
|
||
|
||
# Update status
|
||
self.albums_status.setText(status_text)
|
||
|
||
# Store current releases for ownership checking
|
||
self.current_albums = releases_to_show
|
||
|
||
# Display releases immediately (without ownership info)
|
||
self.display_albums(releases_to_show, set())
|
||
|
||
# Start appropriate ownership check in background
|
||
if self.current_filter == "albums":
|
||
self.start_plex_library_check(releases_to_show) # Use existing album-level matching
|
||
else:
|
||
self.start_singles_eps_library_check(releases_to_show) # New track-level matching
|
||
|
||
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;")
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.warning("Please enter an artist name to search")
|
||
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;")
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.error("Spotify authentication required")
|
||
return
|
||
|
||
self.search_status.setText("Searching for artists...")
|
||
self.search_status.setStyleSheet("color: #1db954; padding: 10px;")
|
||
|
||
# Show toast for search start
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.info(f"Searching for artists: '{query}'")
|
||
|
||
# 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;")
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
query = self.search_input.text().strip()
|
||
self.toast_manager.warning(f"No artists found for '{query}'")
|
||
return
|
||
|
||
self.search_status.setText(f"Found {len(artist_matches)} artists")
|
||
self.search_status.setStyleSheet("color: #1db954; padding: 10px;")
|
||
|
||
# Show success toast
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.success(f"Found {len(artist_matches)} artists matching your search")
|
||
|
||
# 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;")
|
||
|
||
# Show error toast
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.error(f"Artist search failed: {error}")
|
||
|
||
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")
|
||
|
||
# Update watchlist button state
|
||
try:
|
||
database = get_database()
|
||
is_watching = database.is_artist_in_watchlist(artist.id)
|
||
self.update_watchlist_button(is_watching)
|
||
except Exception as e:
|
||
logger.error(f"Error checking watchlist status for artist {artist.name}: {e}")
|
||
self.update_watchlist_button(False)
|
||
|
||
# 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...")
|
||
|
||
# Show toast for album loading
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.info(f"Loading albums for {artist.name}")
|
||
|
||
# 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 - now handles all release types"""
|
||
if not albums:
|
||
self.albums_status.setText("No releases found")
|
||
return
|
||
|
||
print(f"Processing {len(albums)} releases for {artist.name}")
|
||
|
||
# Store all releases and classify them
|
||
self.all_releases = albums
|
||
self.albums_only, singles, eps = self.classify_releases(albums)
|
||
self.singles_and_eps = singles + eps
|
||
|
||
print(f"Classification: {len(self.albums_only)} albums, {len(singles)} singles, {len(eps)} EPs")
|
||
|
||
# Initialize match counter for real-time updates
|
||
self.matched_count = 0
|
||
|
||
# Auto-switch to Singles & EPs if no albums available
|
||
if len(self.albums_only) == 0 and len(self.singles_and_eps) > 0:
|
||
print("No albums found, automatically switching to Singles & EPs view")
|
||
self.current_filter = "singles_eps"
|
||
self.albums_button.setChecked(False)
|
||
self.singles_eps_button.setChecked(True)
|
||
|
||
# Display based on current filter
|
||
self.filter_and_display_releases()
|
||
|
||
def display_albums(self, albums, ownership_info):
|
||
"""Display albums in the grid - supports legacy set or new dict of AlbumOwnershipStatus"""
|
||
|
||
# Handle both old format (set of owned album names) and new format (dict of statuses)
|
||
if isinstance(ownership_info, dict):
|
||
print(f"Displaying {len(albums)} albums with detailed ownership info")
|
||
else:
|
||
print(f"Displaying {len(albums)} albums, {len(ownership_info)} owned")
|
||
|
||
# Clear existing albums
|
||
self.clear_albums()
|
||
|
||
row, col = 0, 0
|
||
max_cols = 5
|
||
|
||
for album in albums:
|
||
if isinstance(ownership_info, dict):
|
||
# New format - use detailed ownership status
|
||
status = ownership_info.get(album.name)
|
||
if status:
|
||
card = AlbumCard(album, status.is_owned)
|
||
card.update_ownership(status)
|
||
else:
|
||
# Album not found in statuses - assume not owned
|
||
card = AlbumCard(album, False)
|
||
else:
|
||
# Legacy format - simple set of owned album names
|
||
is_owned = album.name in ownership_info
|
||
card = AlbumCard(album, is_owned)
|
||
|
||
# Connect download signal for all albums - we can download missing tracks for partial albums
|
||
# and missing albums, but complete albums will show a different modal
|
||
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_singles_eps_library_check(self, releases):
|
||
"""Start track-level library check for singles and EPs"""
|
||
if not releases:
|
||
return
|
||
|
||
# Update status to show we're checking
|
||
singles_count = len([r for r in releases if r.total_tracks == 1])
|
||
eps_count = len([r for r in releases if r.total_tracks > 1])
|
||
self.albums_status.setText(f"Found {singles_count} singles, {eps_count} EPs • Checking library...")
|
||
|
||
# Show toast for library check start
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.info("Checking your library for owned singles and EPs...")
|
||
|
||
# Stop any existing worker
|
||
if hasattr(self, 'singles_eps_worker') and self.singles_eps_worker:
|
||
self.singles_eps_worker.stop()
|
||
self.singles_eps_worker.wait()
|
||
|
||
# Start new worker for track-level matching
|
||
self.singles_eps_worker = SinglesEPsLibraryWorker(releases, MusicMatchingEngine())
|
||
self.singles_eps_worker.release_matched.connect(self.on_single_ep_matched)
|
||
self.singles_eps_worker.check_completed.connect(self.on_singles_eps_library_checked)
|
||
self.singles_eps_worker.check_failed.connect(self.on_singles_eps_check_failed)
|
||
self.singles_eps_worker.start()
|
||
|
||
def start_plex_library_check(self, albums):
|
||
"""Start database library check in background"""
|
||
# Get active server for dynamic toast message
|
||
try:
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
server_name = "Jellyfin" if active_server == "jellyfin" else "Plex"
|
||
except:
|
||
server_name = "Plex" # Fallback
|
||
|
||
# Show toast for library check start
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.info(f"Checking your {server_name} library for owned albums...")
|
||
|
||
# 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.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, album_statuses):
|
||
"""Handle final database library check completion with detailed status info"""
|
||
print(f"Database check completed: {len(album_statuses)} album statuses")
|
||
|
||
if not self.current_albums:
|
||
print("No current albums, skipping final update")
|
||
return
|
||
|
||
# Count different types of ownership
|
||
complete_count = sum(1 for status in album_statuses.values() if status.is_complete)
|
||
nearly_complete_count = sum(1 for status in album_statuses.values() if status.is_nearly_complete)
|
||
partial_count = sum(1 for status in album_statuses.values() if status.is_owned and not status.is_complete and not status.is_nearly_complete)
|
||
missing_count = sum(1 for status in album_statuses.values() if not status.is_owned)
|
||
total_count = len(self.current_albums)
|
||
|
||
# Update final status message with all categories
|
||
status_parts = []
|
||
if complete_count > 0:
|
||
status_parts.append(f"{complete_count} complete")
|
||
if nearly_complete_count > 0:
|
||
status_parts.append(f"{nearly_complete_count} nearly complete")
|
||
if partial_count > 0:
|
||
status_parts.append(f"{partial_count} partial")
|
||
if missing_count > 0:
|
||
status_parts.append(f"{missing_count} missing")
|
||
|
||
self.albums_status.setText(f"Found {total_count} releases • " + " • ".join(status_parts))
|
||
|
||
|
||
# Show toast with library check results
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
owned_count = complete_count + nearly_complete_count + partial_count
|
||
if owned_count == 0:
|
||
self.toast_manager.info(f"No albums found in your library ({total_count} available for download)")
|
||
elif nearly_complete_count > 0 or partial_count > 0:
|
||
if nearly_complete_count > 0:
|
||
self.toast_manager.success(f"Found {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial albums out of {total_count}")
|
||
else:
|
||
self.toast_manager.success(f"Found {complete_count} complete, {partial_count} partial albums out of {total_count}")
|
||
else:
|
||
self.toast_manager.success(f"Found {complete_count} complete albums out of {total_count}")
|
||
|
||
print(f"Database check complete: {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial, {missing_count} missing out of {total_count} albums")
|
||
|
||
# Update the album display with the final ownership statuses
|
||
self.display_albums(self.current_albums, album_statuses)
|
||
|
||
def on_album_matched(self, album_name, ownership_status):
|
||
"""Handle individual album match for real-time UI update with detailed status"""
|
||
if ownership_status.is_complete:
|
||
print(f"Real-time match: '{album_name}' (complete)")
|
||
elif ownership_status.is_nearly_complete:
|
||
print(f"Real-time match: '{album_name}' (nearly complete {int(ownership_status.completion_ratio * 100)}%)")
|
||
else:
|
||
print(f"Real-time match: '{album_name}' (partial {int(ownership_status.completion_ratio * 100)}%)")
|
||
|
||
# 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} releases • {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:
|
||
if ownership_status.is_complete:
|
||
status_text = "complete"
|
||
elif ownership_status.is_nearly_complete:
|
||
status_text = f"nearly complete ({int(ownership_status.completion_ratio * 100)}%)"
|
||
else:
|
||
status_text = f"partial ({int(ownership_status.completion_ratio * 100)}%)"
|
||
print(f"Real-time update: '{album_name}' -> {status_text}")
|
||
album_card.update_ownership(ownership_status)
|
||
break
|
||
|
||
def on_plex_library_check_failed(self, error):
|
||
"""Handle Plex library check failure"""
|
||
print(f"Plex library check failed: {error}")
|
||
|
||
# Get active server for dynamic error message
|
||
try:
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
server_name = "Jellyfin" if active_server == "jellyfin" else "Plex"
|
||
except:
|
||
server_name = "Plex" # Fallback
|
||
|
||
# Show error toast
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.error(f"{server_name} connection failed - cannot check owned albums")
|
||
|
||
if self.current_albums:
|
||
self.albums_status.setText(f"Found {len(self.current_albums)} albums • {server_name} check failed")
|
||
# Display albums without ownership info
|
||
self.display_albums(self.current_albums, set())
|
||
|
||
def on_single_ep_matched(self, release_name, ownership_status):
|
||
"""Handle real-time single/EP match results"""
|
||
if ownership_status.is_owned:
|
||
if ownership_status.is_complete:
|
||
print(f"Single/EP match: '{release_name}' (complete)")
|
||
else:
|
||
print(f"Single/EP match: '{release_name}' (partial {int(ownership_status.completion_ratio * 100)}%)")
|
||
|
||
# Find the corresponding card and update it
|
||
for i in range(self.albums_grid_layout.count()):
|
||
item = self.albums_grid_layout.itemAt(i)
|
||
if item:
|
||
card = item.widget()
|
||
if isinstance(card, AlbumCard) and card.album.name == release_name:
|
||
card.update_ownership(ownership_status)
|
||
break
|
||
|
||
def on_singles_eps_library_checked(self, release_statuses):
|
||
"""Handle singles/EPs library check completion"""
|
||
print(f"Singles/EPs check completed: {len(release_statuses)} statuses")
|
||
|
||
# Count results for summary
|
||
complete_count = sum(1 for status in release_statuses.values() if status.is_complete)
|
||
nearly_complete_count = sum(1 for status in release_statuses.values() if status.is_nearly_complete)
|
||
partial_count = sum(1 for status in release_statuses.values() if status.is_owned and not status.is_complete and not status.is_nearly_complete)
|
||
missing_count = sum(1 for status in release_statuses.values() if not status.is_owned)
|
||
total_count = len(release_statuses)
|
||
|
||
# Update status text with results
|
||
singles_count = len([r for r in self.singles_and_eps if r.total_tracks == 1])
|
||
eps_count = len([r for r in self.singles_and_eps if r.total_tracks > 1])
|
||
owned_count = complete_count + nearly_complete_count + partial_count
|
||
self.albums_status.setText(f"Found {singles_count} singles, {eps_count} EPs • {owned_count} owned")
|
||
|
||
# Show toast notifications
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
if owned_count == 0:
|
||
self.toast_manager.info(f"No releases found in your library ({total_count} available for download)")
|
||
else:
|
||
if complete_count > 0 and (nearly_complete_count > 0 or partial_count > 0):
|
||
self.toast_manager.success(f"Found {complete_count} complete, {nearly_complete_count + partial_count} partial releases")
|
||
elif complete_count > 0:
|
||
self.toast_manager.success(f"Found {complete_count} complete releases")
|
||
else:
|
||
self.toast_manager.success(f"Found {owned_count} partial releases")
|
||
|
||
print(f"Singles/EPs check complete: {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial, {missing_count} missing out of {total_count} releases")
|
||
|
||
# Update the display with the final ownership statuses
|
||
self.display_albums(self.current_albums, release_statuses)
|
||
|
||
def on_singles_eps_check_failed(self, error):
|
||
"""Handle singles/EPs check failure"""
|
||
print(f"Singles/EPs library check failed: {error}")
|
||
|
||
# Update status
|
||
singles_count = len([r for r in self.singles_and_eps if r.total_tracks == 1])
|
||
eps_count = len([r for r in self.singles_and_eps if r.total_tracks > 1])
|
||
self.albums_status.setText(f"Found {singles_count} singles, {eps_count} EPs • Check failed")
|
||
|
||
# Show error toast
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.error("Library connection failed - cannot check owned releases")
|
||
|
||
if self.current_albums:
|
||
# Display releases 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 releases: {error}")
|
||
|
||
def on_album_download_requested(self, album: Album):
|
||
"""Handle album download request from an AlbumCard using new modal system."""
|
||
print(f"Download requested for album: {album.name} by {', '.join(album.artists)}")
|
||
|
||
# Find the album card for this album to pass to modal
|
||
album_card = None
|
||
for i in range(self.albums_grid_layout.count()):
|
||
item = self.albums_grid_layout.itemAt(i)
|
||
if item and item.widget():
|
||
card = item.widget()
|
||
if hasattr(card, 'album') and card.album.id == album.id:
|
||
album_card = card
|
||
break
|
||
|
||
if not album_card:
|
||
QMessageBox.critical(self, "Error", "Could not find album card for tracking.")
|
||
return
|
||
|
||
# Check if we have necessary clients
|
||
if not self.downloads_page:
|
||
QMessageBox.critical(self, "Error", "Downloads page is not connected. Cannot start download.")
|
||
return
|
||
|
||
if not self.media_client:
|
||
QMessageBox.critical(self, "Error", "Music database is not available. Cannot verify existing tracks.")
|
||
return
|
||
|
||
# Check if there's already an active session for this album
|
||
if album.id in self.active_album_sessions:
|
||
existing_session = self.active_album_sessions[album.id]
|
||
existing_modal = existing_session.get('modal')
|
||
|
||
# Check if the modal still exists and is valid
|
||
try:
|
||
if existing_modal and existing_modal.isVisible():
|
||
print(f"Resuming existing active modal for album: {album.name}")
|
||
# Modal is already visible and active, just bring it to front
|
||
existing_modal.activateWindow()
|
||
existing_modal.raise_()
|
||
|
||
# Show toast notification
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.info(f"Downloads already in progress for '{album.name}'")
|
||
return
|
||
elif existing_modal:
|
||
# Modal exists but is not visible - check if downloads are still in progress
|
||
if hasattr(existing_modal, 'download_in_progress') and existing_modal.download_in_progress:
|
||
print(f"Resuming hidden modal with active downloads for album: {album.name}")
|
||
# Show the existing modal to resume progress tracking
|
||
existing_modal.show()
|
||
existing_modal.activateWindow()
|
||
existing_modal.raise_()
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.info(f"Resuming downloads for '{album.name}'")
|
||
return
|
||
else:
|
||
# Modal finished or cancelled, safe to create fresh one
|
||
print("Found finished modal, creating fresh modal")
|
||
del self.active_album_sessions[album.id]
|
||
else:
|
||
# No modal reference, clean up stale session
|
||
print("Stale session found, cleaning up")
|
||
del self.active_album_sessions[album.id]
|
||
except RuntimeError:
|
||
# Modal was deleted, remove from sessions
|
||
print("Existing modal was deleted, creating fresh session")
|
||
del self.active_album_sessions[album.id]
|
||
|
||
print("Fetching album tracks and creating DownloadMissingAlbumTracksModal...")
|
||
|
||
# First, we need to fetch the tracks for this album
|
||
try:
|
||
# Get the full album data with tracks from Spotify
|
||
album_data = self.spotify_client.get_album(album.id)
|
||
if not album_data or not album_data.get('tracks'):
|
||
QMessageBox.critical(self, "Error", f"Could not fetch tracks for album '{album.name}'. Please try again.")
|
||
return
|
||
|
||
# Import Track class for track creation
|
||
from core.spotify_client import Track
|
||
|
||
# Convert track data to Track objects
|
||
tracks = []
|
||
track_items = album_data['tracks']['items']
|
||
|
||
for track_data in track_items:
|
||
# Add missing fields that are required by Track.from_spotify_track()
|
||
track_data['album'] = {
|
||
'name': album_data['name'],
|
||
'id': album_data['id']
|
||
}
|
||
# Album tracks don't have popularity field, so set it to 0
|
||
if 'popularity' not in track_data:
|
||
track_data['popularity'] = 0
|
||
|
||
track = Track.from_spotify_track(track_data)
|
||
tracks.append(track)
|
||
|
||
print(f"Fetched {len(tracks)} tracks for album '{album.name}'")
|
||
|
||
# Create a copy of the album with tracks added
|
||
album_with_tracks = album
|
||
album_with_tracks.tracks = tracks # Add tracks attribute dynamically
|
||
|
||
# Create and show the new sophisticated modal
|
||
# Get active server and media client
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
|
||
if active_server == "jellyfin":
|
||
media_client = getattr(self, 'jellyfin_client', None)
|
||
if not media_client:
|
||
QMessageBox.critical(self, "Error", "Jellyfin client not available")
|
||
return
|
||
else:
|
||
media_client = self.plex_client
|
||
if not media_client:
|
||
QMessageBox.critical(self, "Error", "Plex client not available")
|
||
return
|
||
|
||
modal = DownloadMissingAlbumTracksModal(
|
||
album=album_with_tracks, # Use the album with tracks
|
||
album_card=album_card,
|
||
parent_page=self,
|
||
downloads_page=self.downloads_page,
|
||
media_client=media_client,
|
||
server_type=active_server
|
||
)
|
||
|
||
# Store the session for resumption
|
||
self.active_album_sessions[album.id] = {
|
||
'modal': modal,
|
||
'album_with_tracks': album_with_tracks,
|
||
'album_card': album_card
|
||
}
|
||
|
||
# Connect signals to handle cleanup - only use modal.finished to avoid double handling
|
||
modal.finished.connect(lambda result: self.on_album_modal_closed(album.id, album_card, result))
|
||
|
||
# Show the modal
|
||
modal.exec()
|
||
|
||
except Exception as e:
|
||
print(f"Error fetching album tracks: {e}")
|
||
QMessageBox.critical(self, "Error", f"Failed to fetch album tracks: {str(e)}\n\nPlease check your Spotify connection and try again.")
|
||
|
||
def on_album_download_process_finished(self, album_id: str, album_card):
|
||
"""Handle cleanup when album download process is actually finished (downloads completed)"""
|
||
print(f"Album download process finished for album: {album_id}")
|
||
|
||
# Only mark as completed if downloads actually finished successfully
|
||
if album_card and hasattr(album_card, 'set_download_completed'):
|
||
album_card.set_download_completed()
|
||
print(f"Marked album {album_id} as download completed")
|
||
|
||
print("Album download process cleanup completed")
|
||
|
||
def on_album_modal_closed(self, album_id: str, album_card, result):
|
||
"""Handle cleanup when album modal is closed (regardless of reason)"""
|
||
print(f"Album modal closed for album: {album_id}, result: {'Accepted' if result == 1 else 'Rejected/Cancelled'}")
|
||
|
||
# Clean up the session when modal is definitely closing
|
||
if album_id in self.active_album_sessions:
|
||
session = self.active_album_sessions[album_id]
|
||
modal = session.get('modal')
|
||
|
||
# Only remove session if downloads are completely finished or cancelled
|
||
if result == 1: # QDialog.Accepted = 1 (downloads completed or all tracks exist)
|
||
del self.active_album_sessions[album_id]
|
||
print(f"Removed completed session for album {album_id}")
|
||
elif modal and hasattr(modal, 'cancel_requested') and modal.cancel_requested:
|
||
# User explicitly cancelled - remove session for fresh modal on next click
|
||
del self.active_album_sessions[album_id]
|
||
print(f"Removed cancelled session for album {album_id} - user requested cancellation")
|
||
elif modal and hasattr(modal, 'download_in_progress') and not modal.download_in_progress:
|
||
# Downloads are not in progress, safe to remove session
|
||
del self.active_album_sessions[album_id]
|
||
print(f"Removed finished session for album {album_id} - no downloads in progress")
|
||
else:
|
||
# Downloads still in progress and not cancelled - keep session alive for resumption
|
||
print(f"Keeping session for album {album_id} - downloads still in progress, can be resumed")
|
||
|
||
if album_card:
|
||
try:
|
||
if result == 1: # QDialog.Accepted = 1 (downloads actually completed)
|
||
# Only mark as completed if downloads were actually successful
|
||
if hasattr(album_card, 'set_download_completed'):
|
||
album_card.set_download_completed()
|
||
print(f"Marked album {album_id} as download completed")
|
||
else:
|
||
# Modal was cancelled/closed - reset the card to allow reopening (but keep session)
|
||
# Reset any download-in-progress indicators
|
||
if hasattr(album_card, 'progress_overlay') and album_card.progress_overlay is not None:
|
||
try:
|
||
album_card.progress_overlay.hide()
|
||
print(f"Hidden progress overlay for album {album_id}")
|
||
except RuntimeError:
|
||
pass
|
||
|
||
# Also call the safe hide method if available
|
||
if hasattr(album_card, 'safe_hide_overlay'):
|
||
album_card.safe_hide_overlay()
|
||
|
||
# Reset the card to allow clicking again (if not already owned)
|
||
if not album_card.is_owned:
|
||
# Show a visual indicator that this album has an active session
|
||
if hasattr(album_card, 'status_indicator'):
|
||
try:
|
||
album_card.status_indicator.setText("")
|
||
album_card.status_indicator.setToolTip("Click to resume download session")
|
||
except RuntimeError:
|
||
pass
|
||
print(f"Reset album card for {album_id} to allow resumption")
|
||
|
||
except Exception as e:
|
||
print(f"Error handling album card state: {e}")
|
||
|
||
print("Album modal cleanup completed")
|
||
|
||
# === LEGACY METHODS - NO LONGER USED WITH NEW MODAL SYSTEM ===
|
||
# These methods were part of the old manual album download flow
|
||
# Keeping them commented for reference but they are replaced by DownloadMissingAlbumTracksModal
|
||
|
||
# def on_album_selected_for_download(self, album_result: AlbumResult):
|
||
# """
|
||
# [DEPRECATED] Handles album selection from the search dialog and delegates the
|
||
# matched album download process to the main DownloadsPage.
|
||
# REPLACED BY: DownloadMissingAlbumTracksModal which handles everything internally
|
||
# """
|
||
# print(f"Selected album for download: {album_result.album_title} by {album_result.artist}")
|
||
#
|
||
# if self.downloads_page:
|
||
# # Start tracking this album download
|
||
# album_id = f"{self.album_to_download.id}"
|
||
# self.start_album_download_tracking(album_id, album_result, self.album_to_download)
|
||
#
|
||
# # 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 start_album_download_tracking(self, album_id: str, album_result: AlbumResult, spotify_album: Album):
|
||
# """
|
||
# [DEPRECATED] Start tracking downloads for an album
|
||
# REPLACED BY: DownloadMissingAlbumTracksModal handles its own tracking
|
||
# """
|
||
# # Find the album card for this album
|
||
# album_card = None
|
||
# for i in range(self.albums_grid_layout.count()):
|
||
# item = self.albums_grid_layout.itemAt(i)
|
||
# if item and item.widget():
|
||
# card = item.widget()
|
||
# if hasattr(card, 'album') and card.album.id == spotify_album.id:
|
||
# album_card = card
|
||
# break
|
||
#
|
||
# if album_card:
|
||
# # Initialize tracking for this album
|
||
# self.album_downloads[album_id] = {
|
||
# 'total_tracks': album_result.track_count,
|
||
# 'completed_tracks': 0,
|
||
# 'active_downloads': [],
|
||
# 'album_card': album_card,
|
||
# 'album_result': album_result,
|
||
# 'spotify_album': spotify_album
|
||
# }
|
||
#
|
||
# # Update album card to show download in progress
|
||
# album_card.set_download_in_progress()
|
||
# print(f"Started tracking album: {spotify_album.name} ({album_result.track_count} tracks)")
|
||
|
||
# === END LEGACY METHODS ===
|
||
|
||
def poll_album_download_statuses(self):
|
||
"""Poll download statuses for tracked albums"""
|
||
if self._is_status_update_running or not self.album_downloads:
|
||
return
|
||
|
||
# Collect all active download IDs from tracked albums
|
||
all_download_ids = []
|
||
for album_info in self.album_downloads.values():
|
||
all_download_ids.extend(album_info.get('active_downloads', []))
|
||
|
||
if not all_download_ids:
|
||
# No active downloads to check, but we might need to populate the active_downloads
|
||
# by checking the downloads page for downloads related to our tracked albums
|
||
self.update_active_downloads_from_queue()
|
||
return
|
||
|
||
self._is_status_update_running = True
|
||
|
||
# Create items to check with enhanced data structure for album tracking
|
||
items_to_check = []
|
||
|
||
# Build comprehensive data for each tracked download
|
||
for album_id, album_info in self.album_downloads.items():
|
||
active_downloads = album_info.get('active_downloads', [])
|
||
|
||
for download_id in active_downloads:
|
||
# Try to get filename from downloads page if possible
|
||
file_path = self._get_download_filename(download_id)
|
||
|
||
item_data = {
|
||
'widget_id': download_id, # Use download_id as widget_id for tracking
|
||
'download_id': download_id,
|
||
'file_path': file_path,
|
||
'api_missing_count': 0, # Track for grace period logic
|
||
'album_id': album_id # Link back to album for easier processing
|
||
}
|
||
items_to_check.append(item_data)
|
||
|
||
if not items_to_check:
|
||
self._is_status_update_running = False
|
||
return
|
||
|
||
print(f"Starting album status check for {len(items_to_check)} downloads across {len(self.album_downloads)} albums")
|
||
|
||
# Create and start our dedicated album worker
|
||
worker = AlbumStatusProcessingWorker(
|
||
self.soulseek_client,
|
||
items_to_check
|
||
)
|
||
worker.signals.completed.connect(self._handle_album_status_updates)
|
||
worker.signals.error.connect(lambda e: self._on_album_status_error(e))
|
||
self.download_status_pool.start(worker)
|
||
|
||
def _get_download_filename(self, download_id):
|
||
"""Try to get filename for a download ID from the downloads page"""
|
||
if not self.downloads_page or not hasattr(self.downloads_page, 'download_queue'):
|
||
return ''
|
||
|
||
# Check active queue first
|
||
if hasattr(self.downloads_page.download_queue, 'active_queue'):
|
||
for item in self.downloads_page.download_queue.active_queue.download_items:
|
||
# Check for exact ID match first
|
||
if hasattr(item, 'download_id') and item.download_id == download_id:
|
||
if hasattr(item, 'filename'):
|
||
return item.filename
|
||
elif hasattr(item, 'title'):
|
||
return f"{item.title}.mp3" # Fallback with extension
|
||
|
||
# Also check if the real ID of this item matches
|
||
real_id = self._get_real_download_id(item)
|
||
if real_id and real_id == download_id:
|
||
if hasattr(item, 'filename'):
|
||
return item.filename
|
||
elif hasattr(item, 'title'):
|
||
return f"{item.title}.mp3" # Fallback with extension
|
||
|
||
# Check finished queue
|
||
if hasattr(self.downloads_page.download_queue, 'finished_queue'):
|
||
for item in self.downloads_page.download_queue.finished_queue.download_items:
|
||
# Check for exact ID match first
|
||
if hasattr(item, 'download_id') and item.download_id == download_id:
|
||
if hasattr(item, 'filename'):
|
||
return item.filename
|
||
elif hasattr(item, 'title'):
|
||
return f"{item.title}.mp3" # Fallback with extension
|
||
|
||
# Also check if the real ID of this item matches
|
||
real_id = self._get_real_download_id(item)
|
||
if real_id and real_id == download_id:
|
||
if hasattr(item, 'filename'):
|
||
return item.filename
|
||
elif hasattr(item, 'title'):
|
||
return f"{item.title}.mp3" # Fallback with extension
|
||
|
||
return ''
|
||
|
||
def _on_album_status_error(self, error_msg):
|
||
"""Handle errors from album status worker"""
|
||
print(f"Album status worker error: {error_msg}")
|
||
self._is_status_update_running = False
|
||
|
||
def update_active_downloads_from_queue(self):
|
||
"""Update active downloads list by checking the downloads page queue"""
|
||
if not self.downloads_page or not hasattr(self.downloads_page, 'download_queue'):
|
||
return
|
||
|
||
# Get all active downloads from the downloads page
|
||
active_items = []
|
||
finished_items = []
|
||
|
||
if hasattr(self.downloads_page.download_queue, 'active_queue'):
|
||
active_items = self.downloads_page.download_queue.active_queue.download_items
|
||
|
||
if hasattr(self.downloads_page.download_queue, 'finished_queue'):
|
||
finished_items = self.downloads_page.download_queue.finished_queue.download_items
|
||
|
||
print(f"Checking {len(active_items)} active downloads and {len(finished_items)} finished downloads for album tracking")
|
||
|
||
# For each tracked album, check if any downloads match
|
||
for album_id, album_info in self.album_downloads.items():
|
||
album_result = album_info.get('album_result')
|
||
spotify_album = album_info.get('spotify_album')
|
||
if not album_result or not spotify_album:
|
||
continue
|
||
|
||
album_name = spotify_album.name if spotify_album else 'Unknown'
|
||
print(f"Looking for downloads matching album: {album_name} by {album_result.artist}")
|
||
|
||
# Look for downloads that match this album's tracks (both active and finished)
|
||
matching_downloads = []
|
||
completed_count = 0
|
||
|
||
# Check both active and finished downloads
|
||
all_items = active_items + finished_items
|
||
|
||
for download_item in all_items:
|
||
# Enhanced matching logic for better album detection
|
||
is_match = self._is_download_from_album(download_item, album_result, spotify_album)
|
||
|
||
if is_match:
|
||
# Debug: show what download ID we're working with
|
||
current_id = getattr(download_item, 'download_id', 'NO_ID')
|
||
title = getattr(download_item, 'title', 'Unknown')
|
||
print(f" Found matching item: '{title}' with download_id: {current_id}")
|
||
|
||
# Use the download ID directly from the item (should be the real one)
|
||
if current_id and current_id != 'NO_ID':
|
||
# Check if this item is in finished items (completed)
|
||
if download_item in finished_items:
|
||
completed_count += 1
|
||
print(f" Found completed track: '{title}' (ID: {current_id})")
|
||
else:
|
||
# It's an active download - use the current ID
|
||
matching_downloads.append(current_id)
|
||
print(f" Added active download ID: {current_id} for '{title}'")
|
||
else:
|
||
print(f" No download ID found for: '{title}'")
|
||
|
||
# Update the active downloads and completed count for this album
|
||
old_active = album_info.get('active_downloads', [])
|
||
old_completed = album_info.get('completed_tracks', 0)
|
||
|
||
album_info['active_downloads'] = matching_downloads
|
||
|
||
# Update completed tracks count if we found more completed items
|
||
if completed_count > old_completed:
|
||
print(f"Updating completed tracks: {old_completed} -> {completed_count}")
|
||
album_info['completed_tracks'] = completed_count
|
||
# Trigger UI update
|
||
self.update_album_card_progress(album_id)
|
||
|
||
# Log changes
|
||
if len(matching_downloads) != len(old_active) or completed_count != old_completed:
|
||
print(f"Album '{album_name}': {len(old_active)} -> {len(matching_downloads)} active, {old_completed} -> {completed_count} completed")
|
||
|
||
if not matching_downloads and completed_count == 0:
|
||
total_tracks = album_info.get('total_tracks', 0)
|
||
print(f"No matching downloads found for album: {album_name} (expected {total_tracks} tracks)")
|
||
|
||
def _get_real_download_id(self, download_item):
|
||
"""Extract the real slskd download ID from a download item"""
|
||
if not hasattr(download_item, 'download_id'):
|
||
return None
|
||
|
||
download_id = download_item.download_id
|
||
|
||
# Check if it's already a UUID (real ID from slskd)
|
||
import re
|
||
uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||
|
||
if re.match(uuid_pattern, download_id, re.IGNORECASE):
|
||
# It's already a real UUID
|
||
return download_id
|
||
|
||
# Check if it's a simple numeric ID
|
||
if download_id.isdigit():
|
||
return download_id
|
||
|
||
# If it's a composite ID like "username_filename_timestamp_suffix",
|
||
# we need to look it up in the slskd API by filename
|
||
if hasattr(download_item, 'filename') and download_item.filename:
|
||
# Try to find the real ID by querying current downloads by filename
|
||
real_id = self._lookup_download_id_by_filename(download_item.filename)
|
||
if real_id:
|
||
print(f"Found real ID {real_id} for composite ID {download_id}")
|
||
return real_id
|
||
|
||
# If we can't determine the real ID, return the composite one
|
||
# The worker will try filename matching as fallback
|
||
return download_id
|
||
|
||
def _lookup_download_id_by_filename(self, filename):
|
||
"""Look up the real download ID by filename from slskd API"""
|
||
if not self.soulseek_client:
|
||
return None
|
||
|
||
try:
|
||
import asyncio
|
||
import os
|
||
|
||
# Create a temporary event loop to make the API call
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
try:
|
||
transfers_data = loop.run_until_complete(
|
||
self.soulseek_client._make_request('GET', 'transfers/downloads')
|
||
)
|
||
|
||
if not transfers_data:
|
||
return None
|
||
|
||
expected_basename = os.path.basename(filename).lower()
|
||
|
||
# Search through all transfers for matching filename
|
||
for user_data in transfers_data:
|
||
# Check files directly under user object
|
||
if 'files' in user_data and isinstance(user_data['files'], list):
|
||
for file_data in user_data['files']:
|
||
api_filename = file_data.get('filename', '')
|
||
api_basename = os.path.basename(api_filename).lower()
|
||
if api_basename == expected_basename:
|
||
return file_data.get('id')
|
||
|
||
# Check files in directories
|
||
if 'directories' in user_data and isinstance(user_data['directories'], list):
|
||
for directory in user_data['directories']:
|
||
if 'files' in directory and isinstance(directory['files'], list):
|
||
for file_data in directory['files']:
|
||
api_filename = file_data.get('filename', '')
|
||
api_basename = os.path.basename(api_filename).lower()
|
||
if api_basename == expected_basename:
|
||
return file_data.get('id')
|
||
|
||
finally:
|
||
loop.close()
|
||
|
||
except Exception as e:
|
||
print(f"Error looking up download ID for {filename}: {e}")
|
||
|
||
return None
|
||
|
||
def _is_download_from_album(self, download_item, album_result, spotify_album):
|
||
"""Enhanced matching logic to determine if a download belongs to the tracked album"""
|
||
# Check for explicit album match flag (from Spotify matching modal)
|
||
if hasattr(download_item, 'matched_download') and download_item.matched_download:
|
||
print(f" Found explicitly matched download: {getattr(download_item, 'title', 'Unknown')}")
|
||
return True
|
||
|
||
# Check for album metadata match
|
||
if hasattr(download_item, 'album') and download_item.album and spotify_album:
|
||
download_album = download_item.album.lower().strip()
|
||
spotify_album_name = spotify_album.name.lower().strip()
|
||
|
||
# Exact or partial album name match
|
||
if (download_album == spotify_album_name or
|
||
download_album in spotify_album_name or
|
||
spotify_album_name in download_album):
|
||
print(f" Album name match: '{download_album}' ~ '{spotify_album_name}'")
|
||
return True
|
||
|
||
# Check artist matching
|
||
artist_match = False
|
||
if hasattr(download_item, 'artist') and download_item.artist:
|
||
download_artist = download_item.artist.lower().strip()
|
||
|
||
# Check against album result artist
|
||
if album_result and album_result.artist:
|
||
album_artist = album_result.artist.lower().strip()
|
||
if (download_artist == album_artist or
|
||
download_artist in album_artist or
|
||
album_artist in download_artist):
|
||
artist_match = True
|
||
|
||
# Check against Spotify album artists
|
||
if spotify_album and spotify_album.artists:
|
||
for spotify_artist in spotify_album.artists:
|
||
spotify_artist_name = spotify_artist.lower().strip()
|
||
if (download_artist == spotify_artist_name or
|
||
download_artist in spotify_artist_name or
|
||
spotify_artist_name in download_artist):
|
||
artist_match = True
|
||
break
|
||
|
||
# For artist match, also check if it's recent (to avoid false positives from other albums)
|
||
if artist_match:
|
||
# Check if download was started recently (within album tracking timeframe)
|
||
# This helps filter out downloads from other albums by the same artist
|
||
if hasattr(download_item, 'created_time') or hasattr(download_item, 'start_time'):
|
||
# Could add timestamp checking here if needed
|
||
pass
|
||
print(f" Artist match found for: {getattr(download_item, 'title', 'Unknown')}")
|
||
return True
|
||
|
||
# Check filename-based matching as last resort
|
||
if hasattr(download_item, 'filename') and download_item.filename:
|
||
filename = download_item.filename.lower()
|
||
|
||
# Check if filename contains album name
|
||
if spotify_album and spotify_album.name.lower() in filename:
|
||
print(f" Filename contains album name: {download_item.filename}")
|
||
return True
|
||
|
||
# Check if filename contains artist name
|
||
if album_result and album_result.artist and album_result.artist.lower() in filename:
|
||
print(f" Filename contains artist name: {download_item.filename}")
|
||
return True
|
||
|
||
return False
|
||
|
||
def _handle_album_status_updates(self, results):
|
||
"""Handle status updates from the background worker"""
|
||
if not results:
|
||
self._is_status_update_running = False
|
||
return
|
||
|
||
print(f"Processing {len(results)} album download status updates")
|
||
|
||
albums_to_update = set()
|
||
albums_completed = set()
|
||
|
||
for result in results:
|
||
download_id = result.get('download_id')
|
||
widget_id = result.get('widget_id')
|
||
status = result.get('status', '')
|
||
progress = result.get('progress', 0.0)
|
||
album_id = result.get('album_id') # Direct album link from our enhanced data
|
||
|
||
# Handle missing downloads with grace period
|
||
if status == 'missing':
|
||
api_missing_count = result.get('api_missing_count', 0)
|
||
# Check if this download was previously completed but now missing (due to cleanup)
|
||
if self._was_download_previously_completed(download_id):
|
||
print(f"Download {download_id} was previously completed (now cleaned up)")
|
||
status = 'completed' # Treat as completed
|
||
else:
|
||
# Update the missing count in our tracking data for next poll
|
||
self._update_missing_count(download_id, api_missing_count)
|
||
continue
|
||
|
||
# Find which album this download belongs to
|
||
target_album_id = album_id # Use direct link if available
|
||
if not target_album_id:
|
||
# Fallback: search through all albums
|
||
for aid, album_info in self.album_downloads.items():
|
||
if download_id in album_info.get('active_downloads', []):
|
||
target_album_id = aid
|
||
break
|
||
|
||
if not target_album_id or target_album_id not in self.album_downloads:
|
||
print(f"Could not find album for download {download_id}")
|
||
continue
|
||
|
||
album_info = self.album_downloads[target_album_id]
|
||
album_name = album_info.get('spotify_album', {}).name if album_info.get('spotify_album') else 'Unknown'
|
||
|
||
print(f"Album '{album_name}': Download {download_id} status = {status} ({progress:.1f}%)")
|
||
|
||
# Handle status changes
|
||
if status == 'completed':
|
||
# Only process if not already handled by notification system
|
||
if not self._was_download_previously_completed(download_id):
|
||
# Mark this download as completed in our tracking
|
||
self._mark_download_as_completed(download_id)
|
||
|
||
# Only increment if not already counted
|
||
if download_id in album_info.get('active_downloads', []):
|
||
album_info['completed_tracks'] += 1
|
||
album_info['active_downloads'].remove(download_id)
|
||
albums_to_update.add(target_album_id)
|
||
print(f"Album track completed via polling: {album_info['completed_tracks']}/{album_info['total_tracks']}")
|
||
|
||
# Check if album is fully completed
|
||
if (album_info['completed_tracks'] >= album_info['total_tracks'] and
|
||
not album_info.get('active_downloads')):
|
||
albums_completed.add(target_album_id)
|
||
else:
|
||
print(f"Download {download_id} already counted as completed")
|
||
|
||
elif status in ['failed', 'cancelled']:
|
||
# Remove from active downloads but don't increment completed
|
||
if download_id in album_info['active_downloads']:
|
||
album_info['active_downloads'].remove(download_id)
|
||
albums_to_update.add(target_album_id)
|
||
print(f"Album track {status}: {download_id}")
|
||
|
||
elif status in ['downloading', 'queued']:
|
||
# Update progress for in-progress downloads
|
||
albums_to_update.add(target_album_id)
|
||
if progress > 0:
|
||
print(f"Track downloading: {progress:.1f}%")
|
||
|
||
# Update album cards for albums that had status changes
|
||
for album_id in albums_to_update:
|
||
self.update_album_card_progress(album_id)
|
||
|
||
# Handle completed albums
|
||
for album_id in albums_completed:
|
||
album_info = self.album_downloads[album_id]
|
||
album_card = album_info.get('album_card')
|
||
spotify_album = album_info.get('spotify_album')
|
||
album_name = spotify_album.name if spotify_album else 'Unknown'
|
||
|
||
if album_card:
|
||
album_card.set_download_completed()
|
||
|
||
# Remove from tracking
|
||
del self.album_downloads[album_id]
|
||
print(f"Album download completed and removed from tracking: {album_name}")
|
||
|
||
self._is_status_update_running = False
|
||
|
||
def _update_missing_count(self, download_id, missing_count):
|
||
"""Update missing count for downloads in grace period"""
|
||
# Find and update the missing count in our tracked items
|
||
# This helps maintain grace period logic across polling cycles
|
||
for album_info in self.album_downloads.values():
|
||
if download_id in album_info.get('active_downloads', []):
|
||
# We could store per-download missing counts if needed
|
||
# For now, if missing count reaches 3, the worker marks it as failed
|
||
if missing_count >= 3:
|
||
# Remove from active downloads as it's considered failed
|
||
album_info['active_downloads'] = [
|
||
did for did in album_info.get('active_downloads', [])
|
||
if did != download_id
|
||
]
|
||
print(f"Removed failed download {download_id} from album tracking")
|
||
break
|
||
|
||
def _mark_download_as_completed(self, download_id):
|
||
"""Mark a download as completed to handle cleanup detection"""
|
||
if download_id:
|
||
self.completed_downloads.add(download_id)
|
||
print(f"Marked download {download_id} as completed")
|
||
|
||
def _was_download_previously_completed(self, download_id):
|
||
"""Check if a download was previously marked as completed"""
|
||
return download_id in self.completed_downloads
|
||
|
||
def notify_download_completed(self, download_id, download_item=None):
|
||
"""Called by downloads page when a download completes (before cleanup)"""
|
||
print(f"Downloads page notified completion of: {download_id}")
|
||
if download_item:
|
||
print(f" Item: '{getattr(download_item, 'title', 'Unknown')}' by '{getattr(download_item, 'artist', 'Unknown')}'")
|
||
|
||
# Check if already processed to prevent double counting
|
||
if self._was_download_previously_completed(download_id):
|
||
print(f"Download {download_id} already processed, skipping")
|
||
return
|
||
|
||
# Mark as completed immediately
|
||
self._mark_download_as_completed(download_id)
|
||
|
||
# Find which album this belongs to - try multiple approaches
|
||
target_album_id = None
|
||
|
||
# Approach 1: Direct ID match (might work if IDs were updated)
|
||
for album_id, album_info in self.album_downloads.items():
|
||
if download_id in album_info.get('active_downloads', []):
|
||
target_album_id = album_id
|
||
print(f"Found album by direct ID match: {album_id}")
|
||
break
|
||
|
||
# Approach 2: Match by download item attributes if we have the item
|
||
if not target_album_id and download_item:
|
||
for album_id, album_info in self.album_downloads.items():
|
||
album_result = album_info.get('album_result')
|
||
spotify_album = album_info.get('spotify_album')
|
||
|
||
if self._is_download_from_album(download_item, album_result, spotify_album):
|
||
target_album_id = album_id
|
||
print(f"Found album by item matching: {album_id}")
|
||
break
|
||
|
||
# Approach 3: Remove any composite ID that might match this download
|
||
if not target_album_id and download_item:
|
||
item_title = getattr(download_item, 'title', '')
|
||
for album_id, album_info in self.album_downloads.items():
|
||
# Look for any active download that might be this track
|
||
active_downloads = album_info.get('active_downloads', [])
|
||
for active_id in active_downloads[:]: # Copy list to avoid modification during iteration
|
||
# Check if this composite ID refers to the same track
|
||
if item_title and item_title.lower() in active_id.lower():
|
||
# Replace the composite ID with the real ID
|
||
album_info['active_downloads'].remove(active_id)
|
||
album_info['active_downloads'].append(download_id)
|
||
target_album_id = album_id
|
||
print(f"Found album by title matching and updated ID: {active_id} -> {download_id}")
|
||
break
|
||
|
||
if target_album_id:
|
||
break
|
||
|
||
if target_album_id:
|
||
album_info = self.album_downloads[target_album_id]
|
||
|
||
# Remove the download ID from active downloads (might be composite or real)
|
||
if download_id in album_info['active_downloads']:
|
||
album_info['active_downloads'].remove(download_id)
|
||
|
||
# Increment completed count
|
||
album_info['completed_tracks'] += 1
|
||
|
||
# Update UI immediately
|
||
self.update_album_card_progress(target_album_id)
|
||
|
||
spotify_album = album_info.get('spotify_album')
|
||
album_name = spotify_album.name if spotify_album else 'Unknown'
|
||
print(f"Album '{album_name}' track completed via notification: {album_info['completed_tracks']}/{album_info['total_tracks']}")
|
||
|
||
# Check if album is complete
|
||
if (album_info['completed_tracks'] >= album_info['total_tracks'] and
|
||
not album_info.get('active_downloads')):
|
||
|
||
album_card = album_info.get('album_card')
|
||
if album_card:
|
||
album_card.set_download_completed()
|
||
|
||
# Remove from tracking
|
||
del self.album_downloads[target_album_id]
|
||
print(f"Album download completed via notification: {album_name}")
|
||
else:
|
||
print(f"Could not find album for completed download: {download_id}")
|
||
if download_item:
|
||
print(f" Title: '{getattr(download_item, 'title', 'Unknown')}'")
|
||
print(f" Artist: '{getattr(download_item, 'artist', 'Unknown')}'")
|
||
print(f" Album: '{getattr(download_item, 'album', 'Unknown')}'")
|
||
|
||
# List current tracked albums for debugging
|
||
print(f" Currently tracking {len(self.album_downloads)} albums:")
|
||
for aid, ainfo in self.album_downloads.items():
|
||
sa = ainfo.get('spotify_album')
|
||
name = sa.name if sa else 'Unknown'
|
||
active_count = len(ainfo.get('active_downloads', []))
|
||
print(f" {aid}: '{name}' ({active_count} active downloads)")
|
||
|
||
def update_album_card_progress(self, album_id: str):
|
||
"""Update the album card with current download progress"""
|
||
album_info = self.album_downloads.get(album_id)
|
||
if not album_info:
|
||
return
|
||
|
||
album_card = album_info.get('album_card')
|
||
if not album_card:
|
||
return
|
||
|
||
completed = album_info.get('completed_tracks', 0)
|
||
total = album_info.get('total_tracks', 1) # Avoid division by zero
|
||
active_downloads = album_info.get('active_downloads', [])
|
||
|
||
# Calculate progress percentage
|
||
percentage = int((completed / total) * 100) if total > 0 else 0
|
||
|
||
# Determine album download state
|
||
if completed >= total and not active_downloads:
|
||
# Album is fully complete - this will be handled in the main status handler
|
||
# Don't call set_download_completed here to avoid duplicate processing
|
||
print(f"Album '{album_info.get('spotify_album', {}).name if album_info.get('spotify_album') else 'Unknown'}' is complete: {completed}/{total}")
|
||
return
|
||
elif not active_downloads and completed == 0:
|
||
# No active downloads and nothing completed - might be initializing
|
||
album_card.set_download_in_progress()
|
||
print(f"Album initializing downloads...")
|
||
elif active_downloads:
|
||
# Has active downloads - show progress
|
||
album_card.update_download_progress(completed, total, percentage)
|
||
print(f"Album progress: {completed}/{total} tracks ({percentage}%)")
|
||
else:
|
||
# Some completed but no active - might be stalled or failed
|
||
if completed > 0:
|
||
album_card.update_download_progress(completed, total, percentage)
|
||
print(f"Album partially complete: {completed}/{total} tracks ({percentage}%)")
|
||
else:
|
||
album_card.set_download_in_progress()
|
||
print(f"Album status unclear, showing in progress...")
|
||
|
||
# Update the album card's status indicator to show download activity
|
||
if hasattr(album_card, 'status_indicator'):
|
||
if active_downloads:
|
||
# Show progress percentage or downloading indicator
|
||
if percentage > 0:
|
||
album_card.status_indicator.setText(f"{percentage}%")
|
||
album_card.status_indicator.setToolTip(f"Downloading: {completed}/{total} tracks ({percentage}%)")
|
||
else:
|
||
album_card.status_indicator.setText("")
|
||
album_card.status_indicator.setToolTip("Starting download...")
|
||
elif completed > 0:
|
||
# Show partial completion
|
||
album_card.status_indicator.setText(f"{percentage}%")
|
||
album_card.status_indicator.setToolTip(f"Partially downloaded: {completed}/{total} tracks")
|
||
|
||
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 toggle_watchlist(self):
|
||
"""Toggle artist in watchlist"""
|
||
if not hasattr(self, 'selected_artist') or not self.selected_artist:
|
||
return
|
||
|
||
try:
|
||
database = get_database()
|
||
artist_id = self.selected_artist.id
|
||
artist_name = self.selected_artist.name
|
||
|
||
if database.is_artist_in_watchlist(artist_id):
|
||
# Remove from watchlist
|
||
success = database.remove_artist_from_watchlist(artist_id)
|
||
if success:
|
||
self.update_watchlist_button(False)
|
||
# Emit signal to update dashboard button count
|
||
self.database_updated_externally.emit()
|
||
# Refresh all artist card watchlist indicators
|
||
self.refresh_all_artist_card_watchlist_status()
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.success(f"Removed {artist_name} from watchlist")
|
||
else:
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.error(f"Failed to remove {artist_name} from watchlist")
|
||
else:
|
||
# Add to watchlist
|
||
success = database.add_artist_to_watchlist(artist_id, artist_name)
|
||
if success:
|
||
self.update_watchlist_button(True)
|
||
# Emit signal to update dashboard button count
|
||
self.database_updated_externally.emit()
|
||
# Refresh all artist card watchlist indicators
|
||
self.refresh_all_artist_card_watchlist_status()
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.success(f"Added {artist_name} to watchlist")
|
||
else:
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.error(f"Failed to add {artist_name} to watchlist")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error toggling watchlist for artist: {e}")
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.error("Error updating watchlist")
|
||
|
||
def refresh_all_artist_card_watchlist_status(self):
|
||
"""Refresh watchlist indicators on all visible artist cards"""
|
||
try:
|
||
# Find all artist cards in the search results layout
|
||
for i in range(self.artist_results_layout.count()):
|
||
item = self.artist_results_layout.itemAt(i)
|
||
if item and item.widget():
|
||
widget = item.widget()
|
||
if isinstance(widget, ArtistResultCard):
|
||
widget.refresh_watchlist_status()
|
||
except Exception as e:
|
||
logger.error(f"Error refreshing artist card watchlist status: {e}")
|
||
|
||
def update_watchlist_button(self, is_watching):
|
||
"""Update watchlist button appearance based on watching status"""
|
||
if is_watching:
|
||
self.watchlist_button.setText("Watching...")
|
||
self.watchlist_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 193, 7, 0.15),
|
||
stop:1 rgba(255, 165, 0, 0.1));
|
||
border: 1px solid rgba(255, 193, 7, 0.6);
|
||
border-radius: 18px;
|
||
color: #ffc107;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
padding: 0 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 193, 7, 0.25),
|
||
stop:1 rgba(255, 165, 0, 0.18));
|
||
border: 1px solid rgba(255, 193, 7, 0.8);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 165, 0, 0.3),
|
||
stop:1 rgba(255, 193, 7, 0.25));
|
||
}
|
||
""")
|
||
else:
|
||
self.watchlist_button.setText("Add to Watchlist")
|
||
self.watchlist_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.15),
|
||
stop:1 rgba(20, 160, 70, 0.1));
|
||
border: 1px solid rgba(29, 185, 84, 0.6);
|
||
border-radius: 18px;
|
||
color: #1db954;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
padding: 0 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.25),
|
||
stop:1 rgba(20, 160, 70, 0.18));
|
||
border: 1px solid rgba(29, 185, 84, 0.8);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(20, 160, 70, 0.3),
|
||
stop:1 rgba(29, 185, 84, 0.25));
|
||
}
|
||
QPushButton:disabled {
|
||
background: rgba(80, 80, 85, 0.3);
|
||
border: 1px solid rgba(80, 80, 85, 0.5);
|
||
color: rgba(150, 150, 155, 0.7);
|
||
}
|
||
""")
|
||
|
||
def cleanup_download_tracking(self):
|
||
"""Clean up download tracking resources"""
|
||
print("Starting album download tracking cleanup...")
|
||
|
||
# Stop the download status timer
|
||
if hasattr(self, 'download_status_timer') and self.download_status_timer.isActive():
|
||
self.download_status_timer.stop()
|
||
print(" Stopped download status timer")
|
||
|
||
# Reset any album cards that are showing download progress
|
||
cards_reset = 0
|
||
for album_info in list(self.album_downloads.values()):
|
||
album_card = album_info.get('album_card')
|
||
if album_card:
|
||
# Hide progress overlays
|
||
if hasattr(album_card, 'progress_overlay'):
|
||
album_card.progress_overlay.hide()
|
||
|
||
# Reset status indicator if album wasn't owned originally
|
||
if hasattr(album_card, 'update_ownership') and not album_card.is_owned:
|
||
# Reset to available for download state
|
||
album_card.update_ownership(False)
|
||
|
||
cards_reset += 1
|
||
|
||
if cards_reset > 0:
|
||
print(f" Reset {cards_reset} album cards")
|
||
|
||
# Clear download tracking state
|
||
tracked_albums = len(self.album_downloads)
|
||
completed_downloads = len(self.completed_downloads)
|
||
self.album_downloads.clear()
|
||
self.completed_downloads.clear()
|
||
self._is_status_update_running = False
|
||
|
||
if tracked_albums > 0:
|
||
print(f" Cleared tracking for {tracked_albums} albums")
|
||
|
||
# Shutdown the download status thread pool gracefully
|
||
if hasattr(self, 'download_status_pool'):
|
||
try:
|
||
# Clear any pending tasks
|
||
self.download_status_pool.clear()
|
||
|
||
# Wait for active tasks to complete (with timeout)
|
||
if not self.download_status_pool.waitForDone(2000): # Wait up to 2 seconds
|
||
print(" Download status pool did not finish within timeout")
|
||
else:
|
||
print(" Download status pool shut down cleanly")
|
||
|
||
except Exception as e:
|
||
print(f" Error cleaning up download status pool: {e}")
|
||
|
||
print("Album download tracking cleanup completed")
|
||
|
||
def cleanup_album_sessions(self):
|
||
"""Clean up active album download sessions"""
|
||
if not self.active_album_sessions:
|
||
return
|
||
|
||
session_count = len(self.active_album_sessions)
|
||
print(f"Cleaning up {session_count} active album sessions...")
|
||
|
||
for album_id, session in list(self.active_album_sessions.items()):
|
||
try:
|
||
modal = session.get('modal')
|
||
if modal:
|
||
modal.cancel_operations()
|
||
modal.close()
|
||
except Exception as e:
|
||
print(f" Error cleaning up session for album {album_id}: {e}")
|
||
|
||
self.active_album_sessions.clear()
|
||
print(f"Cleaned up {session_count} album sessions")
|
||
|
||
def restart_download_tracking(self):
|
||
"""Restart download tracking timer if stopped"""
|
||
if hasattr(self, 'download_status_timer') and not self.download_status_timer.isActive():
|
||
self.download_status_timer.start(2000)
|
||
print("Download tracking timer restarted")
|
||
|
||
def stop_all_workers(self):
|
||
"""Stop all background workers"""
|
||
print("Stopping all artist page workers...")
|
||
|
||
workers_stopped = 0
|
||
|
||
if self.artist_search_worker and self.artist_search_worker.isRunning():
|
||
print(" Stopping artist search worker...")
|
||
self.artist_search_worker.terminate()
|
||
if self.artist_search_worker.wait(2000): # Wait up to 2 seconds
|
||
print(" Artist search worker stopped")
|
||
else:
|
||
print(" Artist search worker did not stop within timeout")
|
||
self.artist_search_worker = None
|
||
workers_stopped += 1
|
||
|
||
if self.album_fetch_worker and self.album_fetch_worker.isRunning():
|
||
print(" Stopping album fetch worker...")
|
||
self.album_fetch_worker.terminate()
|
||
if self.album_fetch_worker.wait(2000): # Wait up to 2 seconds
|
||
print(" Album fetch worker stopped")
|
||
else:
|
||
print(" Album fetch worker did not stop within timeout")
|
||
self.album_fetch_worker = None
|
||
workers_stopped += 1
|
||
|
||
if self.plex_library_worker and self.plex_library_worker.isRunning():
|
||
print(" Stopping Plex library worker...")
|
||
self.plex_library_worker.stop()
|
||
self.plex_library_worker.terminate()
|
||
if self.plex_library_worker.wait(2000): # Wait up to 2 seconds
|
||
print(" Plex library worker stopped")
|
||
else:
|
||
print(" Plex library worker did not stop within timeout")
|
||
self.plex_library_worker = None
|
||
|
||
if hasattr(self, 'singles_eps_worker') and self.singles_eps_worker:
|
||
self.singles_eps_worker.stop()
|
||
self.singles_eps_worker.wait()
|
||
self.singles_eps_worker = None
|
||
workers_stopped += 1
|
||
|
||
if workers_stopped > 0:
|
||
print(f" Stopped {workers_stopped} background workers")
|
||
|
||
# Stop download tracking (this includes its own worker cleanup)
|
||
self.cleanup_download_tracking()
|
||
|
||
# Clean up active album sessions
|
||
self.cleanup_album_sessions()
|
||
|
||
print("All workers stopped")
|
||
|
||
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 on_paths_updated(self, key: str, value: str):
|
||
"""Handle settings path updates for immediate effect"""
|
||
# No action needed - paths are fetched dynamically via config_manager.get()
|
||
# This method exists for future extensibility if caching is added later
|
||
pass
|
||
|
||
def closeEvent(self, event):
|
||
"""Handle page close/cleanup"""
|
||
self.stop_all_workers()
|
||
super().closeEvent(event)
|
||
|
||
|
||
|