9911 lines
441 KiB
Python
9911 lines
441 KiB
Python
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||
QFrame, QPushButton, QListWidget, QListWidgetItem,
|
||
QProgressBar, QTextEdit, QCheckBox, QComboBox,
|
||
QScrollArea, QSizePolicy, QMessageBox, QDialog,
|
||
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QLineEdit, QTabWidget)
|
||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve, QRunnable, QThreadPool, QObject
|
||
from PyQt6.QtGui import QFont, QBrush, QColor
|
||
import os
|
||
import json
|
||
from datetime import datetime
|
||
from dataclasses import dataclass
|
||
from typing import List, Optional
|
||
from core.soulseek_client import TrackResult
|
||
import re
|
||
import asyncio
|
||
import time
|
||
from core.matching_engine import MusicMatchingEngine
|
||
from core.wishlist_service import get_wishlist_service
|
||
from ui.components.toast_manager import ToastType
|
||
from database.music_database import get_database
|
||
from core.plex_scan_manager import PlexScanManager
|
||
from utils.logging_config import get_logger
|
||
import yt_dlp
|
||
from core.spotify_client import Track, Playlist
|
||
from core.tidal_client import TidalClient
|
||
|
||
logger = get_logger("sync")
|
||
|
||
# Define constants for storage
|
||
STORAGE_DIR = "storage"
|
||
STATUS_FILE = os.path.join(STORAGE_DIR, "sync_status.json")
|
||
|
||
class EllipsisLabel(QLabel):
|
||
"""A label that shows ellipsis for long text and tooltip on hover"""
|
||
def __init__(self, text="", parent=None):
|
||
super().__init__(text, parent)
|
||
self.full_text = text
|
||
self.setText(text)
|
||
|
||
def setText(self, text):
|
||
self.full_text = text
|
||
# Set elided text with ellipsis
|
||
try:
|
||
fm = self.fontMetrics()
|
||
widget_width = self.width()
|
||
# Use a minimum width if widget isn't sized yet
|
||
if widget_width <= 0:
|
||
widget_width = 200 # Default fallback width
|
||
elided_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, widget_width - 10)
|
||
super().setText(elided_text)
|
||
|
||
# Set tooltip to show full text if it's elided
|
||
if elided_text != text:
|
||
self.setToolTip(text)
|
||
else:
|
||
self.setToolTip("") # Clear tooltip if text fits
|
||
except Exception as e:
|
||
# Fallback to just setting the text if ellipsis calculation fails
|
||
logger.debug(f"EllipsisLabel setText error: {e}")
|
||
super().setText(text)
|
||
self.setToolTip(text)
|
||
|
||
def resizeEvent(self, event):
|
||
"""Handle resize events to recalculate ellipsis"""
|
||
super().resizeEvent(event)
|
||
# Re-elide text with new width
|
||
if self.full_text:
|
||
fm = self.fontMetrics()
|
||
elided_text = fm.elidedText(self.full_text, Qt.TextElideMode.ElideRight, self.width() - 10)
|
||
super().setText(elided_text)
|
||
|
||
# Update tooltip
|
||
if elided_text != self.full_text:
|
||
self.setToolTip(self.full_text)
|
||
else:
|
||
self.setToolTip("")
|
||
|
||
def load_sync_status():
|
||
"""Loads the sync status from the JSON file."""
|
||
if not os.path.exists(STATUS_FILE):
|
||
return {}
|
||
try:
|
||
with open(STATUS_FILE, 'r') as f:
|
||
# Return empty dict if file is empty
|
||
content = f.read()
|
||
if not content:
|
||
return {}
|
||
return json.loads(content)
|
||
except (json.JSONDecodeError, FileNotFoundError):
|
||
# If file is corrupted or not found, return an empty dict
|
||
print(f"Warning: Could not read or parse {STATUS_FILE}. Starting with a clean slate.")
|
||
return {}
|
||
|
||
def save_sync_status(data):
|
||
"""Saves the sync status to the JSON file."""
|
||
try:
|
||
os.makedirs(STORAGE_DIR, exist_ok=True)
|
||
with open(STATUS_FILE, 'w') as f:
|
||
json.dump(data, f, indent=4)
|
||
except Exception as e:
|
||
print(f"Error saving sync status to {STATUS_FILE}: {e}")
|
||
|
||
def clean_track_name_for_search(track_name):
|
||
"""
|
||
Intelligently cleans a track name for searching by removing noise while preserving important version information.
|
||
Removes: (feat. Artist), (Explicit), (Clean), etc.
|
||
Keeps: (Extended Version), (Live), (Acoustic), (Remix), etc.
|
||
"""
|
||
if not track_name or not isinstance(track_name, str):
|
||
return track_name
|
||
|
||
cleaned_name = track_name
|
||
|
||
# Define patterns to REMOVE (noise that doesn't affect track identity)
|
||
remove_patterns = [
|
||
r'\s*\(explicit\)', # (Explicit)
|
||
r'\s*\(clean\)', # (Clean)
|
||
r'\s*\(radio\s*edit\)', # (Radio Edit)
|
||
r'\s*\(radio\s*version\)', # (Radio Version)
|
||
r'\s*\(feat\.?\s*[^)]+\)', # (feat. Artist) or (ft. Artist)
|
||
r'\s*\(ft\.?\s*[^)]+\)', # (ft Artist)
|
||
r'\s*\(featuring\s*[^)]+\)', # (featuring Artist)
|
||
r'\s*\(with\s*[^)]+\)', # (with Artist)
|
||
r'\s*\[[^\]]*explicit[^\]]*\]', # [Explicit] in brackets
|
||
r'\s*\[[^\]]*clean[^\]]*\]', # [Clean] in brackets
|
||
]
|
||
|
||
# Apply removal patterns
|
||
for pattern in remove_patterns:
|
||
cleaned_name = re.sub(pattern, '', cleaned_name, flags=re.IGNORECASE).strip()
|
||
|
||
# PRESERVE important version information (do NOT remove these)
|
||
# These patterns are intentionally NOT in the remove list:
|
||
# - (Extended Version), (Extended), (Long Version)
|
||
# - (Live), (Live Version), (Concert)
|
||
# - (Acoustic), (Acoustic Version)
|
||
# - (Remix), (Club Mix), (Dance Mix)
|
||
# - (Remastered), (Remaster)
|
||
# - (Demo), (Studio Version)
|
||
# - (Instrumental)
|
||
# - Album/year info like (2023), (Deluxe Edition)
|
||
|
||
# If cleaning results in an empty string, return the original track name
|
||
if not cleaned_name.strip():
|
||
return track_name
|
||
|
||
# Log cleaning if significant changes were made
|
||
if cleaned_name != track_name:
|
||
print(f"🧹 Intelligent track cleaning: '{track_name}' -> '{cleaned_name}'")
|
||
|
||
return cleaned_name
|
||
|
||
def clean_youtube_track_title(title, artist_name=None):
|
||
"""
|
||
Aggressively clean YouTube track titles by removing video noise and extracting clean track names
|
||
|
||
Examples:
|
||
'No Way Jose (Official Music Video)' → 'No Way Jose'
|
||
'bbno$ - mary poppins (official music video)' → 'mary poppins'
|
||
'Beyond (From "Moana 2") (Official Video) ft. Rachel House' → 'Beyond'
|
||
'Temporary (feat. Skylar Grey) [Official Music Video]' → 'Temporary'
|
||
'ALL MY LOVE (Directors\' Cut)' → 'ALL MY LOVE'
|
||
'Espresso Macchiato | Estonia 🇪🇪 | Official Music Video | #Eurovision2025' → 'Espresso Macchiato'
|
||
"""
|
||
import re
|
||
|
||
if not title:
|
||
return title
|
||
|
||
original_title = title
|
||
|
||
# FIRST: Remove artist name if it appears at the start with a dash
|
||
# Handle formats like "LITTLE BIG - MOUSTACHE" → "MOUSTACHE"
|
||
if artist_name:
|
||
# Create a regex pattern to match artist name at the beginning followed by dash
|
||
# Use word boundaries and case-insensitive matching for better accuracy
|
||
artist_pattern = r'^' + re.escape(artist_name.strip()) + r'\s*[-–—]\s*'
|
||
cleaned_title = re.sub(artist_pattern, '', title, flags=re.IGNORECASE).strip()
|
||
|
||
# Debug logging for artist removal
|
||
if cleaned_title != title:
|
||
print(f"🎯 Removed artist from title: '{title}' -> '{cleaned_title}' (artist: '{artist_name}')")
|
||
|
||
title = cleaned_title
|
||
|
||
# Remove content in brackets/braces of any type SECOND (before general dash removal)
|
||
title = re.sub(r'【[^】]*】', '', title) # Japanese brackets
|
||
title = re.sub(r'\s*\([^)]*\)', '', title) # Parentheses - removes everything after first (
|
||
title = re.sub(r'\s*\(.*$', '', title) # Remove everything after lone ( (unmatched parentheses)
|
||
title = re.sub(r'\[[^\]]*\]', '', title) # Square brackets
|
||
title = re.sub(r'\{[^}]*\}', '', title) # Curly braces
|
||
title = re.sub(r'<[^>]*>', '', title) # Angle brackets
|
||
|
||
# Remove everything after a dash (often album or extra info)
|
||
title = re.sub(r'\s*-\s*.*$', '', title)
|
||
|
||
# Remove everything after pipes (|) - often used for additional context
|
||
title = re.split(r'\s*\|\s*', title)[0].strip()
|
||
|
||
# Remove common video/platform noise
|
||
noise_patterns = [
|
||
r'\bapple\s+music\b',
|
||
r'\bfull\s+video\b',
|
||
r'\bmusic\s+video\b',
|
||
r'\bofficial\s+video\b',
|
||
r'\bofficial\s+music\s+video\b',
|
||
r'\bofficial\b',
|
||
r'\bcensored\s+version\b',
|
||
r'\buncensored\s+version\b',
|
||
r'\bexplicit\s+version\b',
|
||
r'\blive\s+version\b',
|
||
r'\bversion\b',
|
||
r'\btopic\b',
|
||
r'\baudio\b',
|
||
r'\blyrics?\b',
|
||
r'\blyric\s+video\b',
|
||
r'\bwith\s+lyrics?\b',
|
||
r'\bvisuali[sz]er\b',
|
||
r'\bmv\b',
|
||
r'\bdirectors?\s+cut\b',
|
||
r'\bremaster(ed)?\b',
|
||
r'\bremix\b'
|
||
]
|
||
|
||
for pattern in noise_patterns:
|
||
title = re.sub(pattern, '', title, flags=re.IGNORECASE)
|
||
|
||
# Remove artist name from title if present
|
||
if artist_name:
|
||
# Try removing exact artist name
|
||
title = re.sub(rf'\b{re.escape(artist_name)}\b', '', title, flags=re.IGNORECASE)
|
||
# Try removing artist name with common separators
|
||
title = re.sub(rf'\b{re.escape(artist_name)}\s*[-–—:]\s*', '', title, flags=re.IGNORECASE)
|
||
title = re.sub(rf'^{re.escape(artist_name)}\s*[-–—:]\s*', '', title, flags=re.IGNORECASE)
|
||
|
||
# Remove all quotes and other punctuation
|
||
title = re.sub(r'["\'''""„‚‛‹›«»]', '', title)
|
||
|
||
# Remove featured artist patterns (after removing parentheses)
|
||
feat_patterns = [
|
||
r'\s+feat\.?\s+.+$', # " feat Artist" at end
|
||
r'\s+ft\.?\s+.+$', # " ft Artist" at end
|
||
r'\s+featuring\s+.+$', # " featuring Artist" at end
|
||
r'\s+with\s+.+$', # " with Artist" at end
|
||
]
|
||
|
||
for pattern in feat_patterns:
|
||
title = re.sub(pattern, '', title, flags=re.IGNORECASE).strip()
|
||
|
||
# Clean up whitespace and punctuation
|
||
title = re.sub(r'\s+', ' ', title).strip()
|
||
title = re.sub(r'^[-–—:,.\s]+|[-–—:,.\s]+$', '', title).strip()
|
||
|
||
# If we cleaned too much, return original
|
||
if not title.strip() or len(title.strip()) < 2:
|
||
title = original_title
|
||
|
||
if title != original_title:
|
||
print(f"🧹 YouTube title cleaned: '{original_title}' → '{title}'")
|
||
|
||
return title
|
||
|
||
def clean_youtube_artist(artist_string):
|
||
"""
|
||
Clean YouTube artist strings to get primary artist name
|
||
|
||
Examples:
|
||
'Yung Gravy, bbno$ (BABY GRAVY)' → 'Yung Gravy'
|
||
'Y2K, bbno$' → 'Y2K'
|
||
'LITTLE BIG' → 'LITTLE BIG'
|
||
'Artist "Nickname" Name' → 'Artist Nickname Name'
|
||
'ArtistVEVO' → 'Artist'
|
||
"""
|
||
import re
|
||
|
||
if not artist_string:
|
||
return artist_string
|
||
|
||
original_artist = artist_string
|
||
|
||
# Remove all quotes - they're usually not part of artist names
|
||
artist_string = artist_string.replace('"', '').replace("'", '').replace(''', '').replace(''', '').replace('"', '').replace('"', '')
|
||
|
||
# Remove anything in parentheses (often group/label names)
|
||
artist_string = re.sub(r'\s*\([^)]*\)', '', artist_string).strip()
|
||
|
||
# Remove anything in brackets (often additional info)
|
||
artist_string = re.sub(r'\s*\[[^\]]*\]', '', artist_string).strip()
|
||
|
||
# Remove common YouTube channel suffixes
|
||
channel_suffixes = [
|
||
r'\s*VEVO\s*$',
|
||
r'\s*Music\s*$',
|
||
r'\s*Official\s*$',
|
||
r'\s*Records\s*$',
|
||
r'\s*Entertainment\s*$',
|
||
r'\s*TV\s*$',
|
||
r'\s*Channel\s*$'
|
||
]
|
||
|
||
for suffix in channel_suffixes:
|
||
artist_string = re.sub(suffix, '', artist_string, flags=re.IGNORECASE).strip()
|
||
|
||
# Split on common separators and take the first artist
|
||
separators = [',', '&', ' and ', ' x ', ' X ', ' feat.', ' ft.', ' featuring', ' with', ' vs ', ' vs.']
|
||
|
||
for sep in separators:
|
||
if sep in artist_string:
|
||
parts = artist_string.split(sep)
|
||
artist_string = parts[0].strip()
|
||
break
|
||
|
||
# Clean up extra whitespace and punctuation
|
||
artist_string = re.sub(r'\s+', ' ', artist_string).strip()
|
||
artist_string = re.sub(r'^\-\s*|\s*\-$', '', artist_string).strip() # Remove leading/trailing dashes
|
||
artist_string = re.sub(r'^,\s*|\s*,$', '', artist_string).strip() # Remove leading/trailing commas
|
||
|
||
# If we cleaned too much, return original
|
||
if not artist_string.strip():
|
||
artist_string = original_artist
|
||
|
||
if artist_string != original_artist:
|
||
print(f"🧹 YouTube artist cleaned: '{original_artist}' → '{artist_string}'")
|
||
|
||
return artist_string
|
||
|
||
def parse_youtube_playlist(url):
|
||
"""
|
||
Parse a YouTube Music playlist URL and extract track information using yt-dlp
|
||
Uses flat playlist extraction to avoid rate limits and get all tracks
|
||
Returns a list of track dictionaries compatible with our Track structure
|
||
"""
|
||
try:
|
||
# Configure yt-dlp options for flat playlist extraction (avoids rate limits)
|
||
ydl_opts = {
|
||
'quiet': True,
|
||
'no_warnings': True,
|
||
'extract_flat': True, # Only extract basic info, no individual video metadata
|
||
'flat_playlist': True, # Extract all playlist entries without hitting API for each video
|
||
'skip_download': True, # Don't download, just extract IDs and basic info
|
||
# Remove all limits to get complete playlist
|
||
}
|
||
|
||
tracks = []
|
||
|
||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||
# Extract playlist info
|
||
playlist_info = ydl.extract_info(url, download=False)
|
||
|
||
if not playlist_info:
|
||
raise Exception("Could not extract playlist information")
|
||
|
||
# Get playlist entries
|
||
entries = playlist_info.get('entries', [])
|
||
|
||
if not entries:
|
||
raise Exception("No tracks found in playlist")
|
||
|
||
# Extract playlist title
|
||
playlist_title = playlist_info.get('title', 'YouTube Playlist')
|
||
print(f"🎵 Found {len(entries)} tracks in YouTube playlist: '{playlist_title}'")
|
||
print(f"📊 Playlist info keys: {list(playlist_info.keys())}")
|
||
if 'playlist_count' in playlist_info:
|
||
print(f"📊 Reported playlist count: {playlist_info['playlist_count']}")
|
||
if 'n_entries' in playlist_info:
|
||
print(f"📊 Reported n_entries: {playlist_info['n_entries']}")
|
||
print(f"📊 Actual entries length: {len(entries)}")
|
||
|
||
# Convert each entry to our Track format
|
||
for i, entry in enumerate(entries):
|
||
if not entry: # Skip None entries
|
||
continue
|
||
|
||
try:
|
||
# Extract title and uploader
|
||
raw_title = entry.get('title', f'Unknown Track {i+1}')
|
||
raw_uploader = entry.get('uploader', 'Unknown Artist')
|
||
duration = entry.get('duration', 0)
|
||
|
||
# Start with uploader as default artist
|
||
artists = [clean_youtube_artist(raw_uploader)]
|
||
track_name = raw_title
|
||
|
||
# Try to extract artist and track from title patterns
|
||
# Pattern 1: "LITTLE BIG – HARDCORE AMERICAN COWBOY" (artist in title)
|
||
if ' – ' in raw_title or ' - ' in raw_title:
|
||
separator = ' – ' if ' – ' in raw_title else ' - '
|
||
parts = raw_title.split(separator, 1)
|
||
if len(parts) == 2:
|
||
potential_artist = clean_youtube_artist(parts[0].strip())
|
||
potential_track = clean_youtube_track_title(parts[1].strip(), potential_artist)
|
||
|
||
# Use the artist from title if it looks valid
|
||
if potential_artist and len(potential_artist) > 1:
|
||
artists = [potential_artist]
|
||
track_name = potential_track
|
||
else:
|
||
track_name = clean_youtube_track_title(raw_title)
|
||
else:
|
||
track_name = clean_youtube_track_title(raw_title)
|
||
|
||
# Pattern 2: "Track by Artist" or "Track : Artist"
|
||
elif ' by ' in raw_title.lower():
|
||
parts = raw_title.lower().split(' by ', 1)
|
||
if len(parts) == 2:
|
||
track_name = clean_youtube_track_title(raw_title[:len(parts[0])].strip())
|
||
potential_artist = clean_youtube_artist(raw_title[len(parts[0]) + 4:].strip())
|
||
if potential_artist and len(potential_artist) > 1:
|
||
artists = [potential_artist]
|
||
|
||
elif ': ' in raw_title and len(raw_title.split(': ')) == 2:
|
||
parts = raw_title.split(': ', 1)
|
||
potential_artist = clean_youtube_artist(parts[0].strip())
|
||
potential_track = clean_youtube_track_title(parts[1].strip(), potential_artist)
|
||
|
||
if potential_artist and len(potential_artist) > 1:
|
||
artists = [potential_artist]
|
||
track_name = potential_track
|
||
else:
|
||
track_name = clean_youtube_track_title(raw_title)
|
||
|
||
else:
|
||
# No clear pattern, just clean the title
|
||
track_name = clean_youtube_track_title(raw_title)
|
||
|
||
# Final cleanup
|
||
track_name = track_name.strip()
|
||
artists = [artist.strip() for artist in artists if artist.strip()]
|
||
if not artists or not artists[0]:
|
||
artists = ['Unknown Artist']
|
||
|
||
# Create track dict compatible with our Track structure
|
||
track_data = {
|
||
'id': entry.get('id', f'youtube_{i}'),
|
||
'name': track_name,
|
||
'artists': artists,
|
||
'album': 'YouTube Music', # Default album name
|
||
'duration_ms': duration * 1000 if duration else 0,
|
||
'popularity': 0, # YouTube doesn't provide popularity
|
||
'preview_url': None,
|
||
'external_urls': {'youtube': entry.get('webpage_url', '')},
|
||
# Store original uncleaned data for fallback searches
|
||
'raw_title': raw_title,
|
||
'raw_uploader': raw_uploader
|
||
}
|
||
|
||
tracks.append(track_data)
|
||
|
||
# Log the parsing result for debugging
|
||
if track_name != raw_title or artists[0] != raw_uploader:
|
||
print(f"🎯 Parsed: '{raw_title}' by '{raw_uploader}' → '{track_name}' by '{artists[0]}'")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error processing track {i}: {e}")
|
||
continue
|
||
|
||
print(f"✅ Successfully processed {len(tracks)} tracks out of {len(entries)} entries")
|
||
if len(tracks) != len(entries):
|
||
skipped = len(entries) - len(tracks)
|
||
print(f"⚠️ Skipped {skipped} tracks due to processing errors")
|
||
|
||
return tracks, playlist_title
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error parsing YouTube playlist: {e}")
|
||
raise e
|
||
|
||
def create_youtube_playlist_object(tracks_data, playlist_url, playlist_title=None):
|
||
"""
|
||
Create a Playlist object from YouTube tracks data that's compatible
|
||
with the existing DownloadMissingTracksModal
|
||
"""
|
||
try:
|
||
# Convert track dictionaries to Track objects
|
||
tracks = []
|
||
for track_data in tracks_data:
|
||
track = Track(
|
||
id=track_data['id'],
|
||
name=track_data['name'],
|
||
artists=track_data['artists'],
|
||
album=track_data['album'],
|
||
duration_ms=track_data['duration_ms'],
|
||
popularity=track_data['popularity'],
|
||
preview_url=track_data['preview_url'],
|
||
external_urls=track_data['external_urls']
|
||
)
|
||
|
||
# Add raw uncleaned data for fallback searches
|
||
if 'raw_title' in track_data and 'raw_uploader' in track_data:
|
||
track.raw_title = track_data['raw_title']
|
||
track.raw_uploader = track_data['raw_uploader']
|
||
|
||
tracks.append(track)
|
||
|
||
# Create playlist object
|
||
# Use provided playlist title or fall back to generic name
|
||
if playlist_title:
|
||
playlist_name = playlist_title
|
||
else:
|
||
playlist_name = f"YouTube Playlist ({len(tracks)} tracks)"
|
||
|
||
playlist = Playlist(
|
||
id=f"youtube_{hash(playlist_url)}", # Generate unique ID from URL
|
||
name=playlist_name,
|
||
description=f"Imported from YouTube Music: {playlist_url}",
|
||
owner="YouTube Music",
|
||
public=True,
|
||
collaborative=False,
|
||
tracks=tracks,
|
||
total_tracks=len(tracks)
|
||
)
|
||
|
||
return playlist
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error creating YouTube playlist object: {e}")
|
||
raise e
|
||
|
||
@dataclass
|
||
class TrackAnalysisResult:
|
||
"""Result of analyzing a track for Plex existence"""
|
||
spotify_track: object # Spotify track object
|
||
exists_in_plex: bool
|
||
plex_match: Optional[object] = None # Plex track if found
|
||
confidence: float = 0.0
|
||
error_message: Optional[str] = None
|
||
|
||
class PlaylistTrackAnalysisWorkerSignals(QObject):
|
||
"""Signals for playlist track analysis worker"""
|
||
analysis_started = pyqtSignal(int) # total_tracks
|
||
track_analyzed = pyqtSignal(int, object) # track_index, TrackAnalysisResult
|
||
analysis_completed = pyqtSignal(list) # List[TrackAnalysisResult]
|
||
analysis_failed = pyqtSignal(str) # error_message
|
||
|
||
class PlaylistTrackAnalysisWorker(QRunnable):
|
||
"""Background worker to analyze playlist tracks against media library"""
|
||
|
||
def __init__(self, playlist_tracks, media_client, server_type="plex"):
|
||
super().__init__()
|
||
self.playlist_tracks = playlist_tracks
|
||
self.media_client = media_client # Can be plex_client or jellyfin_client
|
||
self.server_type = server_type
|
||
self.signals = PlaylistTrackAnalysisWorkerSignals()
|
||
self._cancelled = False
|
||
# Instantiate the matching engine once per worker for efficiency
|
||
self.matching_engine = MusicMatchingEngine()
|
||
|
||
def cancel(self):
|
||
"""Cancel the analysis operation"""
|
||
self._cancelled = True
|
||
|
||
def run(self):
|
||
"""Analyze each track in the playlist"""
|
||
try:
|
||
if self._cancelled:
|
||
return
|
||
|
||
self.signals.analysis_started.emit(len(self.playlist_tracks))
|
||
results = []
|
||
|
||
# Check if media server is connected
|
||
server_connected = False
|
||
try:
|
||
if self.media_client:
|
||
server_connected = self.media_client.is_connected()
|
||
except Exception as e:
|
||
print(f"{self.server_type.title()} connection check failed: {e}")
|
||
server_connected = False
|
||
|
||
for i, track in enumerate(self.playlist_tracks):
|
||
if self._cancelled:
|
||
return
|
||
|
||
result = TrackAnalysisResult(
|
||
spotify_track=track,
|
||
exists_in_plex=False
|
||
)
|
||
|
||
if server_connected:
|
||
# Check if track exists in media server
|
||
try:
|
||
match, confidence = self._check_track_in_library(track)
|
||
# Use the 0.8 confidence threshold
|
||
if match and confidence >= 0.8:
|
||
result.exists_in_plex = True # Keep existing field name for compatibility
|
||
result.plex_match = match # Keep existing field name for compatibility
|
||
result.confidence = confidence
|
||
except Exception as e:
|
||
result.error_message = f"{self.server_type.title()} check failed: {str(e)}"
|
||
|
||
results.append(result)
|
||
self.signals.track_analyzed.emit(i + 1, result)
|
||
|
||
if not self._cancelled:
|
||
self.signals.analysis_completed.emit(results)
|
||
|
||
except Exception as e:
|
||
if not self._cancelled:
|
||
import traceback
|
||
traceback.print_exc()
|
||
self.signals.analysis_failed.emit(str(e))
|
||
|
||
def _check_track_in_library(self, spotify_track):
|
||
"""
|
||
Check if a Spotify track exists in the database by searching for each artist and
|
||
stopping as soon as a confident match is found.
|
||
Now uses local database instead of media server API for much faster performance.
|
||
"""
|
||
try:
|
||
original_title = spotify_track.name
|
||
|
||
# Get database instance
|
||
db = get_database()
|
||
|
||
# --- Generate conservative title variations (preserve meaningful differences) ---
|
||
title_variations = [original_title]
|
||
|
||
# Only add cleaned version if it removes clear noise (not meaningful content like remixes)
|
||
cleaned_for_search = clean_track_name_for_search(original_title)
|
||
if cleaned_for_search.lower() != original_title.lower():
|
||
title_variations.append(cleaned_for_search)
|
||
|
||
# Use matching engine's conservative clean_title (no longer strips remixes/versions)
|
||
base_title = self.matching_engine.clean_title(original_title)
|
||
if base_title.lower() not in [t.lower() for t in title_variations]:
|
||
title_variations.append(base_title)
|
||
|
||
# DO NOT strip content after dashes - this removes important remix/version info
|
||
|
||
unique_title_variations = list(dict.fromkeys(title_variations))
|
||
|
||
# --- Search for each artist with each title variation ---
|
||
artists_to_search = spotify_track.artists if spotify_track.artists else [""]
|
||
for artist_name in artists_to_search:
|
||
if self._cancelled: return None, 0.0
|
||
|
||
for query_title in unique_title_variations:
|
||
if self._cancelled: return None, 0.0
|
||
|
||
# Use database check_track_exists method with consistent thresholds and active server filter
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server)
|
||
|
||
if db_track and confidence >= 0.7:
|
||
print(f"✔️ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
|
||
|
||
# Convert database track to format compatible with existing code
|
||
# Create a mock Plex track object for compatibility
|
||
class MockPlexTrack:
|
||
def __init__(self, db_track):
|
||
self.id = str(db_track.id)
|
||
self.title = db_track.title
|
||
self.artist_name = db_track.artist_name
|
||
self.album_title = db_track.album_title
|
||
self.track_number = db_track.track_number
|
||
self.duration = db_track.duration
|
||
self.file_path = db_track.file_path
|
||
|
||
mock_track = MockPlexTrack(db_track)
|
||
return mock_track, confidence
|
||
|
||
print(f"❌ No database match found for '{original_title}' by any of the artists {artists_to_search}")
|
||
return None, 0.0
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
print(f"Error checking track in database: {e}")
|
||
traceback.print_exc()
|
||
return None, 0.0
|
||
|
||
|
||
class TrackDownloadWorkerSignals(QObject):
|
||
"""Signals for track download worker"""
|
||
download_started = pyqtSignal(int, int, str) # download_index, track_index, download_id
|
||
download_failed = pyqtSignal(int, int, str) # download_index, track_index, error_message
|
||
|
||
class TrackDownloadWorker(QRunnable):
|
||
"""Background worker to download individual tracks via Soulseek"""
|
||
|
||
def __init__(self, spotify_track, soulseek_client, download_index, track_index, quality_preference=None):
|
||
super().__init__()
|
||
self.spotify_track = spotify_track
|
||
self.soulseek_client = soulseek_client
|
||
self.download_index = download_index
|
||
self.track_index = track_index
|
||
self.quality_preference = quality_preference or 'flac'
|
||
self.signals = TrackDownloadWorkerSignals()
|
||
self._cancelled = False
|
||
|
||
def cancel(self):
|
||
"""Cancel the download operation"""
|
||
self._cancelled = True
|
||
|
||
def run(self):
|
||
"""Download the track via Soulseek"""
|
||
try:
|
||
if self._cancelled or not self.soulseek_client:
|
||
return
|
||
|
||
# Create search queries - prioritize artist + track for better accuracy
|
||
track_name = self.spotify_track.name
|
||
artist_name = self.spotify_track.artists[0] if self.spotify_track.artists else ""
|
||
|
||
search_queries = []
|
||
# Try artist + track first (more specific, less false matches)
|
||
if artist_name:
|
||
search_queries.append(f"{artist_name} {track_name}")
|
||
# Fallback to track name only if artist search fails
|
||
search_queries.append(track_name)
|
||
|
||
download_id = None
|
||
|
||
# Try each search query until we find a download
|
||
for query in search_queries:
|
||
if self._cancelled:
|
||
return
|
||
|
||
print(f"🔍 Searching Soulseek: {query}")
|
||
|
||
# Use the async method (need to run in sync context)
|
||
import asyncio
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
try:
|
||
download_id = loop.run_until_complete(
|
||
self.soulseek_client.search_and_download_best(query, self.quality_preference)
|
||
)
|
||
if download_id:
|
||
break # Success - stop trying other queries
|
||
finally:
|
||
loop.close()
|
||
|
||
if download_id:
|
||
self.signals.download_started.emit(self.download_index, self.track_index, download_id)
|
||
else:
|
||
self.signals.download_failed.emit(self.download_index, self.track_index, "No search results found")
|
||
|
||
except Exception as e:
|
||
self.signals.download_failed.emit(self.download_index, self.track_index, str(e))
|
||
|
||
class SyncStatusProcessingWorkerSignals(QObject):
|
||
"""Defines the signals available from the SyncStatusProcessingWorker."""
|
||
completed = pyqtSignal(list)
|
||
error = pyqtSignal(str)
|
||
|
||
class SyncStatusProcessingWorker(QRunnable):
|
||
"""
|
||
Runs download status processing in a background thread for the sync modal.
|
||
It checks the slskd API to provide a reliable status, with fallbacks.
|
||
This implementation is based on the working logic from downloads.py to restore live updates.
|
||
"""
|
||
def __init__(self, soulseek_client, download_items_data):
|
||
super().__init__()
|
||
self.signals = SyncStatusProcessingWorkerSignals()
|
||
self.soulseek_client = soulseek_client
|
||
self.download_items_data = download_items_data
|
||
# This worker no longer performs filesystem checks, so it doesn't need transfers_directory.
|
||
|
||
def run(self):
|
||
"""The main logic of the background worker."""
|
||
try:
|
||
import asyncio
|
||
import os
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
transfers_data = loop.run_until_complete(
|
||
self.soulseek_client._make_request('GET', 'transfers/downloads')
|
||
)
|
||
loop.close()
|
||
|
||
results = []
|
||
if not transfers_data:
|
||
transfers_data = []
|
||
|
||
# --- FIX: More robustly parse the transfers data ---
|
||
# Errored/finished downloads might not be nested inside 'directories'.
|
||
# This checks for a 'files' list at both the user and directory levels.
|
||
all_transfers = []
|
||
for user_data in transfers_data:
|
||
# Check for files directly under the user object
|
||
if 'files' in user_data and isinstance(user_data['files'], list):
|
||
all_transfers.extend(user_data['files'])
|
||
# Also check for files nested inside 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):
|
||
all_transfers.extend(directory['files'])
|
||
|
||
transfers_by_id = {t['id']: t for t in all_transfers}
|
||
|
||
for item_data in self.download_items_data:
|
||
matching_transfer = None
|
||
|
||
# Step 1: Try to match by the original download ID.
|
||
if item_data.get('download_id'):
|
||
matching_transfer = transfers_by_id.get(item_data['download_id'])
|
||
|
||
# Step 2: If no match by ID, fall back to an exact filename match.
|
||
if not matching_transfer:
|
||
expected_basename = os.path.basename(item_data['file_path']).lower()
|
||
for t in all_transfers:
|
||
api_basename = os.path.basename(t.get('filename', '')).lower()
|
||
if api_basename == expected_basename:
|
||
matching_transfer = t
|
||
print(f"ℹ️ Found download for '{expected_basename}' by exact filename match.")
|
||
break
|
||
|
||
if matching_transfer:
|
||
state = matching_transfer.get('state', 'Unknown')
|
||
progress = matching_transfer.get('percentComplete', 0)
|
||
|
||
# Determine status with correct priority (Errored/Cancelled before Completed)
|
||
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'
|
||
elif 'InProgress' in state:
|
||
new_status = 'downloading'
|
||
else:
|
||
new_status = 'queued'
|
||
|
||
payload = {
|
||
'widget_id': item_data['widget_id'],
|
||
'status': new_status,
|
||
'progress': int(progress),
|
||
'transfer_id': matching_transfer.get('id'),
|
||
'username': matching_transfer.get('username')
|
||
}
|
||
results.append(payload)
|
||
else:
|
||
# If not found in the API, it might have failed or been cancelled.
|
||
# Use a grace period before marking as failed.
|
||
item_data['api_missing_count'] = item_data.get('api_missing_count', 0) + 1
|
||
if item_data['api_missing_count'] >= 3:
|
||
expected_filename = os.path.basename(item_data['file_path'])
|
||
print(f"❌ Download failed (missing from API after 3 checks): {expected_filename}")
|
||
payload = {'widget_id': item_data['widget_id'], 'status': 'failed'}
|
||
results.append(payload)
|
||
|
||
self.signals.completed.emit(results)
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
self.signals.error.emit(str(e))
|
||
|
||
class PlaylistLoaderThread(QThread):
|
||
playlist_loaded = pyqtSignal(object) # Single playlist
|
||
loading_finished = pyqtSignal(int) # Total count
|
||
loading_failed = pyqtSignal(str) # Error message
|
||
progress_updated = pyqtSignal(str) # Progress text
|
||
|
||
def __init__(self, spotify_client):
|
||
super().__init__()
|
||
self.spotify_client = spotify_client
|
||
|
||
def run(self):
|
||
try:
|
||
self.progress_updated.emit("Connecting to Spotify...")
|
||
if not self.spotify_client or not self.spotify_client.is_authenticated():
|
||
self.loading_failed.emit("Spotify not authenticated")
|
||
return
|
||
|
||
self.progress_updated.emit("Fetching playlists...")
|
||
playlists = self.spotify_client.get_user_playlists_metadata_only()
|
||
|
||
for i, playlist in enumerate(playlists):
|
||
self.progress_updated.emit(f"Loading playlist {i+1}/{len(playlists)}: {playlist.name}")
|
||
self.playlist_loaded.emit(playlist)
|
||
self.msleep(20) # Reduced delay for faster but visible progressive loading
|
||
|
||
self.loading_finished.emit(len(playlists))
|
||
|
||
except Exception as e:
|
||
self.loading_failed.emit(str(e))
|
||
|
||
class TidalPlaylistLoaderThread(QThread):
|
||
playlist_loaded = pyqtSignal(object) # Single playlist
|
||
loading_finished = pyqtSignal(int) # Total count
|
||
loading_failed = pyqtSignal(str) # Error message
|
||
progress_updated = pyqtSignal(str) # Progress text
|
||
|
||
def __init__(self, tidal_client):
|
||
super().__init__()
|
||
self.tidal_client = tidal_client
|
||
|
||
def run(self):
|
||
try:
|
||
self.progress_updated.emit("Connecting to Tidal...")
|
||
if not self.tidal_client:
|
||
self.loading_failed.emit("Tidal client not available")
|
||
return
|
||
|
||
# Try to ensure authentication (will trigger OAuth if needed)
|
||
if not self.tidal_client.is_authenticated():
|
||
self.progress_updated.emit("Authenticating with Tidal...")
|
||
if not self.tidal_client._ensure_valid_token():
|
||
self.loading_failed.emit("Tidal authentication failed. Please check your settings and complete OAuth flow.")
|
||
return
|
||
|
||
self.progress_updated.emit("Fetching playlists...")
|
||
playlists = self.tidal_client.get_user_playlists_metadata_only()
|
||
|
||
for i, playlist in enumerate(playlists):
|
||
self.progress_updated.emit(f"Loading playlist {i+1}/{len(playlists)}: {playlist.name}")
|
||
self.playlist_loaded.emit(playlist)
|
||
self.msleep(20) # Reduced delay for faster but visible progressive loading
|
||
|
||
self.loading_finished.emit(len(playlists))
|
||
|
||
except Exception as e:
|
||
self.loading_failed.emit(str(e))
|
||
|
||
class TrackLoadingWorkerSignals(QObject):
|
||
"""Signals for async track loading worker"""
|
||
tracks_loaded = pyqtSignal(str, list) # playlist_id, tracks
|
||
loading_failed = pyqtSignal(str, str) # playlist_id, error_message
|
||
loading_started = pyqtSignal(str) # playlist_id
|
||
|
||
class TrackLoadingWorker(QRunnable):
|
||
"""Async worker for loading playlist tracks (following downloads.py pattern)"""
|
||
|
||
def __init__(self, spotify_client, playlist_id, playlist_name):
|
||
super().__init__()
|
||
self.spotify_client = spotify_client
|
||
self.playlist_id = playlist_id
|
||
self.playlist_name = playlist_name
|
||
self.signals = TrackLoadingWorkerSignals()
|
||
self._cancelled = False
|
||
|
||
def cancel(self):
|
||
"""Cancel the worker operation"""
|
||
self._cancelled = True
|
||
|
||
def run(self):
|
||
"""Load tracks in background thread"""
|
||
logger.info(f"TrackLoadingWorker starting for playlist {self.playlist_id}")
|
||
try:
|
||
if self._cancelled:
|
||
logger.info(f"TrackLoadingWorker cancelled before starting for playlist {self.playlist_id}")
|
||
return
|
||
|
||
logger.info(f"Emitting loading_started signal for playlist {self.playlist_id}")
|
||
self.signals.loading_started.emit(self.playlist_id)
|
||
|
||
if self._cancelled:
|
||
logger.info(f"TrackLoadingWorker cancelled after loading_started for playlist {self.playlist_id}")
|
||
return
|
||
|
||
# Fetch tracks from Spotify API
|
||
logger.info(f"Fetching tracks from Spotify API for playlist {self.playlist_id}")
|
||
tracks = self.spotify_client._get_playlist_tracks(self.playlist_id)
|
||
logger.info(f"Successfully fetched {len(tracks) if tracks else 0} tracks for playlist {self.playlist_id}")
|
||
|
||
if self._cancelled:
|
||
logger.info(f"TrackLoadingWorker cancelled after fetching tracks for playlist {self.playlist_id}")
|
||
return
|
||
|
||
# Emit success signal
|
||
logger.info(f"Emitting tracks_loaded signal for playlist {self.playlist_id} with {len(tracks) if tracks else 0} tracks")
|
||
self.signals.tracks_loaded.emit(self.playlist_id, tracks)
|
||
logger.info(f"TrackLoadingWorker completed successfully for playlist {self.playlist_id}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"TrackLoadingWorker failed for playlist {self.playlist_id}: {e}")
|
||
if not self._cancelled:
|
||
# Emit error signal only if not cancelled
|
||
logger.info(f"Emitting loading_failed signal for playlist {self.playlist_id}")
|
||
self.signals.loading_failed.emit(self.playlist_id, str(e))
|
||
else:
|
||
logger.info(f"TrackLoadingWorker was cancelled, not emitting error signal for playlist {self.playlist_id}")
|
||
|
||
class SyncWorkerSignals(QObject):
|
||
"""Signals for sync worker"""
|
||
progress = pyqtSignal(object) # SyncProgress
|
||
finished = pyqtSignal(object, object) # SyncResult, snapshot_id (can be None)
|
||
error = pyqtSignal(str)
|
||
|
||
class SyncWorker(QRunnable):
|
||
"""Background worker for playlist synchronization with real-time progress callbacks"""
|
||
|
||
def __init__(self, playlist, sync_service, progress_callback=None):
|
||
super().__init__()
|
||
self.playlist = playlist
|
||
self.sync_service = sync_service
|
||
self.progress_callback = progress_callback
|
||
self.signals = SyncWorkerSignals()
|
||
self._cancelled = False
|
||
|
||
# Connect progress callback
|
||
if progress_callback:
|
||
self.signals.progress.connect(progress_callback)
|
||
|
||
def cancel(self):
|
||
"""Cancel the sync operation"""
|
||
self._cancelled = True
|
||
if hasattr(self.sync_service, 'cancel_sync'):
|
||
self.sync_service.cancel_sync()
|
||
|
||
# Clear the progress callback to stop further progress updates
|
||
if hasattr(self.sync_service, 'clear_progress_callback'):
|
||
self.sync_service.clear_progress_callback(self.playlist.name)
|
||
|
||
# Log the cancellation request
|
||
print(f"DEBUG: SyncWorker.cancel() called for playlist {getattr(self.playlist, 'name', 'unknown')}")
|
||
|
||
def run(self):
|
||
"""Execute the sync operation"""
|
||
snapshot_id = None # Define snapshot_id in the outer scope
|
||
try:
|
||
if self._cancelled:
|
||
return
|
||
|
||
# Set up progress callback for sync service
|
||
def on_progress(progress):
|
||
print(f"⚡ SyncWorker progress callback called! total={progress.total_tracks}, matched={progress.matched_tracks}")
|
||
if not self._cancelled:
|
||
print(f"⚡ Emitting progress signal to parent page")
|
||
self.signals.progress.emit(progress)
|
||
else:
|
||
print(f"⚡ Sync was cancelled, not emitting signal")
|
||
|
||
print(f"⚡ Setting up progress callback for playlist: '{self.playlist.name}'")
|
||
self.sync_service.set_progress_callback(on_progress, self.playlist.name)
|
||
|
||
# Create new event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
try:
|
||
# Run sync with playlist object
|
||
result = loop.run_until_complete(
|
||
self.sync_service.sync_playlist(self.playlist, download_missing=False)
|
||
)
|
||
|
||
# --- THE FIX ---
|
||
# After sync, fetch the new snapshot_id directly from Spotify
|
||
# to ensure we have the most up-to-date value.
|
||
try:
|
||
if hasattr(self.sync_service, 'spotify_client') and self.sync_service.spotify_client:
|
||
# Assuming a synchronous method to get a single playlist's metadata
|
||
updated_playlist = self.sync_service.spotify_client.get_playlist(self.playlist.id)
|
||
if updated_playlist:
|
||
snapshot_id = updated_playlist.snapshot_id
|
||
print(f"DEBUG: Successfully fetched new snapshot_id: {snapshot_id}")
|
||
else:
|
||
print(f"WARNING: get_playlist returned None for {self.playlist.name}")
|
||
else:
|
||
print("WARNING: Could not get snapshot_id, spotify_client not found on sync_service.")
|
||
except Exception as e:
|
||
print(f"WARNING: Could not fetch updated snapshot_id for {self.playlist.name}: {e}")
|
||
|
||
if not self._cancelled:
|
||
# Emit the result and the (potentially new) snapshot_id
|
||
self.signals.finished.emit(result, snapshot_id)
|
||
|
||
finally:
|
||
loop.close()
|
||
|
||
except Exception as e:
|
||
if not self._cancelled:
|
||
self.signals.error.emit(str(e))
|
||
|
||
class PlaylistDetailsModal(QDialog):
|
||
def __init__(self, playlist, parent=None):
|
||
super().__init__(parent)
|
||
self.playlist = playlist
|
||
self.parent_page = parent
|
||
self.spotify_client = parent.spotify_client if parent else None
|
||
|
||
# Thread management
|
||
self.active_workers = []
|
||
self.fallback_pools = []
|
||
self.is_closing = False
|
||
|
||
# Sync state tracking
|
||
self.is_syncing = False
|
||
self.sync_worker = None
|
||
self.sync_status_widget = None
|
||
self.sync_button = None
|
||
|
||
# Clear existing tracks BEFORE setup_ui to prevent synchronous population
|
||
if self.spotify_client:
|
||
self.playlist.tracks = []
|
||
|
||
self.setup_ui()
|
||
|
||
# Restore sync state if playlist is currently syncing
|
||
self.restore_sync_state()
|
||
|
||
# Load tracks asynchronously if not already loaded
|
||
if not self.playlist.tracks and self.spotify_client:
|
||
# Check cache first
|
||
if hasattr(parent, 'track_cache') and playlist.id in parent.track_cache:
|
||
self.playlist.tracks = parent.track_cache[playlist.id]
|
||
self.refresh_track_table()
|
||
else:
|
||
self.load_tracks_async()
|
||
|
||
def closeEvent(self, event):
|
||
"""Clean up threads and resources when modal is closed"""
|
||
self.is_closing = True
|
||
self.cleanup_workers()
|
||
super().closeEvent(event)
|
||
|
||
def cleanup_workers(self):
|
||
"""Clean up all active workers and thread pools (except sync workers)"""
|
||
# Cancel active workers first, but skip sync workers to allow background sync
|
||
for worker in self.active_workers:
|
||
try:
|
||
# Don't cancel sync workers - they should continue in background
|
||
if hasattr(worker, 'cancel') and not isinstance(worker, SyncWorker):
|
||
worker.cancel()
|
||
except (RuntimeError, AttributeError):
|
||
pass
|
||
|
||
# Disconnect signals from active workers to prevent race conditions (except sync workers)
|
||
for worker in self.active_workers:
|
||
try:
|
||
# Don't disconnect sync worker signals - they need to continue updating playlist items
|
||
if hasattr(worker, 'signals') and not isinstance(worker, SyncWorker):
|
||
# Disconnect track loading worker signals
|
||
try:
|
||
worker.signals.tracks_loaded.disconnect(self.on_tracks_loaded)
|
||
except (RuntimeError, TypeError):
|
||
pass
|
||
try:
|
||
worker.signals.loading_failed.disconnect(self.on_tracks_loading_failed)
|
||
except (RuntimeError, TypeError):
|
||
pass
|
||
|
||
# Disconnect playlist analysis worker signals
|
||
try:
|
||
worker.signals.analysis_started.disconnect(self.on_analysis_started)
|
||
except (RuntimeError, TypeError):
|
||
pass
|
||
try:
|
||
worker.signals.track_analyzed.disconnect(self.on_track_analyzed)
|
||
except (RuntimeError, TypeError):
|
||
pass
|
||
try:
|
||
worker.signals.analysis_completed.disconnect(self.on_analysis_completed)
|
||
except (RuntimeError, TypeError):
|
||
pass
|
||
try:
|
||
worker.signals.analysis_failed.disconnect(self.on_analysis_failed)
|
||
except (RuntimeError, TypeError):
|
||
pass
|
||
except (RuntimeError, AttributeError):
|
||
# Signal may already be disconnected or worker deleted
|
||
pass
|
||
|
||
# Clean up fallback thread pools with timeout
|
||
for pool in self.fallback_pools:
|
||
try:
|
||
pool.clear() # Cancel pending workers
|
||
if not pool.waitForDone(2000): # Wait 2 seconds max
|
||
# Force termination if workers don't finish gracefully
|
||
pool.clear()
|
||
except (RuntimeError, AttributeError):
|
||
pass
|
||
|
||
# Clear tracking lists
|
||
self.active_workers.clear()
|
||
self.fallback_pools.clear()
|
||
|
||
def setup_ui(self):
|
||
self.setWindowTitle(f"Playlist Details - {self.playlist.name}")
|
||
|
||
# Make modal responsive to screen size
|
||
from PyQt6.QtWidgets import QApplication
|
||
screen = QApplication.primaryScreen().geometry()
|
||
modal_width = min(1200, int(screen.width() * 0.9))
|
||
modal_height = min(800, int(screen.height() * 0.9))
|
||
self.resize(modal_width, modal_height)
|
||
|
||
# Center the modal on screen
|
||
self.move((screen.width() - modal_width) // 2, (screen.height() - modal_height) // 2)
|
||
|
||
self.setStyleSheet("""
|
||
QDialog {
|
||
background: #191414;
|
||
color: #ffffff;
|
||
}
|
||
""")
|
||
|
||
main_layout = QVBoxLayout(self)
|
||
main_layout.setContentsMargins(20, 20, 20, 20) # Reduced margins for smaller screens
|
||
main_layout.setSpacing(16)
|
||
|
||
# Header section (fixed height)
|
||
header = self.create_header()
|
||
main_layout.addWidget(header, 0) # stretch factor 0 - fixed size
|
||
|
||
# Track list section (expandable)
|
||
track_list = self.create_track_list()
|
||
main_layout.addWidget(track_list, 1) # stretch factor 1 - takes available space
|
||
|
||
# Button section (fixed height, always visible)
|
||
button_widget = QWidget()
|
||
button_layout = self.create_buttons()
|
||
button_widget.setLayout(button_layout)
|
||
main_layout.addWidget(button_widget, 0) # stretch factor 0 - fixed size
|
||
|
||
def create_header(self):
|
||
header = QFrame()
|
||
header.setFixedHeight(120)
|
||
header.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 16px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(header)
|
||
layout.setContentsMargins(32, 24, 32, 24)
|
||
layout.setSpacing(12)
|
||
|
||
# Playlist name - larger, more prominent
|
||
name_label = QLabel(self.playlist.name)
|
||
name_label.setFont(QFont("SF Pro Display", 24, QFont.Weight.Bold))
|
||
name_label.setStyleSheet("color: #ffffff; border: none; background: transparent;")
|
||
|
||
# Playlist info in a more compact horizontal layout
|
||
info_layout = QHBoxLayout()
|
||
info_layout.setSpacing(24)
|
||
|
||
# Track count with icon-like styling
|
||
track_count = QLabel(f"{self.playlist.total_tracks} tracks")
|
||
track_count.setFont(QFont("SF Pro Text", 14, QFont.Weight.Medium))
|
||
track_count.setStyleSheet("color: #b3b3b3; border: none; background: transparent;")
|
||
|
||
# Owner with subtle separator
|
||
owner = QLabel(f"by {self.playlist.owner}")
|
||
owner.setFont(QFont("SF Pro Text", 14))
|
||
owner.setStyleSheet("color: #b3b3b3; border: none; background: transparent;")
|
||
|
||
# Status with accent color
|
||
visibility = "Public" if self.playlist.public else "Private"
|
||
if self.playlist.collaborative:
|
||
visibility = "Collaborative"
|
||
status = QLabel(visibility)
|
||
status.setFont(QFont("SF Pro Text", 14, QFont.Weight.Medium))
|
||
status.setStyleSheet("""
|
||
color: #1db954;
|
||
border: none;
|
||
background: rgba(29, 185, 84, 0.1);
|
||
padding: 4px 12px;
|
||
border-radius: 12px;
|
||
""")
|
||
|
||
info_layout.addWidget(track_count)
|
||
info_layout.addWidget(owner)
|
||
info_layout.addWidget(status)
|
||
info_layout.addStretch()
|
||
|
||
# Sync status display (hidden by default)
|
||
self.sync_status_widget = self.create_sync_status_display()
|
||
info_layout.addWidget(self.sync_status_widget)
|
||
|
||
layout.addWidget(name_label)
|
||
layout.addLayout(info_layout)
|
||
|
||
return header
|
||
|
||
def create_sync_status_display(self):
|
||
"""Create sync status display widget (hidden by default)"""
|
||
sync_status = QFrame()
|
||
sync_status.setStyleSheet("""
|
||
QFrame {
|
||
background: rgba(29, 185, 84, 0.1);
|
||
border: 1px solid rgba(29, 185, 84, 0.3);
|
||
border-radius: 12px;
|
||
}
|
||
""")
|
||
sync_status.setMinimumHeight(36) # Ensure adequate height
|
||
sync_status.hide() # Hidden by default
|
||
|
||
layout = QHBoxLayout(sync_status)
|
||
layout.setContentsMargins(12, 8, 12, 8) # Increased margins for better text visibility
|
||
layout.setSpacing(12)
|
||
|
||
# Total tracks
|
||
self.total_tracks_label = QLabel("♪ 0")
|
||
self.total_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
self.total_tracks_label.setStyleSheet("color: #ffa500; background: transparent; border: none;")
|
||
|
||
# Matched tracks
|
||
self.matched_tracks_label = QLabel("✓ 0")
|
||
self.matched_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
self.matched_tracks_label.setStyleSheet("color: #1db954; background: transparent; border: none;")
|
||
|
||
# Failed tracks
|
||
self.failed_tracks_label = QLabel("✗ 0")
|
||
self.failed_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
self.failed_tracks_label.setStyleSheet("color: #e22134; background: transparent; border: none;")
|
||
|
||
# Percentage
|
||
self.percentage_label = QLabel("0%")
|
||
self.percentage_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Bold))
|
||
self.percentage_label.setStyleSheet("color: #1db954; background: transparent; border: none;")
|
||
|
||
layout.addWidget(self.total_tracks_label)
|
||
|
||
# Separator 1
|
||
sep1 = QLabel("/")
|
||
sep1.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
sep1.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(sep1)
|
||
|
||
layout.addWidget(self.matched_tracks_label)
|
||
|
||
# Separator 2
|
||
sep2 = QLabel("/")
|
||
sep2.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
sep2.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(sep2)
|
||
|
||
layout.addWidget(self.failed_tracks_label)
|
||
|
||
# Separator 3
|
||
sep3 = QLabel("/")
|
||
sep3.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
sep3.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(sep3)
|
||
|
||
layout.addWidget(self.percentage_label)
|
||
|
||
return sync_status
|
||
|
||
def update_sync_status(self, total_tracks=0, matched_tracks=0, failed_tracks=0):
|
||
"""Update sync status display"""
|
||
if self.sync_status_widget:
|
||
self.total_tracks_label.setText(f"♪ {total_tracks}")
|
||
self.matched_tracks_label.setText(f"✓ {matched_tracks}")
|
||
self.failed_tracks_label.setText(f"✗ {failed_tracks}")
|
||
|
||
if total_tracks > 0:
|
||
processed_tracks = matched_tracks + failed_tracks
|
||
percentage = int((processed_tracks / total_tracks) * 100)
|
||
self.percentage_label.setText(f"{percentage}%")
|
||
else:
|
||
self.percentage_label.setText("0%")
|
||
|
||
def set_sync_button_state(self, is_syncing):
|
||
"""Update sync button appearance based on sync state"""
|
||
if self.sync_button:
|
||
if is_syncing:
|
||
# Change to Cancel Sync with red styling
|
||
self.sync_button.setText("Cancel Sync")
|
||
self.sync_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: #e22134;
|
||
border: none;
|
||
border-radius: 22px;
|
||
color: #ffffff;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
font-family: 'SF Pro Text';
|
||
}
|
||
QPushButton:hover {
|
||
background: #f44336;
|
||
}
|
||
QPushButton:pressed {
|
||
background: #c62828;
|
||
}
|
||
""")
|
||
else:
|
||
# Change back to Sync This Playlist with green styling
|
||
self.sync_button.setText("Sync This Playlist")
|
||
self.sync_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 22px;
|
||
color: #ffffff;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
font-family: 'SF Pro Text';
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
QPushButton:pressed {
|
||
background: #169c46;
|
||
}
|
||
""")
|
||
|
||
def restore_sync_state(self):
|
||
"""Restore sync state when modal is reopened"""
|
||
# Check if sync is ongoing for this playlist
|
||
if self.parent_page and self.parent_page.is_playlist_syncing(self.playlist.id):
|
||
self.is_syncing = True
|
||
self.set_sync_button_state(True)
|
||
|
||
# Find playlist item to get current progress
|
||
playlist_item = self.parent_page.find_playlist_item_widget(self.playlist.id)
|
||
if playlist_item:
|
||
# Show sync status widget with current progress
|
||
if self.sync_status_widget:
|
||
self.sync_status_widget.show()
|
||
self.update_sync_status(
|
||
playlist_item.sync_total_tracks,
|
||
playlist_item.sync_matched_tracks,
|
||
playlist_item.sync_failed_tracks
|
||
)
|
||
|
||
def create_track_list(self):
|
||
container = QFrame()
|
||
container.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 16px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(container)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
|
||
# Track table with professional styling
|
||
self.track_table = QTableWidget()
|
||
self.track_table.setColumnCount(4)
|
||
self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Album", "Duration"])
|
||
|
||
# Set initial row count (may be 0 if tracks not loaded yet)
|
||
track_count = len(self.playlist.tracks) if self.playlist.tracks else 1
|
||
self.track_table.setRowCount(track_count)
|
||
|
||
# Professional table styling
|
||
self.track_table.setStyleSheet("""
|
||
QTableWidget {
|
||
background: #282828;
|
||
border: none;
|
||
border-radius: 16px;
|
||
gridline-color: transparent;
|
||
color: #ffffff;
|
||
font-size: 11px;
|
||
selection-background-color: rgba(29, 185, 84, 0.2);
|
||
}
|
||
QTableWidget::item {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||
background: transparent;
|
||
}
|
||
QTableWidget::item:hover {
|
||
background: rgba(255, 255, 255, 0.02);
|
||
}
|
||
QTableWidget::item:selected {
|
||
background: rgba(29, 185, 84, 0.15);
|
||
color: #ffffff;
|
||
}
|
||
QHeaderView {
|
||
background: transparent;
|
||
border: none;
|
||
}
|
||
QHeaderView::section {
|
||
background: transparent;
|
||
color: #b3b3b3;
|
||
padding: 12px 16px;
|
||
border: none;
|
||
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
|
||
font-weight: 600;
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
QHeaderView::section:hover {
|
||
background: rgba(255, 255, 255, 0.02);
|
||
}
|
||
""")
|
||
|
||
# Populate table with proper styling
|
||
if self.playlist.tracks:
|
||
for row, track in enumerate(self.playlist.tracks):
|
||
# Track name with ellipsis label
|
||
track_label = EllipsisLabel(track.name)
|
||
track_label.setFont(QFont("SF Pro Text", 11, QFont.Weight.Medium))
|
||
track_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
||
self.track_table.setCellWidget(row, 0, track_label)
|
||
|
||
# Artist(s) with ellipsis label
|
||
artists = ", ".join(track.artists)
|
||
artist_label = EllipsisLabel(artists)
|
||
artist_label.setFont(QFont("SF Pro Text", 11))
|
||
artist_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
||
self.track_table.setCellWidget(row, 1, artist_label)
|
||
|
||
# Album with ellipsis label
|
||
album_label = EllipsisLabel(track.album)
|
||
album_label.setFont(QFont("SF Pro Text", 11))
|
||
album_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
||
self.track_table.setCellWidget(row, 2, album_label)
|
||
|
||
# Duration with standard item (doesn't need scrolling)
|
||
duration = self.format_duration(track.duration_ms)
|
||
duration_item = QTableWidgetItem(duration)
|
||
duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
duration_item.setFont(QFont("SF Mono", 10))
|
||
self.track_table.setItem(row, 3, duration_item)
|
||
else:
|
||
# Show placeholder while tracks are being loaded
|
||
placeholder_item = QTableWidgetItem("Loading tracks...")
|
||
placeholder_item.setFlags(placeholder_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.track_table.setItem(0, 0, placeholder_item)
|
||
self.track_table.setSpan(0, 0, 1, 4)
|
||
|
||
# Professional column configuration
|
||
header = self.track_table.horizontalHeader()
|
||
header.setVisible(True)
|
||
header.show()
|
||
header.setStretchLastSection(False)
|
||
header.setHighlightSections(False)
|
||
header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||
|
||
# Calculate available width (modal is 1200px, account for margins)
|
||
available_width = 1136 # 1200 - 64px margins
|
||
|
||
# Professional proportional widths
|
||
track_width = int(available_width * 0.35) # ~398px
|
||
artist_width = int(available_width * 0.28) # ~318px
|
||
album_width = int(available_width * 0.28) # ~318px
|
||
duration_width = 100 # Fixed 100px
|
||
|
||
# Apply column widths with proper resize modes
|
||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)
|
||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive)
|
||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive)
|
||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
|
||
|
||
self.track_table.setColumnWidth(0, track_width)
|
||
self.track_table.setColumnWidth(1, artist_width)
|
||
self.track_table.setColumnWidth(2, album_width)
|
||
self.track_table.setColumnWidth(3, duration_width)
|
||
|
||
# Set minimum widths for professional look
|
||
header.setMinimumSectionSize(120)
|
||
|
||
# Hide row numbers and configure table behavior
|
||
self.track_table.verticalHeader().setVisible(False)
|
||
self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
self.track_table.setAlternatingRowColors(False)
|
||
|
||
# Set uniform row height to accommodate the labels properly
|
||
self.track_table.verticalHeader().setDefaultSectionSize(40) # Height for each row
|
||
|
||
layout.addWidget(self.track_table)
|
||
|
||
return container
|
||
|
||
def create_buttons(self):
|
||
button_layout = QHBoxLayout()
|
||
button_layout.setSpacing(16)
|
||
button_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Close button with subtle styling
|
||
close_btn = QPushButton("Close")
|
||
close_btn.setFixedSize(100, 44)
|
||
close_btn.clicked.connect(self.close)
|
||
close_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
border-radius: 22px;
|
||
color: #ffffff;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
font-family: 'SF Pro Text';
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-color: rgba(255, 255, 255, 0.15);
|
||
}
|
||
QPushButton:pressed {
|
||
background: rgba(255, 255, 255, 0.02);
|
||
}
|
||
""")
|
||
|
||
# Download missing tracks button with outline style
|
||
download_btn = QPushButton("Download Missing Tracks")
|
||
download_btn.setFixedSize(200, 44)
|
||
download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: transparent;
|
||
border: 1px solid #1db954;
|
||
border-radius: 22px;
|
||
color: #1db954;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
font-family: 'SF Pro Text';
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(29, 185, 84, 0.08);
|
||
border-color: #1ed760;
|
||
color: #1ed760;
|
||
}
|
||
QPushButton:pressed {
|
||
background: rgba(29, 185, 84, 0.15);
|
||
}
|
||
""")
|
||
|
||
# Sync button with primary styling (store reference for state management)
|
||
self.sync_button = QPushButton("Sync This Playlist")
|
||
self.sync_button.setFixedSize(160, 44)
|
||
self.sync_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 22px;
|
||
color: #ffffff;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
font-family: 'SF Pro Text';
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
QPushButton:pressed {
|
||
background: #169c46;
|
||
}
|
||
""")
|
||
|
||
# Connect button signals
|
||
download_btn.clicked.connect(self.on_download_missing_tracks_clicked)
|
||
self.sync_button.clicked.connect(self.on_sync_playlist_clicked)
|
||
|
||
button_layout.addStretch()
|
||
button_layout.addWidget(close_btn)
|
||
button_layout.addWidget(download_btn)
|
||
button_layout.addWidget(self.sync_button)
|
||
|
||
return button_layout
|
||
|
||
def format_duration(self, duration_ms):
|
||
"""Convert milliseconds to MM:SS format"""
|
||
seconds = duration_ms // 1000
|
||
minutes = seconds // 60
|
||
seconds = seconds % 60
|
||
return f"{minutes}:{seconds:02d}"
|
||
|
||
def on_download_missing_tracks_clicked(self):
|
||
"""Handle Download Missing Tracks button click"""
|
||
print("🔄 Download Missing Tracks button clicked!")
|
||
|
||
if not self.playlist or not self.playlist.tracks:
|
||
QMessageBox.warning(self, "Error", "Playlist tracks not loaded")
|
||
return
|
||
|
||
playlist_item_widget = self.parent_page.find_playlist_item_widget(self.playlist.id)
|
||
if not playlist_item_widget:
|
||
QMessageBox.critical(self, "Error", "Could not find the associated playlist item on the main page.")
|
||
return
|
||
|
||
print("🚀 Creating DownloadMissingTracksModal...")
|
||
modal = DownloadMissingTracksModal(self.playlist, playlist_item_widget, self.parent_page, self.parent_page.downloads_page)
|
||
|
||
playlist_item_widget.download_modal = modal
|
||
|
||
# --- FIX: Connect the cleanup signal immediately upon creation. ---
|
||
# This ensures that when the modal closes for any reason, the SyncPage
|
||
# is notified and can run its cleanup logic.
|
||
modal.process_finished.connect(
|
||
lambda: self.parent_page.on_download_process_finished(self.playlist.id)
|
||
)
|
||
|
||
self.accept()
|
||
modal.show()
|
||
|
||
def find_playlist_item_from_sync_modal(self):
|
||
"""Find the PlaylistItem widget for this playlist from sync modal"""
|
||
if not hasattr(self.parent_page, 'current_playlists'):
|
||
return None
|
||
|
||
# Look through the parent page's playlist items
|
||
for i in range(self.parent_page.playlist_layout.count()):
|
||
item = self.parent_page.playlist_layout.itemAt(i)
|
||
if item and item.widget() and isinstance(item.widget(), PlaylistItem):
|
||
playlist_item = item.widget()
|
||
if playlist_item.playlist and playlist_item.playlist.id == self.playlist.id:
|
||
return playlist_item
|
||
return None
|
||
|
||
def on_sync_playlist_clicked(self):
|
||
"""Handle Sync This Playlist button click"""
|
||
if self.is_syncing:
|
||
# Cancel sync
|
||
self.cancel_sync()
|
||
return
|
||
|
||
if not self.playlist:
|
||
QMessageBox.warning(self, "Error", "No playlist selected")
|
||
return
|
||
|
||
if not self.playlist.tracks:
|
||
QMessageBox.warning(self, "Error", "Playlist tracks not loaded")
|
||
return
|
||
|
||
# Check if sync service is available
|
||
if not hasattr(self.parent_page, 'sync_service'):
|
||
# Create sync service if not available
|
||
from services.sync_service import PlaylistSyncService
|
||
self.parent_page.sync_service = PlaylistSyncService(
|
||
self.parent_page.spotify_client,
|
||
self.parent_page.plex_client,
|
||
self.parent_page.soulseek_client,
|
||
getattr(self.parent_page, 'jellyfin_client', None),
|
||
getattr(self.parent_page, 'navidrome_client', None)
|
||
)
|
||
|
||
# Start sync
|
||
self.start_sync()
|
||
|
||
def start_sync(self):
|
||
"""Start playlist sync operation via parent page"""
|
||
if self.parent_page and self.parent_page.start_playlist_sync(self.playlist):
|
||
self.is_syncing = True
|
||
|
||
# Update Tidal card state to syncing (matches YouTube workflow)
|
||
if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'):
|
||
print(f"🎵 Updating Tidal card state to syncing for playlist_id: {self.playlist_id}")
|
||
if hasattr(self.parent_page, 'update_tidal_card_phase'):
|
||
self.parent_page.update_tidal_card_phase(self.playlist_id, 'syncing')
|
||
|
||
# Update YouTube card state to syncing (existing logic)
|
||
if hasattr(self, 'youtube_url'):
|
||
print(f"🎬 Updating YouTube card state to syncing for URL: {self.youtube_url}")
|
||
if hasattr(self.parent_page, 'update_youtube_card_phase'):
|
||
self.parent_page.update_youtube_card_phase(self.youtube_url, 'syncing')
|
||
|
||
# Update modal UI state
|
||
self.set_sync_button_state(True)
|
||
|
||
# Show sync status widget
|
||
if self.sync_status_widget:
|
||
self.sync_status_widget.show()
|
||
self.update_sync_status(len(self.playlist.tracks), 0, 0)
|
||
|
||
def cancel_sync(self):
|
||
"""Cancel ongoing sync operation via parent page"""
|
||
if self.parent_page and self.parent_page.cancel_playlist_sync(self.playlist.id):
|
||
self.is_syncing = False
|
||
|
||
# Update modal UI state
|
||
self.set_sync_button_state(False)
|
||
|
||
# Hide sync status widget
|
||
if self.sync_status_widget:
|
||
self.sync_status_widget.hide()
|
||
|
||
def on_sync_progress(self, playlist_id, progress):
|
||
"""Handle sync progress updates (called from parent page)"""
|
||
if playlist_id == self.playlist.id:
|
||
# Update modal status display
|
||
self.update_sync_status(
|
||
progress.total_tracks,
|
||
progress.matched_tracks,
|
||
progress.failed_tracks
|
||
)
|
||
|
||
def on_sync_finished(self, playlist_id, result):
|
||
"""Handle sync completion (called from parent page)"""
|
||
if playlist_id == self.playlist.id:
|
||
self.is_syncing = False
|
||
|
||
# Update button state
|
||
self.set_sync_button_state(False)
|
||
|
||
# Update final status
|
||
self.update_sync_status(
|
||
result.total_tracks,
|
||
result.matched_tracks,
|
||
result.failed_tracks
|
||
)
|
||
|
||
def on_sync_error(self, playlist_id, error_msg):
|
||
"""Handle sync error (called from parent page)"""
|
||
if playlist_id == self.playlist.id:
|
||
self.is_syncing = False
|
||
|
||
# Update button state
|
||
self.set_sync_button_state(False)
|
||
|
||
# Hide sync status widget
|
||
if self.sync_status_widget:
|
||
self.sync_status_widget.hide()
|
||
|
||
# Show error message
|
||
QMessageBox.critical(self, "Sync Failed", f"Sync failed: {error_msg}")
|
||
|
||
def start_playlist_missing_tracks_download(self):
|
||
"""Start the process of downloading missing tracks from playlist"""
|
||
track_count = len(self.playlist.tracks)
|
||
|
||
# Start analysis worker
|
||
self.start_track_analysis()
|
||
|
||
# Show analysis started message
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
server_name = active_server.title()
|
||
QMessageBox.information(self, "Analysis Started",
|
||
f"Starting analysis of {track_count} tracks.\nChecking {server_name} library for existing tracks...")
|
||
|
||
def start_track_analysis(self):
|
||
"""Start background track analysis against media library"""
|
||
# Create analysis worker
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
|
||
if active_server == "plex":
|
||
media_client = getattr(self.parent_page, 'plex_client', None)
|
||
else: # jellyfin
|
||
media_client = getattr(self.parent_page, 'jellyfin_client', None)
|
||
|
||
worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, media_client, active_server)
|
||
|
||
# Connect signals
|
||
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)
|
||
|
||
# Track worker for cleanup
|
||
self.active_workers.append(worker)
|
||
|
||
# Submit to thread pool
|
||
if hasattr(self.parent_page, 'thread_pool'):
|
||
self.parent_page.thread_pool.start(worker)
|
||
else:
|
||
# Create and track fallback thread pool
|
||
thread_pool = QThreadPool()
|
||
self.fallback_pools.append(thread_pool)
|
||
thread_pool.start(worker)
|
||
|
||
def on_analysis_started(self, total_tracks):
|
||
"""Handle analysis started signal"""
|
||
# Get server name for log message
|
||
try:
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
server_name = active_server.title() if active_server else "Plex"
|
||
except:
|
||
server_name = "Plex"
|
||
|
||
print(f"Started analyzing {total_tracks} tracks against {server_name} library")
|
||
|
||
def on_track_analyzed(self, track_index, result):
|
||
"""Handle individual track analysis completion"""
|
||
track = result.spotify_track
|
||
if result.exists_in_plex:
|
||
print(f"Track {track_index}: '{track.name}' by {track.artists[0]} EXISTS in Plex (confidence: {result.confidence:.2f})")
|
||
else:
|
||
print(f"Track {track_index}: '{track.name}' by {track.artists[0]} MISSING from Plex - will download")
|
||
|
||
def on_analysis_completed(self, results):
|
||
"""Handle analysis completion and start downloads for missing tracks"""
|
||
missing_tracks = [r for r in results if not r.exists_in_plex]
|
||
existing_tracks = [r for r in results if r.exists_in_plex]
|
||
try:
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
server_name = active_server.title() if active_server else "Plex"
|
||
except:
|
||
server_name = "Plex"
|
||
print(f"Analysis complete: {len(missing_tracks)} missing, {len(existing_tracks)} existing")
|
||
|
||
if not missing_tracks:
|
||
QMessageBox.information(self, "Analysis Complete",
|
||
f"All tracks already exist in {server_name} library!\nNo downloads needed.")
|
||
return
|
||
|
||
# Show results to user
|
||
message = f"Analysis complete!\n\n"
|
||
# Get server name for display
|
||
try:
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
server_name = active_server.title() if active_server else "Plex"
|
||
except:
|
||
server_name = "Plex"
|
||
|
||
message += f"Tracks already in {server_name}: {len(existing_tracks)}\n"
|
||
message += f"Tracks to download: {len(missing_tracks)}\n\n"
|
||
message += "Ready to start downloading missing tracks?"
|
||
|
||
reply = QMessageBox.question(self, "Start Downloads?", message,
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
self.start_missing_track_downloads(missing_tracks)
|
||
|
||
def on_analysis_failed(self, error_message):
|
||
"""Handle analysis failure"""
|
||
QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}")
|
||
|
||
def start_missing_track_downloads(self, missing_tracks):
|
||
"""Start downloading the missing tracks"""
|
||
# TODO: Implement Soulseek search and download queueing
|
||
# For now, just show what would be downloaded
|
||
track_list = []
|
||
for result in missing_tracks:
|
||
track = result.spotify_track
|
||
artist = track.artists[0] if track.artists else "Unknown Artist"
|
||
track_list.append(f"• {track.name} by {artist}")
|
||
|
||
message = f"Would download {len(missing_tracks)} tracks:\n\n"
|
||
message += "\n".join(track_list[:10]) # Show first 10
|
||
if len(track_list) > 10:
|
||
message += f"\n... and {len(track_list) - 10} more"
|
||
|
||
QMessageBox.information(self, "Downloads Queued", message)
|
||
|
||
def load_tracks_async(self):
|
||
"""Load tracks asynchronously using worker thread"""
|
||
if not self.spotify_client:
|
||
return
|
||
|
||
# Show loading state in track table
|
||
if hasattr(self, 'track_table'):
|
||
self.track_table.setRowCount(1)
|
||
loading_item = QTableWidgetItem("Loading tracks...")
|
||
loading_item.setFlags(loading_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.track_table.setItem(0, 0, loading_item)
|
||
self.track_table.setSpan(0, 0, 1, 4)
|
||
|
||
# Create and submit worker to thread pool
|
||
worker = TrackLoadingWorker(self.spotify_client, self.playlist.id, self.playlist.name)
|
||
worker.signals.tracks_loaded.connect(self.on_tracks_loaded)
|
||
worker.signals.loading_failed.connect(self.on_tracks_loading_failed)
|
||
|
||
# Track active worker for cleanup
|
||
self.active_workers.append(worker)
|
||
|
||
# Submit to parent's thread pool if available, otherwise create one
|
||
if hasattr(self.parent_page, 'thread_pool'):
|
||
self.parent_page.thread_pool.start(worker)
|
||
else:
|
||
# Create and track fallback thread pool
|
||
thread_pool = QThreadPool()
|
||
self.fallback_pools.append(thread_pool)
|
||
thread_pool.start(worker)
|
||
|
||
def on_tracks_loaded(self, playlist_id, tracks):
|
||
"""Handle successful track loading"""
|
||
logger.info(f"Tracks loaded signal received: playlist_id={playlist_id}, tracks_count={len(tracks) if tracks else 0}")
|
||
|
||
# Log validation state
|
||
playlist_match = playlist_id == self.playlist.id
|
||
not_closing = not self.is_closing
|
||
not_hidden = not self.isHidden()
|
||
has_table = hasattr(self, 'track_table')
|
||
|
||
logger.info(f"Validation state: playlist_match={playlist_match}, not_closing={not_closing}, not_hidden={not_hidden}, has_table={has_table}")
|
||
|
||
# Validate modal state before processing
|
||
if (playlist_match and not_closing and not_hidden and has_table):
|
||
logger.info(f"Processing tracks for playlist {self.playlist.name}")
|
||
|
||
self.playlist.tracks = tracks
|
||
|
||
# Cache tracks in parent for future use
|
||
if hasattr(self.parent_page, 'track_cache'):
|
||
self.parent_page.track_cache[playlist_id] = tracks
|
||
logger.info(f"Cached {len(tracks)} tracks for playlist {playlist_id}")
|
||
|
||
# Refresh the track table
|
||
try:
|
||
self.refresh_track_table()
|
||
logger.info(f"Successfully refreshed track table with {len(tracks)} tracks")
|
||
except Exception as e:
|
||
logger.error(f"Error refreshing track table: {e}")
|
||
else:
|
||
logger.warning(f"Skipping track loading due to validation failure for playlist {playlist_id}")
|
||
|
||
def on_tracks_loading_failed(self, playlist_id, error_message):
|
||
"""Handle track loading failure"""
|
||
logger.error(f"Track loading failed for playlist {playlist_id}: {error_message}")
|
||
|
||
# Validate modal state before processing
|
||
if (playlist_id == self.playlist.id and
|
||
not self.is_closing and
|
||
not self.isHidden() and
|
||
hasattr(self, 'track_table')):
|
||
logger.info(f"Displaying error message in track table")
|
||
self.track_table.setRowCount(1)
|
||
error_item = QTableWidgetItem(f"Error loading tracks: {error_message}")
|
||
error_item.setFlags(error_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.track_table.setItem(0, 0, error_item)
|
||
self.track_table.setSpan(0, 0, 1, 4)
|
||
else:
|
||
logger.warning(f"Cannot display error message due to modal state validation failure")
|
||
|
||
def refresh_track_table(self):
|
||
"""Refresh the track table with loaded tracks"""
|
||
logger.info(f"refresh_track_table called for playlist {self.playlist.name}")
|
||
|
||
if not hasattr(self, 'track_table'):
|
||
logger.error("No track_table attribute found")
|
||
return
|
||
|
||
# Limit tracks to prevent UI blocking on large playlists
|
||
total_tracks = len(self.playlist.tracks)
|
||
display_limit = 100
|
||
tracks_to_show = self.playlist.tracks[:display_limit]
|
||
|
||
logger.info(f"Setting track table row count to {len(tracks_to_show)} (total tracks: {total_tracks})")
|
||
|
||
self.track_table.setRowCount(len(tracks_to_show))
|
||
self.track_table.clearSpans() # Remove any spans from loading state
|
||
|
||
# Populate table with limited tracks
|
||
logger.info(f"Populating track table with {len(tracks_to_show)} tracks")
|
||
for row, track in enumerate(tracks_to_show):
|
||
try:
|
||
logger.debug(f"Processing track {row+1}/{len(tracks_to_show)}: {track.name} by {', '.join(track.artists) if track.artists else 'Unknown'}")
|
||
|
||
# Track name with ellipsis label
|
||
track_label = EllipsisLabel(track.name)
|
||
track_label.setFont(QFont("SF Pro Text", 11, QFont.Weight.Medium))
|
||
track_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
||
self.track_table.setCellWidget(row, 0, track_label)
|
||
logger.debug(f"Set track name widget for row {row}")
|
||
|
||
# Artist(s) with ellipsis label
|
||
artists = ", ".join(track.artists) if track.artists else "Unknown Artist"
|
||
artist_label = EllipsisLabel(artists)
|
||
artist_label.setFont(QFont("SF Pro Text", 11))
|
||
artist_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
||
self.track_table.setCellWidget(row, 1, artist_label)
|
||
logger.debug(f"Set artist widget for row {row}")
|
||
|
||
# Album with ellipsis label
|
||
album_name = track.album if track.album else "Unknown Album"
|
||
album_label = EllipsisLabel(album_name)
|
||
album_label.setFont(QFont("SF Pro Text", 11))
|
||
album_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
||
self.track_table.setCellWidget(row, 2, album_label)
|
||
logger.debug(f"Set album widget for row {row}")
|
||
|
||
# Duration with standard item (doesn't need scrolling)
|
||
duration = self.format_duration(track.duration_ms)
|
||
duration_item = QTableWidgetItem(duration)
|
||
duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
duration_item.setFont(QFont("SF Mono", 10))
|
||
self.track_table.setItem(row, 3, duration_item)
|
||
logger.debug(f"Set duration item for row {row}")
|
||
|
||
logger.debug(f"Completed track {row+1}/{len(tracks_to_show)}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error processing track {row+1}: {e}")
|
||
logger.error(f"Track data: name='{track.name if hasattr(track, 'name') else 'N/A'}', artists='{track.artists if hasattr(track, 'artists') else 'N/A'}', album='{track.album if hasattr(track, 'album') else 'N/A'}'")
|
||
# Continue with next track rather than failing completely
|
||
continue
|
||
|
||
logger.info(f"Finished populating all {len(tracks_to_show)} tracks")
|
||
|
||
# Add info message if tracks were limited
|
||
if total_tracks > display_limit:
|
||
# Update the modal title to show track count info
|
||
if hasattr(self, 'setWindowTitle'):
|
||
original_title = f"Playlist Details - {self.playlist.name}"
|
||
self.setWindowTitle(f"{original_title} (Showing {display_limit} of {total_tracks:,} tracks)")
|
||
|
||
# Also show a subtle message at the bottom of the table
|
||
print(f"📊 Playlist Details: Showing first {display_limit} of {total_tracks:,} tracks for better performance")
|
||
|
||
class PlaylistItem(QFrame):
|
||
view_details_clicked = pyqtSignal(object) # Signal to emit playlist object
|
||
|
||
def __init__(self, name: str, track_count: int, sync_status: str, playlist=None, parent=None):
|
||
super().__init__(parent)
|
||
self.name = name
|
||
self.track_count = track_count
|
||
self.sync_status = sync_status
|
||
self.playlist = playlist
|
||
self.is_selected = False
|
||
self.download_modal = None
|
||
|
||
# Sync state tracking
|
||
self.is_syncing = False
|
||
self.sync_total_tracks = 0
|
||
self.sync_matched_tracks = 0
|
||
self.sync_failed_tracks = 0
|
||
self.sync_status_widget = None
|
||
|
||
# Selection state tracking
|
||
self._pending_click = False
|
||
|
||
self.setup_ui()
|
||
|
||
def on_checkbox_clicked(self):
|
||
"""Handle direct checkbox click - use same debounced logic"""
|
||
print(f"Direct checkbox click for {self.name}")
|
||
self.toggle_selection()
|
||
|
||
def update_selection_style(self):
|
||
"""Update visual style based on selection state"""
|
||
if self.is_selected:
|
||
self.setStyleSheet("""
|
||
PlaylistItem {
|
||
background: rgba(29, 185, 84, 0.1);
|
||
border-radius: 8px;
|
||
border: 2px solid #1db954;
|
||
}
|
||
PlaylistItem:hover {
|
||
background: rgba(29, 185, 84, 0.15);
|
||
border: 2px solid #1ed760;
|
||
}
|
||
""")
|
||
else:
|
||
self.setStyleSheet("""
|
||
PlaylistItem {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
PlaylistItem:hover {
|
||
background: #333333;
|
||
border: 1px solid #1db954;
|
||
}
|
||
""")
|
||
|
||
def setup_ui(self):
|
||
self.setFixedHeight(80)
|
||
self.setStyleSheet("""
|
||
PlaylistItem {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
PlaylistItem:hover {
|
||
background: #333333;
|
||
border: 1px solid #1db954;
|
||
}
|
||
""")
|
||
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(20, 15, 20, 15)
|
||
layout.setSpacing(15)
|
||
|
||
self.checkbox = QCheckBox()
|
||
self.checkbox.clicked.connect(self.on_checkbox_clicked)
|
||
self.checkbox.setStyleSheet("""
|
||
QCheckBox::indicator {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 9px;
|
||
border: 2px solid #b3b3b3;
|
||
background: transparent;
|
||
}
|
||
QCheckBox::indicator:checked {
|
||
background: #1db954;
|
||
border: 2px solid #1db954;
|
||
}
|
||
QCheckBox::indicator:checked:hover {
|
||
background: #1ed760;
|
||
}
|
||
""")
|
||
|
||
content_layout = QVBoxLayout()
|
||
content_layout.setSpacing(5)
|
||
|
||
name_label = QLabel(self.name)
|
||
name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||
name_label.setStyleSheet("color: #ffffff;")
|
||
|
||
info_layout = QHBoxLayout()
|
||
info_layout.setSpacing(20)
|
||
|
||
track_label = QLabel(f"{self.track_count} tracks")
|
||
track_label.setFont(QFont("Arial", 10))
|
||
track_label.setStyleSheet("color: #b3b3b3;")
|
||
|
||
# **FIX**: Renamed this to `sync_status_label` to avoid conflicts
|
||
self.sync_status_label = QLabel(self.sync_status)
|
||
self.sync_status_label.setFont(QFont("Arial", 10))
|
||
if "Synced" in self.sync_status:
|
||
self.sync_status_label.setStyleSheet("color: #1db954;")
|
||
elif self.sync_status == "Needs Sync":
|
||
self.sync_status_label.setStyleSheet("color: #ffa500;")
|
||
else:
|
||
self.sync_status_label.setStyleSheet("color: #e22134;")
|
||
|
||
info_layout.addWidget(track_label)
|
||
info_layout.addWidget(self.sync_status_label)
|
||
info_layout.addStretch()
|
||
|
||
content_layout.addWidget(name_label)
|
||
content_layout.addLayout(info_layout)
|
||
|
||
self.action_btn = QPushButton("Sync / Download")
|
||
self.action_btn.setFixedSize(120, 30)
|
||
self.action_btn.clicked.connect(self.on_action_clicked)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: transparent;
|
||
border: 1px solid #1db954;
|
||
border-radius: 15px;
|
||
color: #1db954;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1db954;
|
||
color: #000000;
|
||
}
|
||
""")
|
||
|
||
# **FIX**: Renamed this to `operation_status_button` to avoid conflicts
|
||
self.operation_status_button = QPushButton()
|
||
self.operation_status_button.setFixedSize(120, 30)
|
||
self.operation_status_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: 1px solid #169441;
|
||
border-radius: 15px;
|
||
color: #000000;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
padding: 5px;
|
||
text-align: center;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
|
||
}
|
||
""")
|
||
self.operation_status_button.clicked.connect(self.on_status_clicked)
|
||
self.operation_status_button.hide()
|
||
|
||
self.download_modal = None
|
||
self.sync_status_widget = self.create_compact_sync_status()
|
||
|
||
layout.addWidget(self.checkbox)
|
||
layout.addLayout(content_layout)
|
||
layout.addStretch()
|
||
layout.addWidget(self.sync_status_widget)
|
||
layout.addWidget(self.action_btn)
|
||
layout.addWidget(self.operation_status_button)
|
||
|
||
self.installEventFilter(self)
|
||
for child in self.findChildren(QWidget):
|
||
if child != self.action_btn and child != self.operation_status_button:
|
||
child.installEventFilter(self)
|
||
|
||
def eventFilter(self, source, event):
|
||
"""Filter events to handle clicks anywhere on the item"""
|
||
if event.type() == event.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
|
||
# **FIX**: Updated to check for the correctly named button
|
||
if source == self.action_btn or source == self.operation_status_button:
|
||
return False
|
||
|
||
print(f"Event filter caught click on {source} in playlist {self.name}")
|
||
self.toggle_selection()
|
||
return True
|
||
|
||
return super().eventFilter(source, event)
|
||
|
||
def toggle_selection(self):
|
||
"""Toggle the selection state of this playlist item immediately"""
|
||
if self._pending_click:
|
||
return
|
||
|
||
self._pending_click = True
|
||
|
||
sync_page = self
|
||
while sync_page and not isinstance(sync_page, SyncPage):
|
||
sync_page = sync_page.parent()
|
||
|
||
if sync_page and self.playlist and self.playlist.id:
|
||
currently_selected = self.playlist.id in sync_page.selected_playlists
|
||
sync_page.toggle_playlist_selection(self.playlist.id)
|
||
new_state = self.playlist.id in sync_page.selected_playlists
|
||
self.is_selected = new_state
|
||
|
||
self.checkbox.blockSignals(True)
|
||
self.checkbox.setChecked(new_state)
|
||
self.checkbox.blockSignals(False)
|
||
|
||
self.update_selection_style()
|
||
print(f"Processed click for {self.name}: {currently_selected} -> {new_state}")
|
||
else:
|
||
print(f"Could not process click for {self.name} - missing sync page or playlist ID")
|
||
|
||
QTimer.singleShot(25, lambda: setattr(self, '_pending_click', False))
|
||
|
||
def mousePressEvent(self, event):
|
||
"""Handle direct clicks on the playlist item background"""
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
print(f"Direct click on playlist item: {self.name}")
|
||
self.toggle_selection()
|
||
super().mousePressEvent(event)
|
||
|
||
def sync_selection_state(self):
|
||
"""Synchronize selection state with parent SyncPage (call when needed)"""
|
||
sync_page = self
|
||
while sync_page and not isinstance(sync_page, SyncPage):
|
||
sync_page = sync_page.parent()
|
||
|
||
if sync_page and self.playlist and self.playlist.id:
|
||
actual_selected = self.playlist.id in sync_page.selected_playlists
|
||
|
||
if self.is_selected != actual_selected:
|
||
print(f"Syncing state for {self.name}: {self.is_selected} -> {actual_selected}")
|
||
self.is_selected = actual_selected
|
||
|
||
self.checkbox.blockSignals(True)
|
||
self.checkbox.setChecked(actual_selected)
|
||
self.checkbox.blockSignals(False)
|
||
|
||
self.update_selection_style()
|
||
|
||
def create_compact_sync_status(self):
|
||
"""Create compact sync status display for playlist item"""
|
||
sync_status = QFrame()
|
||
sync_status.setFixedHeight(36)
|
||
sync_status.setStyleSheet("""
|
||
QFrame {
|
||
background: rgba(29, 185, 84, 0.1);
|
||
border: 1px solid rgba(29, 185, 84, 0.3);
|
||
border-radius: 15px;
|
||
}
|
||
""")
|
||
sync_status.hide()
|
||
|
||
layout = QHBoxLayout(sync_status)
|
||
layout.setContentsMargins(8, 6, 8, 6)
|
||
layout.setSpacing(6)
|
||
|
||
self.item_total_tracks_label = QLabel("♪ 0")
|
||
self.item_total_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
self.item_total_tracks_label.setStyleSheet("color: #ffa500; background: transparent; border: none;")
|
||
|
||
self.item_matched_tracks_label = QLabel("✓ 0")
|
||
self.item_matched_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
self.item_matched_tracks_label.setStyleSheet("color: #1db954; background: transparent; border: none;")
|
||
|
||
self.item_failed_tracks_label = QLabel("✗ 0")
|
||
self.item_failed_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
self.item_failed_tracks_label.setStyleSheet("color: #e22134; background: transparent; border: none;")
|
||
|
||
self.item_percentage_label = QLabel("0%")
|
||
self.item_percentage_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Bold))
|
||
self.item_percentage_label.setStyleSheet("color: #1db954; background: transparent; border: none;")
|
||
|
||
layout.addWidget(self.item_total_tracks_label)
|
||
|
||
item_sep1 = QLabel("/")
|
||
item_sep1.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
item_sep1.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(item_sep1)
|
||
|
||
layout.addWidget(self.item_matched_tracks_label)
|
||
|
||
item_sep2 = QLabel("/")
|
||
item_sep2.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
item_sep2.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(item_sep2)
|
||
|
||
layout.addWidget(self.item_failed_tracks_label)
|
||
|
||
item_sep3 = QLabel("/")
|
||
item_sep3.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
item_sep3.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(item_sep3)
|
||
|
||
layout.addWidget(self.item_percentage_label)
|
||
|
||
return sync_status
|
||
|
||
def update_sync_status(self, total_tracks=0, matched_tracks=0, failed_tracks=0):
|
||
"""Update sync status display for playlist item"""
|
||
self.sync_total_tracks = total_tracks
|
||
self.sync_matched_tracks = matched_tracks
|
||
self.sync_failed_tracks = failed_tracks
|
||
|
||
if self.sync_status_widget and hasattr(self, 'item_total_tracks_label'):
|
||
self.item_total_tracks_label.setText(f"♪ {total_tracks}")
|
||
self.item_matched_tracks_label.setText(f"✓ {matched_tracks}")
|
||
self.item_failed_tracks_label.setText(f"✗ {failed_tracks}")
|
||
|
||
if total_tracks > 0:
|
||
processed_tracks = matched_tracks + failed_tracks
|
||
percentage = int((processed_tracks / total_tracks) * 100)
|
||
self.item_percentage_label.setText(f"{percentage}%")
|
||
else:
|
||
self.item_percentage_label.setText("0%")
|
||
|
||
if total_tracks > 0 or self.is_syncing:
|
||
self.sync_status_widget.show()
|
||
else:
|
||
self.sync_status_widget.hide()
|
||
|
||
def show_operation_status(self, status_text="View Progress"):
|
||
"""Changes the button to show an operation is in progress."""
|
||
# **FIX**: Updated to use the correctly named button
|
||
self.operation_status_button.setText(status_text)
|
||
self.operation_status_button.show()
|
||
self.action_btn.hide()
|
||
|
||
def hide_operation_status(self):
|
||
"""Resets the button to its default state."""
|
||
# **FIX**: Updated to use the correctly named button
|
||
self.operation_status_button.hide()
|
||
self.action_btn.show()
|
||
|
||
def on_action_clicked(self):
|
||
"""If a download is in progress, show the modal. Otherwise, open details."""
|
||
if self.download_modal:
|
||
self.download_modal.show()
|
||
self.download_modal.activateWindow()
|
||
else:
|
||
self.view_details_clicked.emit(self.playlist)
|
||
|
||
def update_operation_status(self, status_text):
|
||
"""Update the operation status text"""
|
||
# **FIX**: Updated to use the correctly named button
|
||
self.operation_status_button.setText(status_text)
|
||
|
||
def set_download_modal(self, modal):
|
||
"""Store reference to the download modal"""
|
||
self.download_modal = modal
|
||
|
||
def update_sync_status_text(self, new_status):
|
||
"""Update the sync status text and style the label accordingly"""
|
||
self.sync_status = new_status
|
||
if hasattr(self, 'sync_status_label'):
|
||
self.sync_status_label.setText(new_status)
|
||
|
||
# Update color based on status
|
||
if "Synced" in new_status:
|
||
self.sync_status_label.setStyleSheet("color: #1db954;")
|
||
elif new_status == "Needs Sync":
|
||
self.sync_status_label.setStyleSheet("color: #ffa500;")
|
||
else:
|
||
self.sync_status_label.setStyleSheet("color: #e22134;")
|
||
|
||
def on_status_clicked(self):
|
||
"""Handle status button click - reopen modal"""
|
||
if self.download_modal and not self.download_modal.isVisible():
|
||
self.download_modal.show()
|
||
self.download_modal.activateWindow()
|
||
self.download_modal.raise_()
|
||
|
||
class TidalPlaylistCard(QFrame):
|
||
"""Tidal playlist card with persistent state tracking across all phases (matches YouTube workflow)"""
|
||
card_clicked = pyqtSignal(str, str) # Signal: (playlist_id, phase)
|
||
|
||
def __init__(self, playlist_id: str, playlist_name: str = "Loading...", track_count: int = 0, parent=None):
|
||
super().__init__(parent)
|
||
self.playlist_id = playlist_id
|
||
self.playlist_name = playlist_name
|
||
self.track_count = track_count
|
||
self.phase = "discovering" # discovering, discovery_complete, syncing, sync_complete, downloading, download_complete
|
||
self.progress_data = {'total': 0, 'matched': 0, 'failed': 0}
|
||
|
||
# Modal references
|
||
self.discovery_modal = None
|
||
self.download_modal = None
|
||
|
||
# State data
|
||
self.playlist_data = None
|
||
self.discovered_tracks = []
|
||
|
||
self.setup_ui()
|
||
self.update_display()
|
||
|
||
def setup_ui(self):
|
||
self.setFixedHeight(80)
|
||
self.setStyleSheet("""
|
||
TidalPlaylistCard {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
TidalPlaylistCard:hover {
|
||
background: #333333;
|
||
border: 1px solid #ff6600;
|
||
}
|
||
""")
|
||
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(20, 15, 20, 15)
|
||
layout.setSpacing(15)
|
||
|
||
# Tidal icon indicator
|
||
tidal_icon = QLabel("🎵")
|
||
tidal_icon.setFixedSize(24, 24)
|
||
tidal_icon.setStyleSheet("""
|
||
QLabel {
|
||
color: #ff6600;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
background: transparent;
|
||
text-align: center;
|
||
border-radius: 12px;
|
||
border: 1px solid #ff6600;
|
||
}
|
||
""")
|
||
tidal_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
# Content layout
|
||
content_layout = QVBoxLayout()
|
||
content_layout.setSpacing(4)
|
||
|
||
# Playlist name
|
||
self.name_label = EllipsisLabel(self.playlist_name)
|
||
self.name_label.setStyleSheet("""
|
||
QLabel {
|
||
color: #ffffff;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
background: transparent;
|
||
}
|
||
""")
|
||
|
||
# Info row (track count + phase)
|
||
info_layout = QHBoxLayout()
|
||
info_layout.setSpacing(8)
|
||
info_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Track count
|
||
self.track_label = QLabel(f"{self.track_count} tracks")
|
||
self.track_label.setStyleSheet("""
|
||
QLabel {
|
||
color: #b3b3b3;
|
||
font-size: 11px;
|
||
background: transparent;
|
||
}
|
||
""")
|
||
|
||
# Phase indicator
|
||
self.phase_label = QLabel(self.get_phase_text())
|
||
self.phase_label.setStyleSheet("""
|
||
QLabel {
|
||
color: #ff6600;
|
||
font-size: 11px;
|
||
background: transparent;
|
||
font-weight: bold;
|
||
}
|
||
""")
|
||
|
||
info_layout.addWidget(self.track_label)
|
||
info_layout.addWidget(self.phase_label)
|
||
info_layout.addStretch()
|
||
|
||
content_layout.addWidget(self.name_label)
|
||
content_layout.addLayout(info_layout)
|
||
|
||
# Progress widget (hidden by default, shown during syncing/downloading)
|
||
self.progress_widget = self.create_progress_display()
|
||
self.progress_widget.hide()
|
||
|
||
# Action button
|
||
self.action_btn = QPushButton("Discover Matches")
|
||
self.action_btn.setFixedSize(120, 30)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #ff6600;
|
||
border: none;
|
||
border-radius: 15px;
|
||
color: #ffffff;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #ff7700;
|
||
}
|
||
QPushButton:pressed {
|
||
background: #e55500;
|
||
}
|
||
""")
|
||
self.action_btn.clicked.connect(self.on_action_clicked)
|
||
|
||
layout.addWidget(tidal_icon)
|
||
layout.addLayout(content_layout)
|
||
layout.addWidget(self.progress_widget)
|
||
layout.addStretch()
|
||
layout.addWidget(self.action_btn)
|
||
|
||
def create_progress_display(self):
|
||
"""Create sync status display widget like YouTubePlaylistCard"""
|
||
sync_status = QFrame()
|
||
sync_status.setFixedHeight(30)
|
||
sync_status.setStyleSheet("""
|
||
QFrame {
|
||
background: rgba(0, 0, 0, 0.3);
|
||
border-radius: 15px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(sync_status)
|
||
layout.setContentsMargins(12, 6, 12, 6)
|
||
layout.setSpacing(8)
|
||
|
||
# Create labels for progress display
|
||
self.total_tracks_label = QLabel(f"♪ {self.progress_data['total']}")
|
||
self.total_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
self.total_tracks_label.setStyleSheet("color: #b3b3b3; background: transparent;")
|
||
|
||
self.matched_tracks_label = QLabel(f"✓ {self.progress_data['matched']}")
|
||
self.matched_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
self.matched_tracks_label.setStyleSheet("color: #1db954; background: transparent;")
|
||
|
||
self.failed_tracks_label = QLabel(f"✗ {self.progress_data['failed']}")
|
||
self.failed_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
self.failed_tracks_label.setStyleSheet("color: #e22134; background: transparent;")
|
||
|
||
layout.addWidget(self.total_tracks_label)
|
||
layout.addWidget(self.matched_tracks_label)
|
||
layout.addWidget(self.failed_tracks_label)
|
||
layout.addStretch()
|
||
|
||
return sync_status
|
||
|
||
def get_phase_text(self):
|
||
"""Get display text for current phase"""
|
||
phase_texts = {
|
||
"discovering": "Ready to discover",
|
||
"discovery_complete": "Discovery complete",
|
||
"syncing": "Finding matches...",
|
||
"sync_complete": "Sync complete",
|
||
"downloading": "Downloading...",
|
||
"download_complete": "Download complete"
|
||
}
|
||
return phase_texts.get(self.phase, self.phase)
|
||
|
||
def get_action_text(self):
|
||
"""Get text for action button based on current phase"""
|
||
action_texts = {
|
||
"discovering": "Discover Matches",
|
||
"discovery_complete": "View Results",
|
||
"syncing": "View Progress",
|
||
"sync_complete": "Download Missing",
|
||
"downloading": "View Downloads",
|
||
"download_complete": "View Downloads"
|
||
}
|
||
return action_texts.get(self.phase, "Discover Matches")
|
||
|
||
def update_phase_style(self):
|
||
"""Update styling based on current phase"""
|
||
if self.phase in ['discovering']:
|
||
self.phase_label.setStyleSheet("color: #ff6600; font-size: 11px; background: transparent; font-weight: bold;")
|
||
elif self.phase in ['discovery_complete', 'sync_complete']:
|
||
self.phase_label.setStyleSheet("color: #1db954; font-size: 11px; background: transparent; font-weight: bold;")
|
||
elif self.phase in ['syncing', 'downloading']:
|
||
self.phase_label.setStyleSheet("color: #1db954; font-size: 11px; background: transparent; font-weight: bold;")
|
||
elif self.phase in ['download_complete']:
|
||
self.phase_label.setStyleSheet("color: #1db954; font-size: 11px; background: transparent; font-weight: bold;")
|
||
|
||
def update_display(self):
|
||
"""Update all display elements based on current state"""
|
||
self.name_label.setText(self.playlist_name)
|
||
self.track_label.setText(f"{self.track_count} tracks")
|
||
self.phase_label.setText(self.get_phase_text())
|
||
self.action_btn.setText(self.get_action_text())
|
||
self.update_phase_style()
|
||
|
||
def set_phase(self, phase: str):
|
||
"""Update the current phase and refresh display"""
|
||
self.phase = phase
|
||
self.update_display()
|
||
|
||
# Show/hide progress widget based on phase
|
||
if phase in ['syncing', 'downloading', 'sync_complete']:
|
||
print(f"🎵 Tidal card phase set to {phase} - showing progress widget")
|
||
self.progress_widget.show()
|
||
self.action_btn.hide()
|
||
# For syncing phase, initialize with current progress data
|
||
if phase == 'syncing':
|
||
# Ensure we show some initial progress data
|
||
if self.progress_data['total'] == 0:
|
||
# Initialize with track count if available
|
||
self.progress_data['total'] = self.track_count
|
||
self.total_tracks_label.setText(f"♪ {self.progress_data['total']}")
|
||
self.matched_tracks_label.setText(f"✓ {self.progress_data['matched']}")
|
||
self.failed_tracks_label.setText(f"✗ {self.progress_data['failed']}")
|
||
# For sync_complete, hide progress after a delay to show final results
|
||
elif phase == 'sync_complete':
|
||
from PyQt6.QtCore import QTimer
|
||
QTimer.singleShot(5000, lambda: self.progress_widget.hide() if self.phase == 'sync_complete' else None)
|
||
QTimer.singleShot(5000, lambda: self.action_btn.show() if self.phase == 'sync_complete' else None)
|
||
else:
|
||
self.progress_widget.hide()
|
||
self.action_btn.show()
|
||
|
||
def update_playlist_info(self, name: str, track_count: int):
|
||
"""Update playlist information and refresh display"""
|
||
self.playlist_name = name
|
||
self.track_count = track_count
|
||
self.update_display()
|
||
|
||
def update_progress(self, total: int, matched: int, failed: int):
|
||
"""Update progress data and refresh progress display"""
|
||
self.progress_data = {'total': total, 'matched': matched, 'failed': failed}
|
||
if self.progress_widget.isVisible():
|
||
self.total_tracks_label.setText(f"♪ {total}")
|
||
self.matched_tracks_label.setText(f"✓ {matched}")
|
||
self.failed_tracks_label.setText(f"✗ {failed}")
|
||
|
||
def on_action_clicked(self):
|
||
"""Handle action button click - emit signal with current phase"""
|
||
self.card_clicked.emit(self.playlist_id, self.phase)
|
||
|
||
def mousePressEvent(self, event):
|
||
"""Handle card clicks"""
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
self.card_clicked.emit(self.playlist_id, self.phase)
|
||
super().mousePressEvent(event)
|
||
|
||
class YouTubePlaylistCard(QFrame):
|
||
"""YouTube playlist card with persistent state tracking across all phases"""
|
||
card_clicked = pyqtSignal(str, str) # Signal: (url, phase)
|
||
|
||
def __init__(self, url: str, playlist_name: str = "Loading...", track_count: int = 0, parent=None):
|
||
super().__init__(parent)
|
||
self.url = url
|
||
self.playlist_name = playlist_name
|
||
self.track_count = track_count
|
||
self.phase = "discovering" # discovering, discovery_complete, syncing, sync_complete, downloading, download_complete
|
||
self.progress_data = {'total': 0, 'matched': 0, 'failed': 0}
|
||
|
||
# Modal references
|
||
self.discovery_modal = None
|
||
self.download_modal = None
|
||
|
||
# State data
|
||
self.playlist_data = None
|
||
self.discovered_tracks = []
|
||
|
||
self.setup_ui()
|
||
self.update_display()
|
||
|
||
def setup_ui(self):
|
||
self.setFixedHeight(80)
|
||
self.setStyleSheet("""
|
||
YouTubePlaylistCard {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
YouTubePlaylistCard:hover {
|
||
background: #333333;
|
||
border: 1px solid #ff0000;
|
||
}
|
||
""")
|
||
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(20, 15, 20, 15)
|
||
layout.setSpacing(15)
|
||
|
||
# YouTube icon indicator
|
||
yt_icon = QLabel("▶")
|
||
yt_icon.setFixedSize(24, 24)
|
||
yt_icon.setStyleSheet("""
|
||
QLabel {
|
||
color: #ff0000;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
background: transparent;
|
||
text-align: center;
|
||
border-radius: 12px;
|
||
border: 1px solid #ff0000;
|
||
}
|
||
""")
|
||
yt_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
# Content layout
|
||
content_layout = QVBoxLayout()
|
||
content_layout.setSpacing(5)
|
||
|
||
self.name_label = QLabel(self.playlist_name)
|
||
self.name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||
self.name_label.setStyleSheet("color: #ffffff;")
|
||
|
||
info_layout = QHBoxLayout()
|
||
info_layout.setSpacing(20)
|
||
|
||
self.track_label = QLabel(f"{self.track_count} tracks")
|
||
self.track_label.setFont(QFont("Arial", 10))
|
||
self.track_label.setStyleSheet("color: #b3b3b3;")
|
||
|
||
self.phase_label = QLabel(self.get_phase_text())
|
||
self.phase_label.setFont(QFont("Arial", 10))
|
||
self.update_phase_style()
|
||
|
||
info_layout.addWidget(self.track_label)
|
||
info_layout.addWidget(self.phase_label)
|
||
info_layout.addStretch()
|
||
|
||
content_layout.addWidget(self.name_label)
|
||
content_layout.addLayout(info_layout)
|
||
|
||
# Progress status widget (similar to PlaylistItem)
|
||
self.progress_widget = self.create_progress_display()
|
||
self.progress_widget.hide() # Initially hidden
|
||
|
||
# Action button
|
||
self.action_btn = QPushButton(self.get_action_text())
|
||
self.action_btn.setFixedSize(120, 30)
|
||
self.action_btn.clicked.connect(self.on_action_clicked)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: transparent;
|
||
border: 1px solid #ff0000;
|
||
border-radius: 15px;
|
||
color: #ff0000;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #ff0000;
|
||
color: #ffffff;
|
||
}
|
||
""")
|
||
|
||
layout.addWidget(yt_icon)
|
||
layout.addLayout(content_layout)
|
||
layout.addWidget(self.progress_widget)
|
||
layout.addWidget(self.action_btn)
|
||
|
||
def create_progress_display(self):
|
||
"""Create sync status display widget like PlaylistItem"""
|
||
sync_status = QFrame()
|
||
sync_status.setFixedHeight(30)
|
||
sync_status.setStyleSheet("""
|
||
QFrame {
|
||
background: rgba(0, 0, 0, 0.3);
|
||
border-radius: 15px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(sync_status)
|
||
layout.setContentsMargins(12, 6, 12, 6)
|
||
layout.setSpacing(8)
|
||
|
||
# Create labels for progress display
|
||
self.total_tracks_label = QLabel(f"♪ {self.progress_data['total']}")
|
||
self.total_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
self.total_tracks_label.setStyleSheet("color: #b3b3b3; background: transparent; border: none;")
|
||
layout.addWidget(self.total_tracks_label)
|
||
|
||
sep1 = QLabel("/")
|
||
sep1.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
sep1.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(sep1)
|
||
|
||
self.matched_tracks_label = QLabel(f"✓ {self.progress_data['matched']}")
|
||
self.matched_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
self.matched_tracks_label.setStyleSheet("color: #1db954; background: transparent; border: none;")
|
||
layout.addWidget(self.matched_tracks_label)
|
||
|
||
sep2 = QLabel("/")
|
||
sep2.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
sep2.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(sep2)
|
||
|
||
self.failed_tracks_label = QLabel(f"✗ {self.progress_data['failed']}")
|
||
self.failed_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
self.failed_tracks_label.setStyleSheet("color: #e22134; background: transparent; border: none;")
|
||
layout.addWidget(self.failed_tracks_label)
|
||
|
||
sep3 = QLabel("/")
|
||
sep3.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
sep3.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(sep3)
|
||
|
||
self.percentage_label = QLabel("0%")
|
||
self.percentage_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
||
self.percentage_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
||
layout.addWidget(self.percentage_label)
|
||
|
||
return sync_status
|
||
|
||
def get_phase_text(self):
|
||
"""Get display text for current phase"""
|
||
phase_texts = {
|
||
'discovering': 'Discovering tracks...',
|
||
'discovery_complete': 'Discovery complete',
|
||
'syncing': 'Syncing...',
|
||
'sync_complete': 'Sync complete',
|
||
'downloading': 'Downloading...',
|
||
'download_complete': 'Complete'
|
||
}
|
||
return phase_texts.get(self.phase, self.phase)
|
||
|
||
def get_action_text(self):
|
||
"""Get action button text based on phase"""
|
||
action_texts = {
|
||
'discovering': 'View Progress',
|
||
'discovery_complete': 'View Details',
|
||
'syncing': 'View Progress',
|
||
'sync_complete': 'Download Missing',
|
||
'downloading': 'View Downloads',
|
||
'download_complete': 'View Results'
|
||
}
|
||
return action_texts.get(self.phase, 'Open')
|
||
|
||
def update_phase_style(self):
|
||
"""Update phase label color based on current phase"""
|
||
phase_colors = {
|
||
'discovering': '#ffa500', # Orange
|
||
'discovery_complete': '#1db954', # Green
|
||
'syncing': '#ffa500', # Orange
|
||
'sync_complete': '#1db954', # Green
|
||
'downloading': '#ffa500', # Orange
|
||
'download_complete': '#1db954' # Green
|
||
}
|
||
color = phase_colors.get(self.phase, '#b3b3b3')
|
||
self.phase_label.setStyleSheet(f"color: {color};")
|
||
|
||
def update_display(self):
|
||
"""Update all display elements based on current state"""
|
||
self.name_label.setText(self.playlist_name)
|
||
self.track_label.setText(f"{self.track_count} tracks")
|
||
self.phase_label.setText(self.get_phase_text())
|
||
self.action_btn.setText(self.get_action_text())
|
||
self.update_phase_style()
|
||
|
||
def set_phase(self, phase: str):
|
||
"""Update the current phase and refresh display"""
|
||
self.phase = phase
|
||
self.update_display()
|
||
|
||
# Show/hide progress widget based on phase
|
||
if phase in ['syncing', 'downloading', 'sync_complete']:
|
||
print(f"🎬 Card phase set to {phase} - showing progress widget")
|
||
self.progress_widget.show()
|
||
self.action_btn.hide()
|
||
# For syncing phase, initialize with current progress data
|
||
if phase == 'syncing':
|
||
# Ensure we show some initial progress data
|
||
if self.progress_data['total'] == 0:
|
||
# Initialize with track count if available
|
||
self.progress_data['total'] = self.track_count
|
||
self.total_tracks_label.setText(f"♪ {self.progress_data['total']}")
|
||
self.matched_tracks_label.setText(f"✓ {self.progress_data['matched']}")
|
||
self.failed_tracks_label.setText(f"✗ {self.progress_data['failed']}")
|
||
# For sync_complete, hide progress after a delay to show final results
|
||
elif phase == 'sync_complete':
|
||
from PyQt6.QtCore import QTimer
|
||
QTimer.singleShot(5000, lambda: self.progress_widget.hide() if self.phase == 'sync_complete' else None)
|
||
QTimer.singleShot(5000, lambda: self.action_btn.show() if self.phase == 'sync_complete' else None)
|
||
else:
|
||
print(f"🎬 Card phase set to {phase} - hiding progress widget")
|
||
self.progress_widget.hide()
|
||
self.action_btn.show()
|
||
|
||
def update_progress(self, total=None, matched=None, failed=None):
|
||
"""Update progress data and display"""
|
||
print(f"🎬 Card update_progress called: total={total}, matched={matched}, failed={failed}, phase={self.phase}")
|
||
|
||
if total is not None:
|
||
self.progress_data['total'] = total
|
||
if matched is not None:
|
||
self.progress_data['matched'] = matched
|
||
if failed is not None:
|
||
self.progress_data['failed'] = failed
|
||
|
||
# Update labels
|
||
self.total_tracks_label.setText(f"♪ {self.progress_data['total']}")
|
||
self.matched_tracks_label.setText(f"✓ {self.progress_data['matched']}")
|
||
self.failed_tracks_label.setText(f"✗ {self.progress_data['failed']}")
|
||
|
||
# Ensure progress widget is visible when progress is being updated
|
||
# This ensures live status display is always shown during active operations
|
||
if self.phase in ['syncing', 'downloading']:
|
||
print(f"🎬 Card in {self.phase} phase - ensuring progress widget is visible")
|
||
self.progress_widget.show()
|
||
self.action_btn.hide()
|
||
else:
|
||
print(f"🎬 Card not in active phase ({self.phase}) - progress widget state unchanged")
|
||
|
||
# Calculate percentage
|
||
total = self.progress_data['total']
|
||
if total > 0:
|
||
processed = self.progress_data['matched'] + self.progress_data['failed']
|
||
percentage = int((processed / total) * 100)
|
||
self.percentage_label.setText(f"{percentage}%")
|
||
else:
|
||
self.percentage_label.setText("0%")
|
||
|
||
def update_playlist_info(self, name: str, track_count: int):
|
||
"""Update playlist name and track count"""
|
||
self.playlist_name = name
|
||
self.track_count = track_count
|
||
self.update_display()
|
||
|
||
def set_playlist_data(self, data):
|
||
"""Store discovered playlist data"""
|
||
self.playlist_data = data
|
||
if hasattr(data, 'tracks'):
|
||
self.discovered_tracks = data.tracks
|
||
self.track_count = len(data.tracks)
|
||
self.update_display()
|
||
|
||
def on_action_clicked(self):
|
||
"""Handle action button click - emit signal with current phase"""
|
||
self.card_clicked.emit(self.url, self.phase)
|
||
|
||
def mousePressEvent(self, event):
|
||
"""Handle card clicks"""
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
self.card_clicked.emit(self.url, self.phase)
|
||
super().mousePressEvent(event)
|
||
|
||
class SyncOptionsPanel(QFrame):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
self.setStyleSheet("""
|
||
SyncOptionsPanel {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(20, 20, 20, 20)
|
||
layout.setSpacing(15)
|
||
|
||
# Title
|
||
title_label = QLabel("Sync Options")
|
||
title_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
||
title_label.setStyleSheet("color: #ffffff;")
|
||
|
||
# Download missing tracks option
|
||
self.download_missing = QCheckBox("Download missing tracks from Soulseek")
|
||
self.download_missing.setChecked(True)
|
||
self.download_missing.setStyleSheet("""
|
||
QCheckBox {
|
||
color: #ffffff;
|
||
font-size: 11px;
|
||
}
|
||
QCheckBox::indicator {
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
border: 2px solid #b3b3b3;
|
||
background: transparent;
|
||
}
|
||
QCheckBox::indicator:checked {
|
||
background: #1db954;
|
||
border: 2px solid #1db954;
|
||
}
|
||
""")
|
||
|
||
layout.addWidget(title_label)
|
||
layout.addWidget(self.download_missing)
|
||
|
||
class SyncPage(QWidget):
|
||
# Signals for dashboard activity tracking
|
||
sync_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time
|
||
database_updated_externally = pyqtSignal()
|
||
|
||
def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, jellyfin_client=None, navidrome_client=None, tidal_client=None, parent=None):
|
||
super().__init__(parent)
|
||
self.spotify_client = spotify_client
|
||
self.plex_client = plex_client
|
||
self.jellyfin_client = jellyfin_client
|
||
self.navidrome_client = navidrome_client
|
||
self.soulseek_client = soulseek_client
|
||
self.tidal_client = tidal_client or TidalClient()
|
||
self.downloads_page = downloads_page
|
||
self.sync_statuses = load_sync_status()
|
||
self.current_playlists = []
|
||
self.playlist_loader = None
|
||
self.current_tidal_playlists = []
|
||
self.tidal_playlist_loader = None
|
||
self.active_download_processes = {}
|
||
# Track cache for performance
|
||
self.track_cache = {} # playlist_id -> tracks
|
||
|
||
# Sync worker management
|
||
self.active_sync_workers = {} # playlist_id -> SyncWorker (for individual modal syncs)
|
||
self.sequential_sync_worker = None # Current sequential sync worker
|
||
|
||
# Selection tracking
|
||
self.selected_playlists = set() # Set of selected playlist IDs
|
||
self.sequential_sync_queue = [] # Queue for sequential syncing
|
||
self.is_sequential_syncing = False
|
||
|
||
# Thread pool for async operations (like downloads.py)
|
||
self.thread_pool = QThreadPool()
|
||
self.thread_pool.setMaxThreadCount(3) # Limit concurrent Spotify API calls
|
||
|
||
# YouTube playlist tracking
|
||
self.active_youtube_processes = {} # URL -> modal instance
|
||
self.youtube_worker = None # Current parsing worker
|
||
self.youtube_status_widgets = {} # playlist_id -> status widget
|
||
|
||
# YouTube playlist download modal references for reopening
|
||
self.active_youtube_download_modals = {} # playlist_id -> modal instance
|
||
|
||
# YouTube playlist card hub system
|
||
self.youtube_playlist_states = {} # url -> {phase, data, card, modals}
|
||
self.youtube_cards = {} # url -> YouTubePlaylistCard instance
|
||
self.youtube_cards_container = None # Container for all YouTube cards
|
||
|
||
# Tidal playlist card hub system (identical to YouTube)
|
||
self.tidal_playlist_states = {} # playlist_id -> {phase, data, card, modals}
|
||
self.tidal_cards = {} # playlist_id -> TidalPlaylistCard instance
|
||
self.tidal_cards_container = None # Container for all Tidal cards
|
||
|
||
# Initialize unified media scan manager
|
||
self.scan_manager = None
|
||
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)
|
||
logger.info("✅ MediaScanManager initialized for SyncPage")
|
||
except Exception as e:
|
||
logger.error(f"Failed to initialize MediaScanManager: {e}")
|
||
|
||
self.setup_ui()
|
||
|
||
# Don't auto-load on startup, but do auto-load when page becomes visible
|
||
self.show_initial_state()
|
||
self.playlists_loaded = False
|
||
|
||
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.plex_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 _update_and_save_sync_status(self, playlist_id, result, snapshot_id):
|
||
"""Updates the sync status for a given playlist and saves to file."""
|
||
# THE FIX: This function will now run even if there are failed tracks,
|
||
# ensuring the sync time and snapshot_id are always recorded.
|
||
playlist_obj = next((p for p in self.current_playlists if p.id == playlist_id), None)
|
||
|
||
if playlist_obj:
|
||
now = datetime.now()
|
||
self.sync_statuses[playlist_id] = {
|
||
'name': playlist_obj.name,
|
||
'owner': playlist_obj.owner,
|
||
'snapshot_id': snapshot_id,
|
||
'last_synced': now.isoformat()
|
||
}
|
||
save_sync_status(self.sync_statuses)
|
||
|
||
# This now targets the correct label for real-time UI updates
|
||
playlist_item = self.find_playlist_item_widget(playlist_id)
|
||
if playlist_item and hasattr(playlist_item, 'sync_status_label'):
|
||
new_status_text = f"Synced: {now.strftime('%b %d, %H:%M')}"
|
||
playlist_item.sync_status_label.setText(new_status_text)
|
||
playlist_item.sync_status_label.setStyleSheet("color: #1db954;")
|
||
|
||
def is_playlist_syncing(self, playlist_id):
|
||
"""Check if a playlist is currently syncing"""
|
||
return playlist_id in self.active_sync_workers
|
||
|
||
def get_playlist_sync_worker(self, playlist_id):
|
||
"""Get the sync worker for a playlist if it exists"""
|
||
return self.active_sync_workers.get(playlist_id)
|
||
|
||
def start_playlist_sync(self, playlist):
|
||
"""Start sync for a playlist (called from modal)"""
|
||
if playlist.id in self.active_sync_workers:
|
||
# Already syncing
|
||
return False
|
||
|
||
# Create sync service if not available
|
||
if not hasattr(self, 'sync_service'):
|
||
from services.sync_service import PlaylistSyncService
|
||
self.sync_service = PlaylistSyncService(
|
||
self.spotify_client,
|
||
self.plex_client,
|
||
self.soulseek_client,
|
||
getattr(self, 'jellyfin_client', None),
|
||
getattr(self, 'navidrome_client', None)
|
||
)
|
||
|
||
# Create sync worker
|
||
sync_worker = SyncWorker(
|
||
playlist=playlist,
|
||
sync_service=self.sync_service
|
||
)
|
||
|
||
# Connect worker signals
|
||
sync_worker.signals.finished.connect(lambda result, sid: self.on_sync_finished(playlist.id, result, sid))
|
||
|
||
sync_worker.signals.error.connect(lambda error: self.on_sync_error(playlist.id, error))
|
||
sync_worker.signals.progress.connect(lambda progress: self.on_sync_progress(playlist.id, progress))
|
||
|
||
# Store the worker
|
||
self.active_sync_workers[playlist.id] = sync_worker
|
||
|
||
# Emit activity signal for sync start
|
||
self.sync_activity.emit("🔄", "Sync Started", f"Syncing playlist '{playlist.name}'", "Now")
|
||
|
||
# Show toast notification for sync start
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
track_count = len(playlist.tracks) if hasattr(playlist, 'tracks') else 0
|
||
if track_count > 0:
|
||
self.toast_manager.show_toast(f"Starting sync for '{playlist.name}' ({track_count} tracks)", ToastType.INFO)
|
||
|
||
# Start the worker
|
||
self.thread_pool.start(sync_worker)
|
||
|
||
# Update playlist item status
|
||
playlist_item = self.find_playlist_item_widget(playlist.id)
|
||
if playlist_item:
|
||
playlist_item.is_syncing = True
|
||
playlist_item.update_sync_status(len(playlist.tracks), 0, 0)
|
||
|
||
# Log start
|
||
if hasattr(self, 'log_area'):
|
||
self.log_area.append(f"🔄 Starting sync for playlist: {playlist.name}")
|
||
|
||
# Update refresh button state since we now have an active sync
|
||
self.update_refresh_button_state()
|
||
|
||
return True
|
||
|
||
def start_sequential_playlist_sync(self, playlist):
|
||
"""Start sync for a playlist as part of sequential sync (separate from individual syncs)"""
|
||
# Create sync service if not available
|
||
if not hasattr(self, 'sync_service'):
|
||
from services.sync_service import PlaylistSyncService
|
||
self.sync_service = PlaylistSyncService(
|
||
self.spotify_client,
|
||
self.plex_client,
|
||
self.soulseek_client,
|
||
getattr(self, 'jellyfin_client', None),
|
||
getattr(self, 'navidrome_client', None)
|
||
)
|
||
|
||
# Create sync worker for sequential sync
|
||
sync_worker = SyncWorker(
|
||
playlist=playlist,
|
||
sync_service=self.sync_service
|
||
)
|
||
|
||
# Connect worker signals for sequential sync
|
||
sync_worker.signals.finished.connect(lambda result, sid: self.on_sequential_sync_finished(playlist.id, result, sid))
|
||
sync_worker.signals.error.connect(lambda error: self.on_sequential_sync_error(playlist.id, error))
|
||
sync_worker.signals.progress.connect(lambda progress: self.on_sync_progress(playlist.id, progress))
|
||
|
||
# Store the sequential sync worker
|
||
self.sequential_sync_worker = sync_worker
|
||
|
||
# Start the worker
|
||
self.thread_pool.start(sync_worker)
|
||
|
||
# Update playlist item status
|
||
playlist_item = self.find_playlist_item_widget(playlist.id)
|
||
if playlist_item:
|
||
playlist_item.is_syncing = True
|
||
playlist_item.update_sync_status(len(playlist.tracks), 0, 0)
|
||
|
||
# Log start
|
||
if hasattr(self, 'log_area'):
|
||
self.log_area.append(f"🔄 Starting sequential sync for playlist: {playlist.name}")
|
||
|
||
# Show toast notification for sequential sync start
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
track_count = len(playlist.tracks) if hasattr(playlist, 'tracks') else 0
|
||
if track_count > 0:
|
||
self.toast_manager.show_toast(f"Starting sequential sync for '{playlist.name}' ({track_count} tracks)", ToastType.INFO)
|
||
|
||
return True
|
||
|
||
def toggle_playlist_selection(self, playlist_id):
|
||
"""Toggle selection state of a playlist"""
|
||
if playlist_id in self.selected_playlists:
|
||
self.selected_playlists.remove(playlist_id)
|
||
print(f"Deselected playlist: {playlist_id}")
|
||
else:
|
||
self.selected_playlists.add(playlist_id)
|
||
print(f"Selected playlist: {playlist_id}")
|
||
|
||
print(f"Total selected: {len(self.selected_playlists)}")
|
||
self.update_selection_ui()
|
||
|
||
def update_selection_ui(self):
|
||
"""Update the selection info label and button state"""
|
||
selected_count = len(self.selected_playlists)
|
||
|
||
print(f"Updating UI with {selected_count} selected playlists, sequential syncing: {self.is_sequential_syncing}, individual syncs: {len(self.active_sync_workers)}")
|
||
|
||
if selected_count == 0:
|
||
self.selection_info.setText("Select playlists to sync")
|
||
self.start_sync_btn.setEnabled(False)
|
||
print("Button disabled - no selection")
|
||
elif self.has_active_operations():
|
||
# Don't change button state during any active operations
|
||
print(f"Active operations in progress - keeping button as is")
|
||
elif selected_count == 1:
|
||
self.selection_info.setText("1 playlist selected")
|
||
self.start_sync_btn.setEnabled(True)
|
||
print("Button enabled - 1 playlist")
|
||
else:
|
||
self.selection_info.setText(f"{selected_count} playlists selected")
|
||
self.start_sync_btn.setEnabled(True)
|
||
print(f"Button enabled - {selected_count} playlists")
|
||
|
||
def start_selected_playlist_sync(self):
|
||
"""Start syncing all selected playlists sequentially"""
|
||
if not self.selected_playlists or self.is_sequential_syncing:
|
||
return
|
||
|
||
# Don't allow sequential sync if individual syncs are already running
|
||
if self.active_sync_workers:
|
||
print(f"DEBUG: Cannot start sequential sync - {len(self.active_sync_workers)} individual syncs are running")
|
||
return
|
||
|
||
# Get selected playlist objects
|
||
selected_playlist_objects = []
|
||
for playlist_item in self.get_all_playlist_items():
|
||
if playlist_item.playlist.id in self.selected_playlists:
|
||
selected_playlist_objects.append(playlist_item.playlist)
|
||
|
||
if not selected_playlist_objects:
|
||
return
|
||
|
||
# Start sequential sync
|
||
self.sequential_sync_queue = selected_playlist_objects.copy()
|
||
self.is_sequential_syncing = True
|
||
self.start_sync_btn.setText("Syncing...")
|
||
self.start_sync_btn.setEnabled(False)
|
||
|
||
# Disable refresh button during sequential sync
|
||
self.update_refresh_button_state()
|
||
|
||
# Start first sync
|
||
self.process_next_in_sync_queue()
|
||
|
||
def process_next_in_sync_queue(self):
|
||
"""Process the next playlist in the sequential sync queue."""
|
||
print(f"DEBUG: process_next_in_sync_queue - queue length: {len(self.sequential_sync_queue)}, is_syncing: {self.is_sequential_syncing}")
|
||
|
||
if self.sequential_sync_queue and self.is_sequential_syncing:
|
||
# Get next playlist to sync
|
||
next_playlist = self.sequential_sync_queue.pop(0)
|
||
print(f"DEBUG: Starting sync for next playlist: {next_playlist.name}")
|
||
|
||
# Start sync for this playlist
|
||
if not self.start_sequential_playlist_sync(next_playlist):
|
||
# If sync failed to start, immediately process the next one
|
||
print("DEBUG: Sync failed to start, moving to next playlist")
|
||
self.process_next_in_sync_queue()
|
||
else:
|
||
# If queue is empty or sync was cancelled, call the final completion handler
|
||
print("DEBUG: Sequential sync queue is empty or syncing stopped - calling completion handler.")
|
||
self.on_sequential_sync_complete()
|
||
|
||
def on_sequential_sync_complete(self):
|
||
"""Handle completion of the entire sequential sync process."""
|
||
# Ensure this runs only once at the very end
|
||
if not self.is_sequential_syncing:
|
||
return
|
||
|
||
print("DEBUG: Sequential sync process complete. Resetting all states.")
|
||
self.is_sequential_syncing = False
|
||
self.sequential_sync_queue.clear()
|
||
self.sequential_sync_worker = None # Ensure worker is cleared
|
||
|
||
# Reset the button text and state authoritatively
|
||
self.start_sync_btn.setText("Start Sync")
|
||
|
||
# Update the entire UI based on the new, correct state
|
||
self.update_selection_ui()
|
||
self.update_refresh_button_state()
|
||
|
||
def on_sequential_sync_finished(self, playlist_id, result, snapshot_id):
|
||
"""Handle completion of individual playlist in sequential sync"""
|
||
print(f"DEBUG: Sequential sync finished for playlist {playlist_id}")
|
||
|
||
# Clear sequential sync worker
|
||
self.sequential_sync_worker = None
|
||
|
||
# Update playlist item status
|
||
playlist_item = self.find_playlist_item_widget(playlist_id)
|
||
if playlist_item:
|
||
playlist_item.is_syncing = False
|
||
playlist_item.update_sync_status(
|
||
result.total_tracks,
|
||
result.matched_tracks,
|
||
result.failed_tracks
|
||
)
|
||
|
||
# Hide status widget after completion with delay
|
||
QTimer.singleShot(3000, lambda: playlist_item.sync_status_widget.hide() if playlist_item.sync_status_widget else None)
|
||
|
||
# Update any open modals
|
||
self.update_open_modals_completion(playlist_id, result)
|
||
|
||
# Pass the snapshot_id to the save function
|
||
self._update_and_save_sync_status(playlist_id, result, snapshot_id)
|
||
|
||
# Log completion
|
||
if hasattr(self, 'log_area'):
|
||
success_rate = result.success_rate
|
||
msg = f"✅ Sequential sync complete: {result.synced_tracks}/{result.total_tracks} tracks synced ({success_rate:.1f}%)"
|
||
if result.failed_tracks > 0:
|
||
msg += f", {result.failed_tracks} failed"
|
||
self.log_area.append(msg)
|
||
|
||
# Show toast notification for sequential sync completion
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
playlist_item = self.find_playlist_item_widget(playlist_id)
|
||
playlist_name = playlist_item.name if playlist_item else "Unknown Playlist"
|
||
if result.failed_tracks > 0:
|
||
self.toast_manager.show_toast(f"'{playlist_name}' sync completed: {result.matched_tracks}/{result.total_tracks} tracks, {result.failed_tracks} failed", ToastType.WARNING)
|
||
else:
|
||
self.toast_manager.show_toast(f"'{playlist_name}' sync completed: {result.matched_tracks} tracks added", ToastType.SUCCESS)
|
||
|
||
# **THE FIX**: Defer processing the next item to allow the event loop to catch up.
|
||
# This ensures UI updates (like the status label) are processed before moving on.
|
||
if self.is_sequential_syncing:
|
||
print(f"DEBUG: Scheduling next playlist in sequence.")
|
||
QTimer.singleShot(10, self.process_next_in_sync_queue)
|
||
|
||
def on_sequential_sync_error(self, playlist_id, error_msg):
|
||
"""Handle error in individual playlist during sequential sync"""
|
||
print(f"DEBUG: Sequential sync error for playlist {playlist_id}: {error_msg}")
|
||
|
||
# Clear sequential sync worker
|
||
self.sequential_sync_worker = None
|
||
|
||
# Update playlist item status
|
||
playlist_item = self.find_playlist_item_widget(playlist_id)
|
||
if playlist_item:
|
||
playlist_item.is_syncing = False
|
||
if playlist_item.sync_status_widget:
|
||
playlist_item.sync_status_widget.hide()
|
||
|
||
# Update any open modals
|
||
self.update_open_modals_error(playlist_id, error_msg)
|
||
|
||
# Log error
|
||
if hasattr(self, 'log_area'):
|
||
self.log_area.append(f"❌ Sequential sync failed: {error_msg}")
|
||
|
||
# Show toast notification for sequential sync error
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
playlist_item = self.find_playlist_item_widget(playlist_id)
|
||
playlist_name = playlist_item.name if playlist_id else "Unknown Playlist"
|
||
self.toast_manager.show_toast(f"Sequential sync failed for '{playlist_name}': {error_msg}", ToastType.ERROR)
|
||
|
||
# **THE FIX**: Defer processing the next item to allow the event loop to catch up.
|
||
if self.is_sequential_syncing:
|
||
print(f"DEBUG: Scheduling next playlist in sequence despite error.")
|
||
QTimer.singleShot(10, self.process_next_in_sync_queue)
|
||
|
||
def get_all_playlist_items(self):
|
||
"""Get all PlaylistItem widgets from the playlist layout"""
|
||
playlist_items = []
|
||
for i in range(self.playlist_layout.count()):
|
||
item = self.playlist_layout.itemAt(i)
|
||
widget = item.widget()
|
||
if isinstance(widget, PlaylistItem):
|
||
playlist_items.append(widget)
|
||
return playlist_items
|
||
|
||
def cancel_playlist_sync(self, playlist_id):
|
||
"""Cancel sync for a playlist"""
|
||
if playlist_id in self.active_sync_workers:
|
||
worker = self.active_sync_workers[playlist_id]
|
||
worker.cancel()
|
||
|
||
# Remove from active workers
|
||
del self.active_sync_workers[playlist_id]
|
||
|
||
# Update playlist item status
|
||
playlist_item = self.find_playlist_item_widget(playlist_id)
|
||
if playlist_item:
|
||
playlist_item.is_syncing = False
|
||
if playlist_item.sync_status_widget:
|
||
playlist_item.sync_status_widget.hide()
|
||
|
||
# Log cancellation
|
||
if hasattr(self, 'log_area'):
|
||
self.log_area.append(f"🚫 Sync cancelled for playlist")
|
||
|
||
return True
|
||
return False
|
||
|
||
def on_sync_progress(self, playlist_id, progress):
|
||
"""Handle sync progress updates"""
|
||
print(f"🚀 PARENT PAGE on_sync_progress called! playlist_id={playlist_id}")
|
||
print(f"🚀 Progress: total={progress.total_tracks}, matched={progress.matched_tracks}, failed={progress.failed_tracks}")
|
||
|
||
# Update playlist item status (for Spotify playlists)
|
||
playlist_item = self.find_playlist_item_widget(playlist_id)
|
||
if playlist_item:
|
||
print(f"🚀 Found playlist item widget, updating status")
|
||
playlist_item.update_sync_status(
|
||
progress.total_tracks,
|
||
progress.matched_tracks,
|
||
progress.failed_tracks
|
||
)
|
||
else:
|
||
print(f"🚀 No playlist item widget found for playlist_id: {playlist_id}")
|
||
|
||
# Update YouTube card progress (for YouTube playlists)
|
||
# Find the YouTube card by matching playlist IDs
|
||
youtube_card_updated = False
|
||
print(f"🎬 Searching for YouTube card with playlist_id: {playlist_id}")
|
||
for url, state in self.youtube_playlist_states.items():
|
||
playlist_data = state.get('playlist_data')
|
||
if playlist_data and hasattr(playlist_data, 'id'):
|
||
print(f"🎬 Checking YouTube card: URL={url}, stored playlist_id={playlist_data.id}")
|
||
if playlist_data.id == playlist_id:
|
||
print(f"🎬 ✅ Found matching YouTube card for playlist_id: {playlist_id}, updating progress")
|
||
self.update_youtube_card_progress(
|
||
url,
|
||
total=progress.total_tracks,
|
||
matched=progress.matched_tracks,
|
||
failed=progress.failed_tracks
|
||
)
|
||
youtube_card_updated = True
|
||
break
|
||
else:
|
||
print(f"🎬 ❌ Playlist ID mismatch: {playlist_data.id} != {playlist_id}")
|
||
else:
|
||
print(f"🎬 YouTube card state missing playlist_data or id: URL={url}")
|
||
|
||
if not youtube_card_updated:
|
||
print(f"🎬 ❌ No matching YouTube card found for playlist_id: {playlist_id}")
|
||
|
||
# Update Tidal card progress (for Tidal playlists)
|
||
# Find the Tidal card by matching playlist IDs
|
||
tidal_card_updated = False
|
||
print(f"🎵 Searching for Tidal card with playlist_id: {playlist_id}")
|
||
for tidal_playlist_id, state in self.tidal_playlist_states.items():
|
||
playlist_data = state.get('playlist_data')
|
||
if playlist_data and hasattr(playlist_data, 'id'):
|
||
print(f"🎵 Checking Tidal card: tidal_playlist_id={tidal_playlist_id}, stored playlist_id={playlist_data.id}")
|
||
if playlist_data.id == playlist_id:
|
||
print(f"🎵 ✅ Found matching Tidal card for playlist_id: {playlist_id}, updating progress")
|
||
# Update card progress display
|
||
if tidal_playlist_id in self.tidal_cards:
|
||
card = self.tidal_cards[tidal_playlist_id]
|
||
card.update_progress(
|
||
total=progress.total_tracks,
|
||
matched=progress.matched_tracks,
|
||
failed=progress.failed_tracks
|
||
)
|
||
tidal_card_updated = True
|
||
break
|
||
else:
|
||
print(f"🎵 ❌ Playlist ID mismatch: {playlist_data.id} != {playlist_id}")
|
||
else:
|
||
print(f"🎵 Tidal card state missing playlist_data or id: tidal_playlist_id={tidal_playlist_id}")
|
||
|
||
if not tidal_card_updated:
|
||
print(f"🎵 ❌ No matching Tidal card found for playlist_id: {playlist_id}")
|
||
|
||
if not playlist_item and not youtube_card_updated and not tidal_card_updated:
|
||
print(f"🚀 No playlist widget, YouTube card, OR Tidal card found for playlist_id: {playlist_id}")
|
||
|
||
# Update any open modal for this playlist
|
||
print(f"🚀 About to call update_open_modals_progress")
|
||
self.update_open_modals_progress(playlist_id, progress)
|
||
|
||
def on_sync_finished(self, playlist_id, result, snapshot_id):
|
||
"""Handle sync completion"""
|
||
# Remove from active workers
|
||
if playlist_id in self.active_sync_workers:
|
||
del self.active_sync_workers[playlist_id]
|
||
|
||
# Update playlist item status (for Spotify playlists)
|
||
playlist_item = self.find_playlist_item_widget(playlist_id)
|
||
playlist_name = "Unknown Playlist"
|
||
|
||
if playlist_item:
|
||
playlist_item.is_syncing = False
|
||
playlist_item.update_sync_status(
|
||
result.total_tracks,
|
||
result.matched_tracks,
|
||
result.failed_tracks
|
||
)
|
||
playlist_name = playlist_item.name
|
||
# Hide status widget after completion with delay
|
||
QTimer.singleShot(3000, lambda: playlist_item.sync_status_widget.hide() if playlist_item.sync_status_widget else None)
|
||
|
||
# Update YouTube card status (for YouTube playlists)
|
||
youtube_card_updated = False
|
||
for url, state in self.youtube_playlist_states.items():
|
||
playlist_data = state.get('playlist_data')
|
||
if playlist_data and hasattr(playlist_data, 'id') and playlist_data.id == playlist_id:
|
||
print(f"🎬 YouTube sync finished for playlist_id: {playlist_id}, updating card to sync_complete")
|
||
self.update_youtube_card_phase(url, 'sync_complete')
|
||
self.update_youtube_card_progress(
|
||
url,
|
||
total=result.total_tracks,
|
||
matched=result.matched_tracks,
|
||
failed=result.failed_tracks
|
||
)
|
||
playlist_name = playlist_data.name
|
||
youtube_card_updated = True
|
||
break
|
||
|
||
# Update Tidal card status (for Tidal playlists)
|
||
tidal_card_updated = False
|
||
for tidal_playlist_id, state in self.tidal_playlist_states.items():
|
||
playlist_data = state.get('playlist_data')
|
||
if playlist_data and hasattr(playlist_data, 'id') and playlist_data.id == playlist_id:
|
||
print(f"🎵 Tidal sync finished for playlist_id: {playlist_id}, updating card to sync_complete")
|
||
self.update_tidal_card_phase(tidal_playlist_id, 'sync_complete')
|
||
# Also update card progress display
|
||
if tidal_playlist_id in self.tidal_cards:
|
||
card = self.tidal_cards[tidal_playlist_id]
|
||
card.update_progress(
|
||
total=result.total_tracks,
|
||
matched=result.matched_tracks,
|
||
failed=result.failed_tracks
|
||
)
|
||
playlist_name = playlist_data.name
|
||
tidal_card_updated = True
|
||
break
|
||
|
||
# Update any open modals
|
||
self.update_open_modals_completion(playlist_id, result)
|
||
|
||
# Pass the snapshot_id to the save function
|
||
self._update_and_save_sync_status(playlist_id, result, snapshot_id)
|
||
|
||
# Emit activity signal for sync completion
|
||
success_msg = f"Completed: {result.matched_tracks}/{result.total_tracks} tracks"
|
||
self.sync_activity.emit("✅", "Sync Complete", f"'{playlist_name}' - {success_msg}", "Now")
|
||
|
||
# Show toast notification for sync completion
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
if result.failed_tracks > 0:
|
||
self.toast_manager.show_toast(f"Sync completed: {result.matched_tracks}/{result.total_tracks} tracks added, {result.failed_tracks} failed", ToastType.WARNING)
|
||
else:
|
||
self.toast_manager.show_toast(f"Sync completed: {result.matched_tracks} tracks added to queue", ToastType.SUCCESS)
|
||
|
||
# Continue sequential sync if in progress
|
||
if self.is_sequential_syncing:
|
||
print(f"DEBUG: Sync finished for {playlist_id}, continuing sequential sync")
|
||
self.process_next_in_sync_queue()
|
||
else:
|
||
print(f"DEBUG: Sync finished for {playlist_id}, not in sequential sync mode")
|
||
|
||
# Update refresh button state since a sync completed
|
||
self.update_refresh_button_state()
|
||
|
||
# Log completion
|
||
if hasattr(self, 'log_area'):
|
||
success_rate = result.success_rate
|
||
msg = f"✅ Sync complete: {result.synced_tracks}/{result.total_tracks} tracks synced ({success_rate:.1f}%)"
|
||
if result.failed_tracks > 0:
|
||
msg += f", {result.failed_tracks} failed"
|
||
self.log_area.append(msg)
|
||
|
||
def on_sync_error(self, playlist_id, error_msg):
|
||
"""Handle sync error"""
|
||
# Remove from active workers
|
||
if playlist_id in self.active_sync_workers:
|
||
del self.active_sync_workers[playlist_id]
|
||
|
||
# Update playlist item status
|
||
playlist_item = self.find_playlist_item_widget(playlist_id)
|
||
if playlist_item:
|
||
playlist_item.is_syncing = False
|
||
if playlist_item.sync_status_widget:
|
||
playlist_item.sync_status_widget.hide()
|
||
|
||
# Update any open modals
|
||
self.update_open_modals_error(playlist_id, error_msg)
|
||
|
||
# Emit activity signal for sync error
|
||
playlist_name = playlist_item.name if playlist_item else "Unknown Playlist"
|
||
self.sync_activity.emit("❌", "Sync Failed", f"'{playlist_name}' - {error_msg}", "Now")
|
||
|
||
# Show toast notification for sync error
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.show_toast(f"Sync failed for '{playlist_name}': {error_msg}", ToastType.ERROR)
|
||
|
||
# Continue sequential sync if in progress (even on error)
|
||
if self.is_sequential_syncing:
|
||
self.process_next_in_sync_queue()
|
||
|
||
# Update refresh button state since a sync completed (with error)
|
||
self.update_refresh_button_state()
|
||
|
||
# Log error
|
||
if hasattr(self, 'log_area'):
|
||
self.log_area.append(f"❌ Sync failed: {error_msg}")
|
||
|
||
def update_open_modals_progress(self, playlist_id, progress):
|
||
"""Update any open modals for this playlist with sync progress"""
|
||
print(f"🔍 Looking for modals to update progress for playlist_id: {playlist_id}")
|
||
print(f"🔍 Progress data: total={progress.total_tracks}, matched={progress.matched_tracks}, failed={progress.failed_tracks}")
|
||
|
||
# Find all open modal instances for this playlist
|
||
from PyQt6.QtWidgets import QApplication
|
||
youtube_modals_found = 0
|
||
spotify_modals_found = 0
|
||
|
||
for widget in QApplication.topLevelWidgets():
|
||
widget_name = type(widget).__name__
|
||
print(f"🔍 Checking widget: {widget_name}")
|
||
|
||
# Handle PlaylistDetailsModal
|
||
if isinstance(widget, PlaylistDetailsModal):
|
||
if hasattr(widget, 'playlist'):
|
||
widget_playlist_id = getattr(widget.playlist, 'id', 'NO_ID')
|
||
is_visible = widget.isVisible()
|
||
print(f"🔍 Spotify modal: playlist_id={widget_playlist_id}, visible={is_visible}, target={playlist_id}")
|
||
|
||
if widget_playlist_id == playlist_id and is_visible:
|
||
print(f"📊 Updating Spotify modal progress: {playlist_id}")
|
||
spotify_modals_found += 1
|
||
widget.on_sync_progress(playlist_id, progress)
|
||
else:
|
||
print(f"🔍 Spotify modal without playlist attribute")
|
||
|
||
# Handle YouTubeDownloadMissingTracksModal
|
||
elif isinstance(widget, YouTubeDownloadMissingTracksModal):
|
||
youtube_modals_found += 1
|
||
if hasattr(widget, 'playlist'):
|
||
widget_playlist_id = getattr(widget.playlist, 'id', 'NO_ID')
|
||
is_visible = widget.isVisible()
|
||
print(f"🔍 YouTube modal #{youtube_modals_found}: playlist_id={widget_playlist_id}, visible={is_visible}, target={playlist_id}")
|
||
|
||
if widget_playlist_id == playlist_id:
|
||
print(f"📊 ✅ Found matching YouTube modal for playlist_id: {playlist_id}, calling on_sync_progress")
|
||
# Update the YouTube modal's progress display (even if hidden)
|
||
widget.on_sync_progress(playlist_id, progress)
|
||
else:
|
||
print(f"📊 ❌ YouTube modal playlist_id mismatch: {widget_playlist_id} vs {playlist_id}")
|
||
else:
|
||
print(f"🔍 YouTube modal #{youtube_modals_found} without playlist attribute")
|
||
|
||
print(f"🔍 Summary: Found {spotify_modals_found} Spotify modals, {youtube_modals_found} YouTube modals total")
|
||
|
||
def update_open_modals_completion(self, playlist_id, result):
|
||
"""Update any open modals for this playlist with sync completion"""
|
||
from PyQt6.QtWidgets import QApplication
|
||
for widget in QApplication.topLevelWidgets():
|
||
# Handle PlaylistDetailsModal
|
||
if (isinstance(widget, PlaylistDetailsModal) and
|
||
hasattr(widget, 'playlist') and
|
||
widget.playlist.id == playlist_id and
|
||
widget.isVisible()):
|
||
# Update the modal's completion display
|
||
widget.on_sync_finished(playlist_id, result)
|
||
|
||
# Handle YouTubeDownloadMissingTracksModal
|
||
elif (isinstance(widget, YouTubeDownloadMissingTracksModal) and
|
||
hasattr(widget, 'playlist') and
|
||
widget.playlist.id == playlist_id):
|
||
# Update the YouTube modal's completion display (even if hidden)
|
||
widget.on_sync_finished(playlist_id, result)
|
||
|
||
def update_open_modals_error(self, playlist_id, error_msg):
|
||
"""Update any open modals for this playlist with sync error"""
|
||
from PyQt6.QtWidgets import QApplication
|
||
for widget in QApplication.topLevelWidgets():
|
||
# Handle PlaylistDetailsModal
|
||
if (isinstance(widget, PlaylistDetailsModal) and
|
||
hasattr(widget, 'playlist') and
|
||
widget.playlist.id == playlist_id and
|
||
widget.isVisible()):
|
||
# Update the modal's error display
|
||
widget.on_sync_error(playlist_id, error_msg)
|
||
|
||
# Handle YouTubeDownloadMissingTracksModal
|
||
elif (isinstance(widget, YouTubeDownloadMissingTracksModal) and
|
||
hasattr(widget, 'playlist') and
|
||
widget.playlist.id == playlist_id):
|
||
# Update the YouTube modal's error display (even if hidden)
|
||
widget.on_sync_error(playlist_id, error_msg)
|
||
|
||
# Add these three methods inside the SyncPage class
|
||
def find_playlist_item_widget(self, playlist_id):
|
||
"""Finds the PlaylistItem widget in the UI that corresponds to a given playlist ID."""
|
||
for i in range(self.playlist_layout.count()):
|
||
item = self.playlist_layout.itemAt(i)
|
||
widget = item.widget()
|
||
if isinstance(widget, PlaylistItem) and widget.playlist.id == playlist_id:
|
||
return widget
|
||
return None
|
||
|
||
def on_download_process_started(self, playlist_id, playlist_item_widget):
|
||
"""Disables refresh button and updates the playlist item UI."""
|
||
print(f"Download process started for playlist: {playlist_id}. Disabling refresh.")
|
||
self.active_download_processes[playlist_id] = playlist_item_widget
|
||
playlist_item_widget.show_operation_status()
|
||
|
||
# Use centralized refresh button management
|
||
self.update_refresh_button_state()
|
||
# --- FIX: Connect the finished signal from the modal ---
|
||
# This ensures that when the modal is finished (or cancelled), the cleanup function is called.
|
||
if playlist_item_widget.download_modal:
|
||
playlist_item_widget.download_modal.process_finished.connect(
|
||
lambda: self.on_download_process_finished(playlist_id)
|
||
)
|
||
|
||
def on_download_process_finished(self, playlist_id):
|
||
"""Re-enables refresh button if no other downloads are active."""
|
||
print(f"Download process finished or cancelled for playlist: {playlist_id}.")
|
||
|
||
# Skip refresh button updates for YouTube workflows (they don't affect Spotify playlist refresh)
|
||
if playlist_id.startswith("youtube_"):
|
||
print(f"Ignoring YouTube workflow finish for refresh button: {playlist_id}")
|
||
return
|
||
|
||
# Clear download modal reference even if not in active_download_processes
|
||
playlist_item_widget = None
|
||
if playlist_id in self.active_download_processes:
|
||
playlist_item_widget = self.active_download_processes.pop(playlist_id)
|
||
else:
|
||
# Find the playlist item widget even if not in active processes
|
||
playlist_item_widget = self.find_playlist_item_widget(playlist_id)
|
||
|
||
# --- FIX: Reset the UI state of the playlist item ---
|
||
if playlist_item_widget:
|
||
playlist_item_widget.download_modal = None
|
||
playlist_item_widget.hide_operation_status()
|
||
|
||
if not self.active_download_processes:
|
||
print("All download processes finished. Re-enabling refresh.")
|
||
# Use centralized refresh button management
|
||
self.update_refresh_button_state()
|
||
|
||
|
||
def showEvent(self, event):
|
||
"""Auto-load playlists when page becomes visible (but not during app startup)"""
|
||
super().showEvent(event)
|
||
|
||
# Only auto-load once and only if we have a spotify client
|
||
if (not self.playlists_loaded and
|
||
self.spotify_client and
|
||
self.spotify_client.is_authenticated()):
|
||
|
||
# Small delay to ensure UI is fully rendered
|
||
QTimer.singleShot(100, self.auto_load_playlists)
|
||
|
||
def auto_load_playlists(self):
|
||
"""Auto-load playlists with proper UI transition"""
|
||
# Clear the welcome state first
|
||
self.clear_playlists()
|
||
|
||
# Clear selection state when auto-loading
|
||
self.selected_playlists.clear()
|
||
self.update_selection_ui()
|
||
|
||
# Start loading (this will set playlists_loaded = True)
|
||
self.load_playlists_async()
|
||
|
||
def show_initial_state(self):
|
||
"""Show initial state with option to load playlists"""
|
||
# Add welcome message to playlist area
|
||
welcome_message = QLabel("Ready to sync playlists!\nClick 'Load Playlists' to get started.")
|
||
welcome_message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
welcome_message.setStyleSheet("""
|
||
QLabel {
|
||
color: #b3b3b3;
|
||
font-size: 16px;
|
||
padding: 60px;
|
||
background: #282828;
|
||
border-radius: 12px;
|
||
border: 1px solid #404040;
|
||
line-height: 1.5;
|
||
}
|
||
""")
|
||
|
||
# Add load button
|
||
load_btn = QPushButton("🎵 Load Playlists")
|
||
load_btn.setFixedSize(200, 50)
|
||
load_btn.clicked.connect(self.load_playlists_async)
|
||
load_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 25px;
|
||
color: #000000;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
margin-top: 20px;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
""")
|
||
|
||
# Add them to the playlist layout
|
||
if hasattr(self, 'playlist_layout'):
|
||
self.playlist_layout.addWidget(welcome_message)
|
||
self.playlist_layout.addWidget(load_btn)
|
||
self.playlist_layout.addStretch()
|
||
|
||
def setup_ui(self):
|
||
self.setStyleSheet("""
|
||
SyncPage {
|
||
background: #191414;
|
||
}
|
||
""")
|
||
|
||
main_layout = QVBoxLayout(self)
|
||
main_layout.setContentsMargins(30, 30, 30, 30)
|
||
main_layout.setSpacing(25)
|
||
|
||
# Header
|
||
header = self.create_header()
|
||
main_layout.addWidget(header)
|
||
|
||
# Content area
|
||
content_layout = QHBoxLayout()
|
||
content_layout.setSpacing(15) # Reduced from 25 to 15 for tighter spacing
|
||
|
||
# Left side - Tabbed playlist section
|
||
playlist_section = self.create_tabbed_playlist_section()
|
||
content_layout.addWidget(playlist_section, 2)
|
||
|
||
# Right side - Options and actions
|
||
right_sidebar = self.create_right_sidebar()
|
||
content_layout.addWidget(right_sidebar, 1)
|
||
|
||
main_layout.addLayout(content_layout, 1) # Allow content to stretch
|
||
|
||
def create_header(self):
|
||
header = QWidget()
|
||
layout = QVBoxLayout(header)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(5)
|
||
|
||
# Title
|
||
title_label = QLabel("Playlist Sync")
|
||
title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold))
|
||
title_label.setStyleSheet("color: #ffffff;")
|
||
|
||
# Subtitle
|
||
# Get active server name for subtitle
|
||
try:
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
server_name = active_server.title() if active_server else "Plex"
|
||
except:
|
||
server_name = "Plex"
|
||
|
||
subtitle_label = QLabel(f"Synchronize your Spotify playlists with {server_name}")
|
||
subtitle_label.setFont(QFont("Arial", 14))
|
||
subtitle_label.setStyleSheet("color: #b3b3b3;")
|
||
|
||
layout.addWidget(title_label)
|
||
layout.addWidget(subtitle_label)
|
||
|
||
return header
|
||
|
||
def create_playlist_section(self):
|
||
section = QWidget()
|
||
layout = QVBoxLayout(section)
|
||
layout.setSpacing(15)
|
||
|
||
# Section header
|
||
header_layout = QHBoxLayout()
|
||
|
||
section_title = QLabel("Spotify Playlists")
|
||
section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
section_title.setStyleSheet("color: #ffffff;")
|
||
|
||
self.refresh_btn = QPushButton("🔄 Refresh")
|
||
self.refresh_btn.setFixedSize(100, 35)
|
||
self.refresh_btn.clicked.connect(self.load_playlists_async)
|
||
self.refresh_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 17px;
|
||
color: #000000;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
QPushButton:pressed {
|
||
background: #1aa34a;
|
||
}
|
||
""")
|
||
|
||
header_layout.addWidget(section_title)
|
||
header_layout.addStretch()
|
||
header_layout.addWidget(self.refresh_btn)
|
||
|
||
# Playlist container
|
||
playlist_container = QScrollArea()
|
||
playlist_container.setWidgetResizable(True)
|
||
playlist_container.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
QScrollBar:vertical {
|
||
background: #282828;
|
||
width: 8px;
|
||
border-radius: 4px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #1db954;
|
||
border-radius: 4px;
|
||
}
|
||
""")
|
||
|
||
self.playlist_widget = QWidget()
|
||
self.playlist_layout = QVBoxLayout(self.playlist_widget)
|
||
self.playlist_layout.setSpacing(10)
|
||
|
||
# Playlists will be loaded asynchronously after UI setup
|
||
|
||
self.playlist_layout.addStretch()
|
||
playlist_container.setWidget(self.playlist_widget)
|
||
|
||
layout.addLayout(header_layout)
|
||
layout.addWidget(playlist_container)
|
||
|
||
return section
|
||
|
||
def create_tabbed_playlist_section(self):
|
||
"""Create tabbed section with Spotify and YouTube playlist tabs"""
|
||
section = QWidget()
|
||
layout = QVBoxLayout(section)
|
||
layout.setSpacing(0)
|
||
|
||
# Create tab widget
|
||
self.playlist_tabs = QTabWidget()
|
||
self.playlist_tabs.setStyleSheet("""
|
||
QTabWidget::pane {
|
||
border: 1px solid #404040;
|
||
border-radius: 8px;
|
||
background: #282828;
|
||
margin: 0px;
|
||
}
|
||
QTabWidget::tab-bar {
|
||
alignment: center;
|
||
}
|
||
QTabBar::tab {
|
||
background: #181818;
|
||
color: #b3b3b3;
|
||
border: 1px solid #404040;
|
||
border-bottom: none;
|
||
border-top-left-radius: 8px;
|
||
border-top-right-radius: 8px;
|
||
padding: 12px 24px;
|
||
margin-right: 2px;
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
min-width: 120px;
|
||
}
|
||
QTabBar::tab:selected {
|
||
background: #1db954;
|
||
color: #000000;
|
||
border-color: #1db954;
|
||
}
|
||
QTabBar::tab:hover:!selected {
|
||
background: #404040;
|
||
color: #ffffff;
|
||
}
|
||
""")
|
||
|
||
# Create Spotify tab (move existing functionality here)
|
||
spotify_tab = self.create_spotify_playlist_tab()
|
||
self.playlist_tabs.addTab(spotify_tab, "Spotify Playlists")
|
||
|
||
# Create Tidal tab
|
||
tidal_tab = self.create_tidal_playlist_tab()
|
||
self.playlist_tabs.addTab(tidal_tab, "Tidal Playlists")
|
||
|
||
# Create YouTube tab (placeholder for now)
|
||
youtube_tab = self.create_youtube_playlist_tab()
|
||
self.playlist_tabs.addTab(youtube_tab, "YouTube Playlists")
|
||
|
||
# Set default to Spotify tab
|
||
self.playlist_tabs.setCurrentIndex(0)
|
||
|
||
layout.addWidget(self.playlist_tabs)
|
||
|
||
return section
|
||
|
||
def create_spotify_playlist_tab(self):
|
||
"""Create the Spotify playlist tab (existing functionality)"""
|
||
tab = QWidget()
|
||
layout = QVBoxLayout(tab)
|
||
layout.setSpacing(15)
|
||
layout.setContentsMargins(15, 15, 15, 15)
|
||
|
||
# Section header (same as before)
|
||
header_layout = QHBoxLayout()
|
||
|
||
section_title = QLabel("Your Spotify Playlists")
|
||
section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
section_title.setStyleSheet("color: #ffffff;")
|
||
|
||
self.refresh_btn = QPushButton("🔄 Refresh")
|
||
self.refresh_btn.setFixedSize(100, 35)
|
||
self.refresh_btn.clicked.connect(self.load_playlists_async)
|
||
self.refresh_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 17px;
|
||
color: #000000;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
QPushButton:pressed {
|
||
background: #1aa34a;
|
||
}
|
||
""")
|
||
|
||
header_layout.addWidget(section_title)
|
||
header_layout.addStretch()
|
||
header_layout.addWidget(self.refresh_btn)
|
||
|
||
# Playlist container (same as before)
|
||
playlist_container = QScrollArea()
|
||
playlist_container.setWidgetResizable(True)
|
||
playlist_container.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
QScrollBar:vertical {
|
||
background: #282828;
|
||
width: 8px;
|
||
border-radius: 4px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #1db954;
|
||
border-radius: 4px;
|
||
}
|
||
""")
|
||
|
||
self.playlist_widget = QWidget()
|
||
self.playlist_layout = QVBoxLayout(self.playlist_widget)
|
||
self.playlist_layout.setSpacing(10)
|
||
|
||
# Playlists will be loaded asynchronously after UI setup
|
||
|
||
self.playlist_layout.addStretch()
|
||
playlist_container.setWidget(self.playlist_widget)
|
||
|
||
layout.addLayout(header_layout)
|
||
layout.addWidget(playlist_container)
|
||
|
||
return tab
|
||
|
||
def create_tidal_playlist_tab(self):
|
||
"""Create the Tidal playlist tab (similar to Spotify but opens discovery modal)"""
|
||
tab = QWidget()
|
||
layout = QVBoxLayout(tab)
|
||
layout.setSpacing(15)
|
||
layout.setContentsMargins(15, 15, 15, 15)
|
||
|
||
# Section header
|
||
header_layout = QHBoxLayout()
|
||
|
||
section_title = QLabel("Your Tidal Playlists")
|
||
section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
section_title.setStyleSheet("color: #ffffff;")
|
||
|
||
self.tidal_refresh_btn = QPushButton("🔄 Refresh")
|
||
self.tidal_refresh_btn.setFixedSize(100, 35)
|
||
self.tidal_refresh_btn.clicked.connect(self.load_tidal_playlists_async)
|
||
self.tidal_refresh_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #ff6600;
|
||
border: none;
|
||
border-radius: 17px;
|
||
color: #ffffff;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #ff7700;
|
||
}
|
||
QPushButton:pressed {
|
||
background: #e55500;
|
||
}
|
||
QPushButton:disabled {
|
||
background: #666666;
|
||
color: #999999;
|
||
}
|
||
""")
|
||
|
||
header_layout.addWidget(section_title)
|
||
header_layout.addStretch()
|
||
header_layout.addWidget(self.tidal_refresh_btn)
|
||
|
||
# Playlist area (scrollable)
|
||
playlist_container = QScrollArea()
|
||
playlist_container.setWidgetResizable(True)
|
||
playlist_container.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
playlist_container.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
playlist_container.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
QScrollBar:vertical {
|
||
background: #2a2a2a;
|
||
width: 12px;
|
||
border-radius: 6px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #555555;
|
||
border-radius: 6px;
|
||
min-height: 20px;
|
||
}
|
||
QScrollBar::handle:vertical:hover {
|
||
background: #666666;
|
||
}
|
||
""")
|
||
|
||
# This will hold all playlist items
|
||
self.tidal_playlist_widget = QWidget()
|
||
self.tidal_playlist_layout = QVBoxLayout(self.tidal_playlist_widget)
|
||
self.tidal_playlist_layout.setSpacing(8)
|
||
self.tidal_playlist_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.tidal_playlist_layout.addStretch() # Push items to top
|
||
|
||
playlist_container.setWidget(self.tidal_playlist_widget)
|
||
|
||
layout.addLayout(header_layout)
|
||
layout.addWidget(playlist_container)
|
||
|
||
return tab
|
||
|
||
def create_youtube_playlist_tab(self):
|
||
"""Create the YouTube playlist tab (placeholder for future implementation)"""
|
||
tab = QWidget()
|
||
layout = QVBoxLayout(tab)
|
||
layout.setSpacing(20)
|
||
layout.setContentsMargins(15, 15, 15, 15)
|
||
|
||
# Header
|
||
header_label = QLabel("YouTube Music Playlists")
|
||
header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
header_label.setStyleSheet("color: #ffffff;")
|
||
|
||
# URL input section
|
||
url_section = QFrame()
|
||
url_section.setStyleSheet("""
|
||
QFrame {
|
||
background: #181818;
|
||
border: 1px solid #404040;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
}
|
||
""")
|
||
|
||
url_layout = QVBoxLayout(url_section)
|
||
url_layout.setSpacing(15)
|
||
|
||
url_label = QLabel("Paste YouTube Music Playlist URL:")
|
||
url_label.setFont(QFont("Arial", 12))
|
||
url_label.setStyleSheet("color: #b3b3b3;")
|
||
|
||
self.youtube_url_input = QLineEdit()
|
||
self.youtube_url_input.setPlaceholderText("https://music.youtube.com/playlist?list=...")
|
||
self.youtube_url_input.setStyleSheet("""
|
||
QLineEdit {
|
||
background: #282828;
|
||
border: 1px solid #404040;
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
color: #ffffff;
|
||
font-size: 12px;
|
||
}
|
||
QLineEdit:focus {
|
||
border-color: #1db954;
|
||
}
|
||
""")
|
||
|
||
self.parse_btn = QPushButton("Parse Playlist")
|
||
self.parse_btn.setFixedHeight(40)
|
||
self.parse_btn.clicked.connect(self.parse_youtube_playlist)
|
||
self.parse_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 20px;
|
||
color: #000000;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
QPushButton:pressed {
|
||
background: #1aa34a;
|
||
}
|
||
QPushButton:disabled {
|
||
background: #404040;
|
||
color: #666666;
|
||
}
|
||
""")
|
||
|
||
url_layout.addWidget(url_label)
|
||
url_layout.addWidget(self.youtube_url_input)
|
||
url_layout.addWidget(self.parse_btn)
|
||
|
||
# Content area that will show placeholder or status widget
|
||
self.youtube_content_area = QFrame()
|
||
self.youtube_content_area.setStyleSheet("""
|
||
QFrame {
|
||
background: #181818;
|
||
border: 1px solid #404040;
|
||
border-radius: 8px;
|
||
}
|
||
""")
|
||
|
||
self.youtube_content_layout = QVBoxLayout(self.youtube_content_area)
|
||
self.youtube_content_layout.setContentsMargins(20, 20, 20, 20)
|
||
|
||
# Initial placeholder content
|
||
self.show_youtube_placeholder()
|
||
|
||
|
||
# Add everything to main layout
|
||
layout.addWidget(header_label)
|
||
layout.addWidget(url_section)
|
||
layout.addWidget(self.youtube_content_area, 1) # Stretch to fill remaining space
|
||
|
||
return tab
|
||
|
||
def show_youtube_placeholder(self):
|
||
"""Show the placeholder content in YouTube tab"""
|
||
# Clear existing content
|
||
for i in reversed(range(self.youtube_content_layout.count())):
|
||
child = self.youtube_content_layout.itemAt(i).widget()
|
||
if child:
|
||
child.setParent(None)
|
||
|
||
placeholder_label = QLabel("YouTube playlist tracks will appear here")
|
||
placeholder_label.setFont(QFont("Arial", 12))
|
||
placeholder_label.setStyleSheet("color: #666666;")
|
||
placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
self.youtube_content_layout.addStretch() # Add stretch before to center
|
||
self.youtube_content_layout.addWidget(placeholder_label)
|
||
self.youtube_content_layout.addStretch() # Add stretch after to center
|
||
|
||
def show_youtube_download_status(self, playlist_name, track_count, playlist_id=None):
|
||
"""Show download status widget in YouTube tab - styled like PlaylistItem"""
|
||
print(f"📋 show_youtube_download_status called with playlist_id: {playlist_id}")
|
||
|
||
if playlist_id is None:
|
||
playlist_id = f"youtube_{hash(playlist_name)}"
|
||
|
||
# If a status widget for this playlist already exists, do nothing.
|
||
if playlist_id in self.youtube_status_widgets:
|
||
print(f"📋 Status widget for {playlist_id} already exists. No action taken.")
|
||
return
|
||
|
||
# --- THE FIX ---
|
||
# The destructive loop that cleared the layout has been removed.
|
||
# By the time this function is called, the placeholder is already gone
|
||
# and the main card container is in place. There is no need to clear anything.
|
||
# This function now only ADDS the status widget, preserving the main card.
|
||
|
||
# Create playlist-style status widget (the "green card")
|
||
status_widget = QFrame()
|
||
status_widget.setFixedHeight(80)
|
||
status_widget.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
QFrame:hover {
|
||
background: #333333;
|
||
border: 1px solid #1db954;
|
||
}
|
||
""")
|
||
status_widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||
|
||
layout = QHBoxLayout(status_widget)
|
||
layout.setContentsMargins(20, 15, 20, 15)
|
||
layout.setSpacing(15)
|
||
|
||
# Status icon (instead of checkbox)
|
||
status_icon = QLabel("🎵")
|
||
status_icon.setFont(QFont("Arial", 18))
|
||
status_icon.setFixedSize(22, 22)
|
||
status_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
# Content section (playlist name and info)
|
||
content_layout = QVBoxLayout()
|
||
content_layout.setSpacing(5)
|
||
|
||
name_label = QLabel(playlist_name)
|
||
name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||
name_label.setStyleSheet("color: #ffffff;")
|
||
|
||
info_layout = QHBoxLayout()
|
||
info_layout.setSpacing(20)
|
||
|
||
track_label = QLabel(f"{track_count} tracks")
|
||
track_label.setFont(QFont("Arial", 10))
|
||
track_label.setStyleSheet("color: #b3b3b3;")
|
||
|
||
status_label = QLabel("Downloading...")
|
||
status_label.setFont(QFont("Arial", 10))
|
||
status_label.setStyleSheet("color: #1db954;")
|
||
|
||
info_layout.addWidget(track_label)
|
||
info_layout.addWidget(status_label)
|
||
info_layout.addStretch()
|
||
|
||
content_layout.addWidget(name_label)
|
||
content_layout.addLayout(info_layout)
|
||
|
||
# View Progress button
|
||
view_progress_btn = QPushButton("View Progress")
|
||
view_progress_btn.setFixedSize(120, 30)
|
||
view_progress_btn.clicked.connect(lambda: self.open_youtube_download_modal(playlist_id))
|
||
view_progress_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: transparent;
|
||
border: 1px solid #1db954;
|
||
border-radius: 15px;
|
||
color: #1db954;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1db954;
|
||
color: #000000;
|
||
}
|
||
""")
|
||
|
||
layout.addWidget(status_icon)
|
||
layout.addLayout(content_layout)
|
||
layout.addStretch()
|
||
layout.addWidget(view_progress_btn)
|
||
|
||
# Store widget reference and add it to the top of the layout
|
||
self.youtube_status_widgets[playlist_id] = status_widget
|
||
self.youtube_content_layout.insertWidget(0, status_widget)
|
||
|
||
|
||
def open_youtube_download_modal(self, playlist_id):
|
||
"""Open the YouTube download modal when View Progress button is clicked"""
|
||
print(f"🔍 Attempting to open modal for playlist_id: {playlist_id}")
|
||
print(f"🔍 Available modals: {list(self.active_youtube_download_modals.keys())}")
|
||
|
||
if playlist_id in self.active_youtube_download_modals:
|
||
modal = self.active_youtube_download_modals[playlist_id]
|
||
print(f"✅ Found modal, opening...")
|
||
modal.show()
|
||
modal.raise_()
|
||
modal.activateWindow()
|
||
else:
|
||
print(f"❌ No modal found for playlist_id: {playlist_id}")
|
||
|
||
def create_right_sidebar(self):
|
||
section = QWidget()
|
||
layout = QVBoxLayout(section)
|
||
layout.setSpacing(20)
|
||
|
||
# Action buttons
|
||
actions_frame = QFrame()
|
||
actions_frame.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
actions_layout = QVBoxLayout(actions_frame)
|
||
actions_layout.setContentsMargins(20, 20, 20, 20)
|
||
actions_layout.setSpacing(15)
|
||
|
||
# Selection info label
|
||
self.selection_info = QLabel("Select playlists to sync")
|
||
self.selection_info.setFont(QFont("Arial", 12))
|
||
self.selection_info.setStyleSheet("color: #b3b3b3;")
|
||
self.selection_info.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
# Sync button (initially disabled)
|
||
self.start_sync_btn = QPushButton("Start Sync")
|
||
self.start_sync_btn.setFixedHeight(45)
|
||
self.start_sync_btn.setEnabled(False) # Disabled by default
|
||
self.start_sync_btn.clicked.connect(self.start_selected_playlist_sync)
|
||
self.start_sync_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 22px;
|
||
color: #000000;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover:enabled {
|
||
background: #1ed760;
|
||
}
|
||
QPushButton:pressed:enabled {
|
||
background: #1aa34a;
|
||
}
|
||
QPushButton:disabled {
|
||
background: #404040;
|
||
color: #666666;
|
||
}
|
||
""")
|
||
|
||
actions_layout.addWidget(self.selection_info)
|
||
actions_layout.addWidget(self.start_sync_btn)
|
||
|
||
layout.addWidget(actions_frame)
|
||
|
||
# Progress section below buttons
|
||
progress_section = self.create_progress_section()
|
||
layout.addWidget(progress_section, 1) # Allow progress section to stretch
|
||
|
||
return section
|
||
|
||
def create_progress_section(self):
|
||
section = QFrame()
|
||
section.setMinimumHeight(200) # Set minimum height instead of fixed
|
||
section.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(section)
|
||
layout.setContentsMargins(20, 15, 20, 15)
|
||
layout.setSpacing(10)
|
||
|
||
# Progress header
|
||
progress_header = QLabel("Sync Progress")
|
||
progress_header.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
||
progress_header.setStyleSheet("color: #ffffff;")
|
||
|
||
# Progress bar
|
||
self.progress_bar = QProgressBar()
|
||
self.progress_bar.setFixedHeight(8)
|
||
self.progress_bar.setStyleSheet("""
|
||
QProgressBar {
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: #404040;
|
||
}
|
||
QProgressBar::chunk {
|
||
background: #1db954;
|
||
border-radius: 4px;
|
||
}
|
||
""")
|
||
|
||
# Progress text
|
||
self.progress_text = QLabel("Ready to sync...")
|
||
self.progress_text.setFont(QFont("Arial", 11))
|
||
self.progress_text.setStyleSheet("color: #b3b3b3;")
|
||
|
||
# Log area
|
||
self.log_area = QTextEdit()
|
||
self.log_area.setMinimumHeight(80) # Set minimum height instead of maximum
|
||
|
||
# Override append method to limit to 200 lines
|
||
original_append = self.log_area.append
|
||
def limited_append(text):
|
||
original_append(text)
|
||
# Keep only last 200 lines
|
||
text_content = self.log_area.toPlainText()
|
||
lines = text_content.split('\n')
|
||
if len(lines) > 200:
|
||
trimmed_lines = lines[-200:]
|
||
self.log_area.setPlainText('\n'.join(trimmed_lines))
|
||
# Move cursor to end
|
||
cursor = self.log_area.textCursor()
|
||
cursor.movePosition(cursor.MoveOperation.End)
|
||
self.log_area.setTextCursor(cursor)
|
||
self.log_area.append = limited_append
|
||
|
||
self.log_area.setStyleSheet("""
|
||
QTextEdit {
|
||
background: #181818;
|
||
border: 1px solid #404040;
|
||
border-radius: 4px;
|
||
color: #ffffff;
|
||
font-size: 10px;
|
||
font-family: monospace;
|
||
}
|
||
""")
|
||
self.log_area.setPlainText("Waiting for sync to start...")
|
||
|
||
layout.addWidget(progress_header)
|
||
layout.addWidget(self.progress_bar)
|
||
layout.addWidget(self.progress_text)
|
||
layout.addWidget(self.log_area, 1) # Allow log area to stretch
|
||
|
||
return section
|
||
|
||
def load_playlists_async(self):
|
||
"""Start asynchronous playlist loading"""
|
||
if self.playlist_loader and self.playlist_loader.isRunning():
|
||
return
|
||
|
||
# Mark as loaded to prevent duplicate auto-loading
|
||
self.playlists_loaded = True
|
||
|
||
# Clear existing playlists
|
||
self.clear_playlists()
|
||
|
||
# Clear selection state when refreshing
|
||
self.selected_playlists.clear()
|
||
self.update_selection_ui()
|
||
|
||
# Add loading placeholder
|
||
loading_label = QLabel("🔄 Loading playlists...")
|
||
loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
loading_label.setStyleSheet("""
|
||
QLabel {
|
||
color: #b3b3b3;
|
||
font-size: 14px;
|
||
padding: 40px;
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
self.playlist_layout.insertWidget(0, loading_label)
|
||
|
||
# Show loading state
|
||
self.refresh_btn.setText("🔄 Loading...")
|
||
self.refresh_btn.setEnabled(False)
|
||
self.log_area.append("Starting playlist loading...")
|
||
|
||
# Create and start loader thread
|
||
self.playlist_loader = PlaylistLoaderThread(self.spotify_client)
|
||
self.playlist_loader.playlist_loaded.connect(self.add_playlist_to_ui)
|
||
self.playlist_loader.loading_finished.connect(self.on_loading_finished)
|
||
self.playlist_loader.loading_failed.connect(self.on_loading_failed)
|
||
self.playlist_loader.progress_updated.connect(self.update_progress)
|
||
self.playlist_loader.start()
|
||
|
||
def add_playlist_to_ui(self, playlist):
|
||
"""Add a single playlist to the UI as it's loaded"""
|
||
# Start with simple sync status to avoid datetime operations during loading
|
||
sync_status = "Checking..."
|
||
item = PlaylistItem(playlist.name, playlist.total_tracks, sync_status, playlist, self)
|
||
|
||
# Queue sync status update for after UI creation
|
||
QTimer.singleShot(0, lambda: self.update_playlist_sync_status(item, playlist))
|
||
item.view_details_clicked.connect(self.show_playlist_details)
|
||
|
||
# Add subtle fade-in animation
|
||
item.setStyleSheet(item.styleSheet() + "background: rgba(40, 40, 40, 0);")
|
||
|
||
# Insert before the stretch item
|
||
self.playlist_layout.insertWidget(self.playlist_layout.count() - 1, item)
|
||
self.current_playlists.append(playlist)
|
||
|
||
# Animate the item appearing
|
||
self.animate_item_fade_in(item)
|
||
|
||
# Update log
|
||
self.log_area.append(f"Added playlist: {playlist.name} ({playlist.total_tracks} tracks)")
|
||
|
||
def update_playlist_sync_status(self, playlist_item, playlist):
|
||
"""Update playlist sync status after UI creation to avoid blocking"""
|
||
try:
|
||
sync_info = self.sync_statuses.get(playlist.id)
|
||
sync_status = "Never Synced"
|
||
|
||
if sync_info and 'last_synced' in sync_info:
|
||
current_snapshot_id = getattr(playlist, 'snapshot_id', None)
|
||
stored_snapshot_id = sync_info.get('snapshot_id')
|
||
|
||
if current_snapshot_id and stored_snapshot_id and current_snapshot_id != stored_snapshot_id:
|
||
sync_status = "Needs Sync"
|
||
else:
|
||
try:
|
||
last_synced_dt = datetime.fromisoformat(sync_info['last_synced'])
|
||
sync_status = f"Synced: {last_synced_dt.strftime('%b %d, %H:%M')}"
|
||
except (ValueError, KeyError):
|
||
sync_status = "Synced (legacy)"
|
||
|
||
# Update the playlist item's sync status
|
||
playlist_item.update_sync_status_text(sync_status)
|
||
except Exception as e:
|
||
# Fallback to simple status if anything goes wrong
|
||
playlist_item.update_sync_status_text("Unknown")
|
||
|
||
def animate_item_fade_in(self, item):
|
||
"""Add a subtle fade-in animation to playlist items"""
|
||
# Start with reduced opacity
|
||
item.setStyleSheet("""
|
||
PlaylistItem {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
opacity: 0.3;
|
||
}
|
||
PlaylistItem:hover {
|
||
background: #333333;
|
||
border: 1px solid #1db954;
|
||
}
|
||
""")
|
||
|
||
# Animate to full opacity after a short delay
|
||
QTimer.singleShot(50, lambda: item.setStyleSheet("""
|
||
PlaylistItem {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
PlaylistItem:hover {
|
||
background: #333333;
|
||
border: 1px solid #1db954;
|
||
}
|
||
"""))
|
||
|
||
def on_loading_finished(self, count):
|
||
"""Handle completion of playlist loading"""
|
||
# Remove loading placeholder if it exists
|
||
for i in range(self.playlist_layout.count()):
|
||
item = self.playlist_layout.itemAt(i)
|
||
if item and item.widget() and isinstance(item.widget(), QLabel):
|
||
if "Loading playlists" in item.widget().text():
|
||
item.widget().deleteLater()
|
||
break
|
||
|
||
self.refresh_btn.setText("🔄 Refresh")
|
||
self.refresh_btn.setEnabled(True)
|
||
self.log_area.append(f"✓ Loaded {count} Spotify playlists successfully")
|
||
|
||
# Start background preloading of tracks for smaller playlists
|
||
self.start_background_preloading()
|
||
|
||
def start_background_preloading(self):
|
||
"""Start background preloading of tracks for smaller playlists"""
|
||
if not self.spotify_client:
|
||
return
|
||
|
||
# Preload tracks for playlists with < 100 tracks to improve responsiveness
|
||
for playlist in self.current_playlists:
|
||
if (playlist.total_tracks < 100 and
|
||
playlist.id not in self.track_cache and
|
||
not playlist.tracks):
|
||
|
||
# Create background worker
|
||
worker = TrackLoadingWorker(self.spotify_client, playlist.id, playlist.name)
|
||
worker.signals.tracks_loaded.connect(self.on_background_tracks_loaded)
|
||
# Don't connect error signals for background loading to avoid spam
|
||
|
||
# Submit with low priority
|
||
self.thread_pool.start(worker)
|
||
|
||
# Add delay between requests to be nice to Spotify API
|
||
QTimer.singleShot(2000, lambda: None) # 2 second delay
|
||
|
||
def on_background_tracks_loaded(self, playlist_id, tracks):
|
||
"""Handle background track loading completion"""
|
||
# Cache the tracks for future use
|
||
self.track_cache[playlist_id] = tracks
|
||
|
||
# Update the playlist object if we can find it
|
||
for playlist in self.current_playlists:
|
||
if playlist.id == playlist_id:
|
||
playlist.tracks = tracks
|
||
break
|
||
|
||
def on_loading_failed(self, error_msg):
|
||
"""Handle playlist loading failure"""
|
||
# Remove loading placeholder if it exists
|
||
for i in range(self.playlist_layout.count()):
|
||
item = self.playlist_layout.itemAt(i)
|
||
if item and item.widget() and isinstance(item.widget(), QLabel):
|
||
if "Loading playlists" in item.widget().text():
|
||
item.widget().deleteLater()
|
||
break
|
||
|
||
self.refresh_btn.setText("🔄 Refresh")
|
||
self.refresh_btn.setEnabled(True)
|
||
self.log_area.append(f"✗ Failed to load playlists: {error_msg}")
|
||
QMessageBox.critical(self, "Error", f"Failed to load playlists: {error_msg}")
|
||
|
||
def update_progress(self, message):
|
||
"""Update progress text"""
|
||
self.log_area.append(message)
|
||
|
||
def load_tidal_playlists_async(self):
|
||
"""Start asynchronous Tidal playlist loading"""
|
||
if self.tidal_playlist_loader and self.tidal_playlist_loader.isRunning():
|
||
return
|
||
|
||
# Complete cleanup of all Tidal operations before refresh
|
||
self.cleanup_all_tidal_operations()
|
||
|
||
# Clear existing Tidal playlists
|
||
self.clear_tidal_playlists()
|
||
|
||
# Add loading placeholder
|
||
loading_label = QLabel("🔄 Loading Tidal playlists...")
|
||
loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
loading_label.setStyleSheet("""
|
||
QLabel {
|
||
color: #b3b3b3;
|
||
font-size: 14px;
|
||
padding: 40px;
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
self.tidal_playlist_layout.insertWidget(0, loading_label)
|
||
|
||
# Show loading state
|
||
self.tidal_refresh_btn.setText("🔄 Loading...")
|
||
self.tidal_refresh_btn.setEnabled(False)
|
||
self.log_area.append("Starting Tidal playlist loading...")
|
||
|
||
# Create and start loader thread
|
||
self.tidal_playlist_loader = TidalPlaylistLoaderThread(self.tidal_client)
|
||
self.tidal_playlist_loader.playlist_loaded.connect(self.add_tidal_playlist_to_ui)
|
||
self.tidal_playlist_loader.loading_finished.connect(self.on_tidal_loading_finished)
|
||
self.tidal_playlist_loader.loading_failed.connect(self.on_tidal_loading_failed)
|
||
self.tidal_playlist_loader.progress_updated.connect(self.update_progress)
|
||
self.tidal_playlist_loader.start()
|
||
|
||
def add_tidal_playlist_to_ui(self, playlist):
|
||
"""Add a single Tidal playlist to the UI as it's loaded"""
|
||
# Create a TidalPlaylistCard that matches YouTube card workflow
|
||
card = TidalPlaylistCard(playlist.id, playlist.name, len(playlist.tracks) if hasattr(playlist, 'tracks') else 0, self)
|
||
|
||
# Store card reference
|
||
self.tidal_cards[playlist.id] = card
|
||
|
||
# Initialize state tracking
|
||
self.tidal_playlist_states[playlist.id] = {
|
||
'phase': 'discovering',
|
||
'playlist_data': None,
|
||
'discovered_tracks': [],
|
||
'card': card,
|
||
'discovery_modal': None,
|
||
'download_modal': None,
|
||
'original_name': playlist.name, # Store original name for resets
|
||
'original_track_count': len(playlist.tracks) if hasattr(playlist, 'tracks') else 0
|
||
}
|
||
|
||
# Add to layout and store reference
|
||
self.tidal_playlist_layout.insertWidget(self.tidal_playlist_layout.count() - 1, card)
|
||
self.current_tidal_playlists.append(playlist)
|
||
|
||
# Connect to click handler (new card-based system)
|
||
card.card_clicked.connect(self.on_tidal_card_clicked)
|
||
|
||
def on_tidal_loading_finished(self, count):
|
||
"""Handle completion of Tidal playlist loading"""
|
||
# Remove loading placeholder if it exists
|
||
for i in range(self.tidal_playlist_layout.count()):
|
||
item = self.tidal_playlist_layout.itemAt(i)
|
||
if item and item.widget() and isinstance(item.widget(), QLabel):
|
||
if "Loading Tidal playlists" in item.widget().text():
|
||
item.widget().deleteLater()
|
||
break
|
||
|
||
self.tidal_refresh_btn.setText("🔄 Refresh")
|
||
self.tidal_refresh_btn.setEnabled(True)
|
||
self.log_area.append(f"✓ Loaded {count} Tidal playlists successfully")
|
||
|
||
def on_tidal_loading_failed(self, error_msg):
|
||
"""Handle Tidal playlist loading failure"""
|
||
# Remove loading placeholder if it exists
|
||
for i in range(self.tidal_playlist_layout.count()):
|
||
item = self.tidal_playlist_layout.itemAt(i)
|
||
if item and item.widget() and isinstance(item.widget(), QLabel):
|
||
if "Loading Tidal playlists" in item.widget().text():
|
||
item.widget().deleteLater()
|
||
break
|
||
|
||
self.tidal_refresh_btn.setText("🔄 Refresh")
|
||
self.tidal_refresh_btn.setEnabled(True)
|
||
self.log_area.append(f"✗ Failed to load Tidal playlists: {error_msg}")
|
||
QMessageBox.critical(self, "Error", f"Failed to load Tidal playlists: {error_msg}")
|
||
|
||
def cleanup_all_tidal_operations(self):
|
||
"""Complete cleanup of all Tidal operations - stop workers, close modals, cancel syncs"""
|
||
print("🧹 Starting complete Tidal cleanup for refresh...")
|
||
|
||
# Close and cleanup all active Tidal modals
|
||
for playlist_id, state in list(self.tidal_playlist_states.items()):
|
||
# Close discovery modals
|
||
discovery_modal = state.get('discovery_modal')
|
||
if discovery_modal:
|
||
print(f"🔍 Closing Tidal discovery modal for playlist_id: {playlist_id}")
|
||
try:
|
||
# Cancel any active workers in the discovery modal
|
||
if hasattr(discovery_modal, 'spotify_worker') and discovery_modal.spotify_worker:
|
||
discovery_modal.spotify_worker.cancel()
|
||
discovery_modal.spotify_worker = None
|
||
|
||
# Cancel any active sync operations
|
||
if hasattr(discovery_modal, 'sync_in_progress') and discovery_modal.sync_in_progress:
|
||
if hasattr(self, 'cancel_playlist_sync') and hasattr(discovery_modal, 'playlist'):
|
||
self.cancel_playlist_sync(discovery_modal.playlist.id)
|
||
|
||
# Force close the modal
|
||
discovery_modal.close()
|
||
except Exception as e:
|
||
print(f"⚠️ Error closing discovery modal: {e}")
|
||
|
||
# Close download modals
|
||
download_modal = state.get('download_modal')
|
||
if download_modal:
|
||
print(f"📥 Closing Tidal download modal for playlist_id: {playlist_id}")
|
||
try:
|
||
# Cancel all operations (downloads, searches, etc.)
|
||
download_modal.cancel_operations()
|
||
|
||
# Cancel any additional search workers that might be running
|
||
if hasattr(download_modal, 'parallel_search_tracking'):
|
||
download_modal.parallel_search_tracking.clear()
|
||
|
||
# Stop any active timers
|
||
if hasattr(download_modal, 'download_status_timer'):
|
||
download_modal.download_status_timer.stop()
|
||
|
||
# Clear any queued operations
|
||
if hasattr(download_modal, 'active_downloads'):
|
||
download_modal.active_downloads.clear()
|
||
|
||
# Force close the modal
|
||
download_modal.close()
|
||
except Exception as e:
|
||
print(f"⚠️ Error closing download modal: {e}")
|
||
|
||
# Cancel any active sync workers for Tidal playlists
|
||
tidal_playlist_ids = set()
|
||
for playlist_id, state in self.tidal_playlist_states.items():
|
||
playlist_data = state.get('playlist_data')
|
||
if playlist_data and hasattr(playlist_data, 'id'):
|
||
tidal_playlist_ids.add(playlist_data.id)
|
||
|
||
# Cancel sync workers
|
||
for playlist_id in tidal_playlist_ids:
|
||
if playlist_id in self.active_sync_workers:
|
||
print(f"🔄 Cancelling sync worker for Tidal playlist_id: {playlist_id}")
|
||
try:
|
||
worker = self.active_sync_workers[playlist_id]
|
||
if hasattr(worker, 'cancel'):
|
||
worker.cancel()
|
||
del self.active_sync_workers[playlist_id]
|
||
except Exception as e:
|
||
print(f"⚠️ Error cancelling sync worker: {e}")
|
||
|
||
# Remove from active download modals (shared with YouTube)
|
||
for playlist_id in list(self.active_youtube_download_modals.keys()):
|
||
modal = self.active_youtube_download_modals[playlist_id]
|
||
if hasattr(modal, 'is_tidal_playlist') and modal.is_tidal_playlist:
|
||
print(f"📥 Removing Tidal download modal from active list: {playlist_id}")
|
||
try:
|
||
del self.active_youtube_download_modals[playlist_id]
|
||
except Exception as e:
|
||
print(f"⚠️ Error removing download modal: {e}")
|
||
|
||
# Force cleanup of any remaining thread pool operations
|
||
# This ensures any lingering search workers are properly terminated
|
||
try:
|
||
thread_pool = QThreadPool.globalInstance()
|
||
# Note: QThreadPool doesn't have a direct "cancel all" method,
|
||
# but setting cancel_requested=True in the modals should make workers exit gracefully
|
||
print(f"🔧 Thread pool active count: {thread_pool.activeThreadCount()}")
|
||
if thread_pool.activeThreadCount() > 0:
|
||
print("⏳ Waiting briefly for thread pool workers to finish gracefully...")
|
||
# Give workers a moment to see the cancel_requested flag and exit
|
||
from PyQt6.QtCore import QTimer, QEventLoop
|
||
loop = QEventLoop()
|
||
QTimer.singleShot(500, loop.quit) # 500ms timeout
|
||
loop.exec()
|
||
except Exception as e:
|
||
print(f"⚠️ Error during thread pool cleanup: {e}")
|
||
|
||
print("✅ Tidal cleanup complete")
|
||
|
||
def clear_tidal_playlists(self):
|
||
"""Clear all Tidal playlist items from UI"""
|
||
for i in reversed(range(self.tidal_playlist_layout.count())):
|
||
layout_item = self.tidal_playlist_layout.itemAt(i)
|
||
if layout_item:
|
||
widget = layout_item.widget()
|
||
if widget:
|
||
# Remove TidalPlaylistCard widgets and skip static UI elements
|
||
# (like refresh buttons, labels, etc.)
|
||
if hasattr(widget, 'playlist_id') or isinstance(widget, TidalPlaylistCard):
|
||
widget.setParent(None)
|
||
self.current_tidal_playlists.clear()
|
||
|
||
# Clear the state tracking as well
|
||
self.tidal_cards.clear()
|
||
self.tidal_playlist_states.clear()
|
||
|
||
def on_tidal_card_clicked(self, playlist_id: str, phase: str):
|
||
"""Handle Tidal playlist card clicks - route to appropriate modal (matches YouTube workflow)"""
|
||
print(f"🎵 Tidal card clicked: playlist_id={playlist_id}, Phase={phase}")
|
||
|
||
state = self.get_tidal_playlist_state(playlist_id)
|
||
if not state:
|
||
print(f"⚠️ No state found for playlist_id: {playlist_id}")
|
||
return
|
||
|
||
# Route to appropriate modal based on current phase
|
||
if phase in ['discovering', 'discovery_complete']:
|
||
self.open_or_create_tidal_discovery_modal(playlist_id, state)
|
||
elif phase in ['sync_complete', 'downloading', 'download_complete']:
|
||
# For sync_complete phase, open discovery modal with "Download Missing" button
|
||
if phase == 'sync_complete':
|
||
self.open_or_create_tidal_discovery_modal(playlist_id, state)
|
||
else:
|
||
# For downloading/download_complete phases, check if download modal actually exists
|
||
# If not, route back to discovery modal (handles case where download modal was closed)
|
||
playlist_data = state.get('playlist_data')
|
||
download_modal = state.get('download_modal')
|
||
if download_modal and not download_modal.isVisible():
|
||
# Modal exists but is hidden - show it
|
||
print(f"📍 Reopening hidden Tidal download modal for playlist_id: {playlist_id}")
|
||
download_modal.show()
|
||
download_modal.activateWindow()
|
||
download_modal.raise_()
|
||
elif download_modal and download_modal.isVisible():
|
||
# Modal is already visible - bring to front
|
||
print(f"📍 Bringing visible Tidal download modal to front for playlist_id: {playlist_id}")
|
||
download_modal.activateWindow()
|
||
download_modal.raise_()
|
||
else:
|
||
print(f"📍 No download modal found, routing to discovery modal instead")
|
||
self.open_or_create_tidal_discovery_modal(playlist_id, state)
|
||
elif phase == 'syncing':
|
||
# Show sync progress - route to discovery modal
|
||
self.open_or_create_tidal_discovery_modal(playlist_id, state)
|
||
|
||
def open_or_create_tidal_discovery_modal(self, playlist_id: str, state: dict):
|
||
"""Open or create the discovery modal for a Tidal playlist"""
|
||
# Check if modal already exists and is visible
|
||
if state.get('discovery_modal') and state['discovery_modal'].isVisible():
|
||
state['discovery_modal'].activateWindow()
|
||
state['discovery_modal'].raise_()
|
||
return
|
||
|
||
# Check if modal exists but is hidden - reopen it
|
||
if state.get('discovery_modal') and not state['discovery_modal'].isVisible():
|
||
print(f"🔍 Reopening existing hidden discovery modal for playlist_id: {playlist_id}")
|
||
state['discovery_modal'].show()
|
||
state['discovery_modal'].activateWindow()
|
||
state['discovery_modal'].raise_()
|
||
return
|
||
|
||
# Check if we have playlist data already (discovery_complete state)
|
||
if state.get('playlist_data') and state['phase'] == 'discovery_complete':
|
||
print(f"🔍 Opening existing discovery modal with data for playlist_id: {playlist_id}")
|
||
|
||
# Create a new modal with the existing data
|
||
dummy_playlist_item = type('DummyPlaylistItem', (), {
|
||
'playlist_name': state['playlist_data'].name,
|
||
'track_count': len(state['playlist_data'].tracks),
|
||
'download_modal': None,
|
||
'show_operation_status': lambda self, status_text="View Progress": None,
|
||
'hide_operation_status': lambda self: None
|
||
})()
|
||
|
||
# Create the discovery modal using the existing data
|
||
modal = YouTubeDownloadMissingTracksModal(
|
||
state['playlist_data'],
|
||
dummy_playlist_item,
|
||
self,
|
||
self.downloads_page
|
||
)
|
||
|
||
# Mark this as a Tidal workflow
|
||
modal.is_tidal_playlist = True
|
||
modal.tidal_playlist = state['playlist_data']
|
||
modal.playlist_id = playlist_id # For state tracking
|
||
|
||
# Store modal reference in state
|
||
state['discovery_modal'] = modal
|
||
|
||
# Show the modal
|
||
modal.show()
|
||
modal.activateWindow()
|
||
modal.raise_()
|
||
return
|
||
|
||
# Need to discover playlist data first
|
||
print(f"🔍 Need to discover playlist data for playlist_id: {playlist_id}")
|
||
|
||
# Get playlist data if not cached
|
||
playlist_data = state.get('playlist_data')
|
||
if not playlist_data:
|
||
# Try to get playlist from current loaded playlists
|
||
playlist_data = None
|
||
for playlist in self.current_tidal_playlists:
|
||
if hasattr(playlist, 'id') and playlist.id == playlist_id:
|
||
playlist_data = playlist
|
||
break
|
||
|
||
if not playlist_data:
|
||
print(f"❌ Could not find playlist data for playlist_id: {playlist_id}")
|
||
return
|
||
|
||
# Get full playlist data with tracks if not already loaded
|
||
if not hasattr(playlist_data, 'tracks') or not playlist_data.tracks:
|
||
try:
|
||
full_playlist = self.tidal_client.get_playlist(playlist_id)
|
||
if full_playlist and full_playlist.tracks:
|
||
playlist_data = full_playlist
|
||
else:
|
||
print(f"❌ Failed to load tracks for Tidal playlist {playlist_id}")
|
||
QMessageBox.warning(self, "Error", f"Failed to load tracks for playlist")
|
||
return
|
||
except Exception as e:
|
||
print(f"❌ Error loading Tidal playlist tracks: {e}")
|
||
QMessageBox.warning(self, "Error", f"Error loading playlist tracks: {str(e)}")
|
||
return
|
||
|
||
# Create a dummy playlist item for the modal
|
||
dummy_playlist_item = type('DummyPlaylistItem', (), {
|
||
'playlist_name': playlist_data.name,
|
||
'track_count': len(playlist_data.tracks),
|
||
'download_modal': None,
|
||
'show_operation_status': lambda self, status_text="View Progress": None,
|
||
'hide_operation_status': lambda self: None
|
||
})()
|
||
|
||
# Create the discovery modal
|
||
modal = YouTubeDownloadMissingTracksModal(
|
||
playlist_data,
|
||
dummy_playlist_item,
|
||
self,
|
||
self.downloads_page
|
||
)
|
||
|
||
# Mark this as a Tidal workflow
|
||
modal.is_tidal_playlist = True
|
||
modal.tidal_playlist = playlist_data
|
||
modal.playlist_id = playlist_id # For state tracking
|
||
|
||
# Store playlist data and modal reference in state
|
||
state['playlist_data'] = playlist_data
|
||
state['discovery_modal'] = modal
|
||
|
||
# Show the modal
|
||
modal.show()
|
||
modal.activateWindow()
|
||
modal.raise_()
|
||
|
||
print(f"✅ Opened discovery modal for Tidal playlist '{playlist_data.name}' with {len(playlist_data.tracks)} tracks")
|
||
|
||
def open_or_create_tidal_download_modal(self, playlist_id: str, state: dict):
|
||
"""Open or create the download modal for a Tidal playlist"""
|
||
playlist_data = state.get('playlist_data')
|
||
if not playlist_data:
|
||
print(f"⚠️ No playlist data found for download modal")
|
||
return
|
||
|
||
# Check if download modal already exists
|
||
if hasattr(playlist_data, 'id') and playlist_data.id in self.active_youtube_download_modals:
|
||
modal = self.active_youtube_download_modals[playlist_data.id]
|
||
if modal.isVisible():
|
||
modal.activateWindow()
|
||
modal.raise_()
|
||
return
|
||
else:
|
||
# Modal exists but is hidden - show it
|
||
modal.show()
|
||
modal.activateWindow()
|
||
modal.raise_()
|
||
return
|
||
|
||
# Need to create new download modal - route back to discovery modal for now
|
||
print(f"📍 No download modal found, routing to discovery modal")
|
||
self.open_or_create_tidal_discovery_modal(playlist_id, state)
|
||
|
||
def on_tidal_playlist_clicked(self, playlist):
|
||
"""Legacy method for old TidalPlaylistItem - route to card system"""
|
||
print(f"🎵 Legacy Tidal playlist clicked: {playlist.name} - routing to card system")
|
||
|
||
# For now, create a temporary discovery modal (this should be replaced when cards are fully integrated)
|
||
# Get full playlist data with tracks if not already loaded
|
||
if not hasattr(playlist, 'tracks') or not playlist.tracks:
|
||
try:
|
||
full_playlist = self.tidal_client.get_playlist(playlist.id)
|
||
if full_playlist and full_playlist.tracks:
|
||
playlist = full_playlist
|
||
else:
|
||
print(f"❌ Failed to load tracks for Tidal playlist {playlist.name}")
|
||
QMessageBox.warning(self, "Error", f"Failed to load tracks for playlist '{playlist.name}'")
|
||
return
|
||
except Exception as e:
|
||
print(f"❌ Error loading Tidal playlist tracks: {e}")
|
||
QMessageBox.warning(self, "Error", f"Error loading playlist tracks: {str(e)}")
|
||
return
|
||
|
||
# Create a dummy playlist item for the modal (similar to YouTube workflow)
|
||
dummy_playlist_item = type('DummyPlaylistItem', (), {
|
||
'playlist_name': playlist.name,
|
||
'track_count': len(playlist.tracks),
|
||
'download_modal': None,
|
||
'show_operation_status': lambda self, status_text="View Progress": None,
|
||
'hide_operation_status': lambda self: None
|
||
})()
|
||
|
||
# Create the discovery modal using the YouTube modal class
|
||
# (it works for any track discovery workflow)
|
||
modal = YouTubeDownloadMissingTracksModal(
|
||
playlist,
|
||
dummy_playlist_item,
|
||
self,
|
||
self.downloads_page
|
||
)
|
||
|
||
# Mark this as a Tidal workflow so it uses the Tidal discovery worker
|
||
modal.is_tidal_playlist = True
|
||
modal.tidal_playlist = playlist
|
||
|
||
# Show the modal
|
||
modal.show()
|
||
modal.activateWindow()
|
||
modal.raise_()
|
||
|
||
print(f"✅ Opened discovery modal for Tidal playlist '{playlist.name}' with {len(playlist.tracks)} tracks")
|
||
|
||
def disable_refresh_button(self, operation_name="Operation"):
|
||
"""Disable refresh button during sync/download operations"""
|
||
self.refresh_btn.setEnabled(False)
|
||
self.refresh_btn.setText(f"🔄 {operation_name}...")
|
||
|
||
def enable_refresh_button(self):
|
||
"""Re-enable refresh button after operations complete"""
|
||
self.refresh_btn.setEnabled(True)
|
||
self.refresh_btn.setText("🔄 Refresh")
|
||
|
||
def has_active_operations(self):
|
||
"""Check if any sync or download operations are currently active"""
|
||
has_downloads = bool(self.active_download_processes)
|
||
has_individual_syncs = bool(self.active_sync_workers)
|
||
has_sequential_sync = self.is_sequential_syncing or self.sequential_sync_worker is not None
|
||
|
||
print(f"DEBUG: Active operations check - downloads: {has_downloads}, individual syncs: {has_individual_syncs}, sequential: {has_sequential_sync}")
|
||
return has_downloads or has_individual_syncs or has_sequential_sync
|
||
|
||
def update_refresh_button_state(self):
|
||
"""Update refresh button state based on active operations"""
|
||
if self.has_active_operations():
|
||
if self.is_sequential_syncing:
|
||
self.disable_refresh_button("Sequential Sync")
|
||
elif self.active_sync_workers:
|
||
self.disable_refresh_button("Sync")
|
||
elif self.active_download_processes:
|
||
self.disable_refresh_button("Download")
|
||
else:
|
||
self.enable_refresh_button()
|
||
|
||
def load_initial_playlists(self):
|
||
"""Load initial playlist data (placeholder or real)"""
|
||
if self.spotify_client and self.spotify_client.is_authenticated():
|
||
self.refresh_playlists()
|
||
else:
|
||
# Show placeholder playlists
|
||
playlists = [
|
||
("Liked Songs", 247, "Synced"),
|
||
("Discover Weekly", 30, "Needs Sync"),
|
||
("Chill Vibes", 89, "Synced"),
|
||
("Workout Mix", 156, "Needs Sync"),
|
||
("Road Trip", 67, "Never Synced"),
|
||
("Focus Music", 45, "Synced")
|
||
]
|
||
|
||
for name, count, status in playlists:
|
||
item = PlaylistItem(name, count, status, None, self) # Set parent for placeholders too
|
||
self.playlist_layout.addWidget(item)
|
||
|
||
def refresh_playlists(self):
|
||
"""Refresh playlists from Spotify API using async loader"""
|
||
if not self.spotify_client:
|
||
QMessageBox.warning(self, "Error", "Spotify client not available")
|
||
return
|
||
|
||
if not self.spotify_client.is_authenticated():
|
||
QMessageBox.warning(self, "Error", "Spotify not authenticated. Please check your settings.")
|
||
return
|
||
|
||
# Use the async loader
|
||
self.load_playlists_async()
|
||
|
||
def show_playlist_details(self, playlist):
|
||
"""Show playlist details modal"""
|
||
if playlist:
|
||
modal = PlaylistDetailsModal(playlist, self)
|
||
modal.show()
|
||
|
||
def clear_playlists(self):
|
||
"""Clear all playlist items from the layout"""
|
||
# Clear the current playlists list
|
||
self.current_playlists = []
|
||
|
||
# Remove all items including welcome state
|
||
for i in reversed(range(self.playlist_layout.count())):
|
||
item = self.playlist_layout.itemAt(i)
|
||
if item.widget():
|
||
item.widget().deleteLater()
|
||
elif item.spacerItem():
|
||
continue # Keep the stretch spacer
|
||
else:
|
||
self.playlist_layout.removeItem(item)
|
||
|
||
def parse_youtube_playlist(self):
|
||
"""Parse YouTube playlist URL and create card immediately, then open discovery modal"""
|
||
url = self.youtube_url_input.text().strip()
|
||
|
||
if not url:
|
||
self.show_youtube_error("Please enter a YouTube Music playlist URL")
|
||
return
|
||
|
||
# Basic URL validation
|
||
if not ('youtube.com' in url or 'youtu.be' in url):
|
||
self.show_youtube_error("Please enter a valid YouTube Music playlist URL")
|
||
return
|
||
|
||
# Check if this URL already has a card/state
|
||
if url in self.youtube_playlist_states:
|
||
# Card already exists - check if we need to reopen existing modal or create new one
|
||
state = self.get_youtube_playlist_state(url)
|
||
if state and state.get('discovery_modal') and state['discovery_modal'].isVisible():
|
||
# Modal is already open, just bring it to front
|
||
state['discovery_modal'].activateWindow()
|
||
state['discovery_modal'].raise_()
|
||
return
|
||
elif state and state.get('playlist_data'):
|
||
# We have data but no visible modal - recreate modal with existing data
|
||
self.open_or_create_discovery_modal(url, state)
|
||
return
|
||
else:
|
||
# Card exists but no data yet - this means parsing was cancelled/failed
|
||
# Reset the card state and continue with new parsing
|
||
print(f"🔄 Resetting existing card state for URL: {url}")
|
||
self.reset_youtube_playlist_state(url)
|
||
|
||
# Check if this URL is already being processed (legacy check)
|
||
if url in self.active_youtube_processes:
|
||
existing_modal = self.active_youtube_processes[url]
|
||
if existing_modal and not existing_modal.isHidden():
|
||
# Modal is still open - bring it to front
|
||
existing_modal.show()
|
||
existing_modal.raise_()
|
||
existing_modal.activateWindow()
|
||
return
|
||
elif existing_modal:
|
||
# Modal exists but is hidden - reopen it
|
||
existing_modal.show()
|
||
existing_modal.raise_()
|
||
existing_modal.activateWindow()
|
||
return
|
||
else:
|
||
# Stale reference - clean it up
|
||
del self.active_youtube_processes[url]
|
||
|
||
# Create YouTube playlist card immediately
|
||
card = self.create_youtube_playlist_card(url)
|
||
card.set_phase('discovering')
|
||
|
||
# Show loading state
|
||
self.parse_btn.setEnabled(False)
|
||
self.parse_btn.setText("Parsing...")
|
||
|
||
# Show modal immediately with loading state
|
||
self.show_youtube_modal_loading(url)
|
||
|
||
# Store URL for later use in completion handlers
|
||
self.current_youtube_url = url
|
||
|
||
# Start parsing in a separate thread to avoid blocking UI
|
||
self.youtube_worker = YouTubeParsingWorker(url)
|
||
self.youtube_worker.finished.connect(self.on_youtube_parsing_finished)
|
||
self.youtube_worker.error.connect(self.on_youtube_parsing_error)
|
||
self.youtube_worker.start()
|
||
|
||
def show_youtube_modal_loading(self, url):
|
||
"""Show the YouTube modal immediately with loading state"""
|
||
# Create a dummy playlist item widget (required by modal)
|
||
dummy_playlist_item = type('DummyPlaylistItem', (), {
|
||
'playlist_name': "Loading...",
|
||
'track_count': 0,
|
||
'download_modal': None,
|
||
'show_operation_status': lambda self, status_text="View Progress": None,
|
||
'hide_operation_status': lambda self: None
|
||
})()
|
||
|
||
# Create empty playlist for loading state
|
||
empty_playlist = type('Playlist', (), {
|
||
'name': f"Parsing YouTube Playlist...",
|
||
'tracks': [],
|
||
'total_tracks': 0
|
||
})()
|
||
|
||
# Open the modal in loading state
|
||
print("🚀 Opening YouTubeDownloadMissingTracksModal in loading state...")
|
||
self.current_youtube_modal = YouTubeDownloadMissingTracksModal(
|
||
empty_playlist,
|
||
dummy_playlist_item,
|
||
self,
|
||
self.downloads_page
|
||
)
|
||
|
||
# Store URL in modal for cleanup purposes
|
||
self.current_youtube_modal.youtube_url = url
|
||
|
||
# Register this modal for the URL to prevent duplicates
|
||
self.active_youtube_processes[url] = self.current_youtube_modal
|
||
|
||
# Link modal with card state system
|
||
if url in self.youtube_playlist_states:
|
||
self.youtube_playlist_states[url]['discovery_modal'] = self.current_youtube_modal
|
||
|
||
# Show a loading message in the modal
|
||
self.current_youtube_modal.show_loading_state()
|
||
self.current_youtube_modal.show()
|
||
|
||
def on_youtube_parsing_finished(self, playlist):
|
||
"""Handle successful YouTube playlist parsing"""
|
||
try:
|
||
print(f"✅ Successfully parsed YouTube playlist: {playlist.name}")
|
||
print(f"🔍 Playlist ID: {playlist.id}")
|
||
|
||
# Reset button state
|
||
self.parse_btn.setEnabled(True)
|
||
self.parse_btn.setText("Parse Playlist")
|
||
|
||
# Update the card with discovered playlist info
|
||
if hasattr(self, 'current_youtube_url'):
|
||
url = self.current_youtube_url
|
||
|
||
# Update card state and playlist info
|
||
self.set_youtube_card_playlist_data(url, playlist)
|
||
self.update_youtube_card_playlist_info(url, playlist.name, len(playlist.tracks))
|
||
self.update_youtube_card_phase(url, 'discovery_complete')
|
||
|
||
# Store modal reference in state
|
||
if url in self.youtube_playlist_states and hasattr(self, 'current_youtube_modal'):
|
||
self.youtube_playlist_states[url]['discovery_modal'] = self.current_youtube_modal
|
||
|
||
# Update the existing modal with the parsed playlist data
|
||
print(f"🔍 Has current_youtube_modal: {hasattr(self, 'current_youtube_modal') and self.current_youtube_modal is not None}")
|
||
if hasattr(self, 'current_youtube_modal') and self.current_youtube_modal:
|
||
print(f"🔍 Calling populate_with_playlist_data...")
|
||
self.current_youtube_modal.populate_with_playlist_data(playlist)
|
||
else:
|
||
# Fallback: create new modal if loading modal wasn't created
|
||
dummy_playlist_item = type('DummyPlaylistItem', (), {
|
||
'playlist_name': playlist.name,
|
||
'track_count': len(playlist.tracks),
|
||
'download_modal': None,
|
||
'show_operation_status': lambda self, status_text="View Progress": None,
|
||
'hide_operation_status': lambda self: None
|
||
})()
|
||
|
||
modal = YouTubeDownloadMissingTracksModal(
|
||
playlist,
|
||
dummy_playlist_item,
|
||
self,
|
||
self.downloads_page
|
||
)
|
||
|
||
# Store URL and register in tracking
|
||
if hasattr(self, 'current_youtube_url'):
|
||
modal.youtube_url = self.current_youtube_url
|
||
self.active_youtube_processes[self.current_youtube_url] = modal
|
||
|
||
modal.exec()
|
||
|
||
# Clear the URL input after successful parsing
|
||
self.youtube_url_input.clear()
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error handling YouTube parsing result: {e}")
|
||
self.on_youtube_parsing_error(str(e))
|
||
|
||
def on_youtube_parsing_error(self, error_message):
|
||
"""Handle YouTube playlist parsing error"""
|
||
print(f"❌ YouTube parsing error: {error_message}")
|
||
|
||
# Update card state on error (remove the card since parsing failed)
|
||
if hasattr(self, 'current_youtube_url'):
|
||
url = self.current_youtube_url
|
||
self.remove_youtube_playlist_card(url)
|
||
|
||
# Clean up URL tracking on error
|
||
if hasattr(self, 'current_youtube_url') and self.current_youtube_url in self.active_youtube_processes:
|
||
print(f"🧹 Cleaning up URL tracking on error for: {self.current_youtube_url}")
|
||
del self.active_youtube_processes[self.current_youtube_url]
|
||
|
||
# Reset button state
|
||
self.parse_btn.setEnabled(True)
|
||
self.parse_btn.setText("Parse Playlist")
|
||
|
||
# Show error message
|
||
self.show_youtube_error(f"Failed to parse playlist: {error_message}")
|
||
|
||
def show_youtube_error(self, message):
|
||
"""Show error message for YouTube functionality"""
|
||
# You can enhance this with a proper toast notification if available
|
||
if hasattr(self, 'toast_manager') and self.toast_manager:
|
||
self.toast_manager.show_toast(message, ToastType.ERROR)
|
||
else:
|
||
print(f"⚠️ YouTube Error: {message}")
|
||
# Fallback to a simple message box
|
||
msg_box = QMessageBox(self)
|
||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||
msg_box.setWindowTitle("YouTube Playlist Error")
|
||
msg_box.setText(message)
|
||
msg_box.exec()
|
||
|
||
# ===============================
|
||
# YouTube Playlist Card Hub System
|
||
# ===============================
|
||
|
||
def create_youtube_playlist_card(self, url: str, playlist_name: str = "Loading...", track_count: int = 0):
|
||
"""Create a new YouTube playlist card and add to the cards container"""
|
||
if url in self.youtube_cards:
|
||
return self.youtube_cards[url] # Return existing card
|
||
|
||
# Create new card
|
||
card = YouTubePlaylistCard(url, playlist_name, track_count, self)
|
||
card.card_clicked.connect(self.on_youtube_card_clicked)
|
||
|
||
# Store card reference
|
||
self.youtube_cards[url] = card
|
||
|
||
# Initialize state tracking
|
||
self.youtube_playlist_states[url] = {
|
||
'phase': 'discovering',
|
||
'playlist_data': None,
|
||
'discovered_tracks': [],
|
||
'card': card,
|
||
'discovery_modal': None,
|
||
'download_modal': None
|
||
}
|
||
|
||
# Ensure cards container exists
|
||
if self.youtube_cards_container is None:
|
||
self.setup_youtube_cards_container()
|
||
|
||
# Add card to container at the top (most recent first)
|
||
card_layout = self.youtube_cards_container.layout()
|
||
if card_layout:
|
||
# Insert at position 0 so newest cards appear at the top
|
||
card_layout.insertWidget(0, card)
|
||
|
||
return card
|
||
|
||
def setup_youtube_cards_container(self):
|
||
"""Setup the container for YouTube playlist cards"""
|
||
# Clear existing placeholder content
|
||
for i in reversed(range(self.youtube_content_layout.count())):
|
||
child = self.youtube_content_layout.itemAt(i).widget()
|
||
if child:
|
||
child.setParent(None)
|
||
|
||
# Create cards container
|
||
self.youtube_cards_container = QFrame()
|
||
self.youtube_cards_container.setStyleSheet("""
|
||
QFrame {
|
||
background: transparent;
|
||
border: none;
|
||
}
|
||
""")
|
||
|
||
cards_layout = QVBoxLayout(self.youtube_cards_container)
|
||
cards_layout.setContentsMargins(0, 0, 0, 0)
|
||
cards_layout.setSpacing(10)
|
||
# Set alignment to ensure cards stick to the top
|
||
cards_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||
cards_layout.addStretch() # Stretch at bottom to align cards to top
|
||
|
||
# Add container to main layout at the top
|
||
self.youtube_content_layout.insertWidget(0, self.youtube_cards_container)
|
||
|
||
def update_youtube_card_phase(self, url: str, phase: str):
|
||
"""Update the YouTube card's phase - cards are the single source of truth for state"""
|
||
if url not in self.youtube_cards or url not in self.youtube_playlist_states:
|
||
return
|
||
|
||
card = self.youtube_cards[url]
|
||
state = self.youtube_playlist_states[url]
|
||
|
||
# Update the internal state - card handles its own visual appearance
|
||
card.set_phase(phase)
|
||
state['phase'] = phase
|
||
|
||
# Clean up any existing status widgets for this playlist when changing phases
|
||
playlist_data = state.get('playlist_data')
|
||
if playlist_data and hasattr(playlist_data, 'id'):
|
||
if playlist_data.id in self.youtube_status_widgets:
|
||
status_widget = self.youtube_status_widgets.pop(playlist_data.id, None)
|
||
if status_widget:
|
||
status_widget.setParent(None)
|
||
status_widget.deleteLater()
|
||
print(f"🧹 Cleaned up status widget for phase change to: {phase}")
|
||
|
||
# Ensure card is always visible - it manages its own appearance
|
||
card.show()
|
||
|
||
def update_youtube_card_progress(self, url: str, total=None, matched=None, failed=None):
|
||
"""Update progress display on a YouTube playlist card"""
|
||
if url in self.youtube_cards:
|
||
card = self.youtube_cards[url]
|
||
card.update_progress(total=total, matched=matched, failed=failed)
|
||
|
||
def update_youtube_card_playlist_info(self, url: str, name: str, track_count: int):
|
||
"""Update playlist info on a YouTube playlist card"""
|
||
if url in self.youtube_cards:
|
||
card = self.youtube_cards[url]
|
||
card.update_playlist_info(name, track_count)
|
||
|
||
# Store original name and count for resets
|
||
if url in self.youtube_playlist_states:
|
||
state = self.youtube_playlist_states[url]
|
||
state['original_name'] = name
|
||
state['original_track_count'] = track_count
|
||
|
||
def set_youtube_card_playlist_data(self, url: str, playlist_data):
|
||
"""Store playlist data for a YouTube card"""
|
||
if url in self.youtube_playlist_states:
|
||
self.youtube_playlist_states[url]['playlist_data'] = playlist_data
|
||
if hasattr(playlist_data, 'tracks'):
|
||
self.youtube_playlist_states[url]['discovered_tracks'] = playlist_data.tracks
|
||
|
||
# Update card with playlist info
|
||
if url in self.youtube_cards:
|
||
card = self.youtube_cards[url]
|
||
card.set_playlist_data(playlist_data)
|
||
|
||
def get_youtube_playlist_state(self, url: str):
|
||
"""Get the current state data for a YouTube playlist"""
|
||
return self.youtube_playlist_states.get(url, None)
|
||
|
||
def reset_youtube_playlist_state(self, url: str):
|
||
"""Reset YouTube playlist state (for cancel operations)"""
|
||
if url in self.youtube_playlist_states:
|
||
state = self.youtube_playlist_states[url]
|
||
state['phase'] = 'discovering'
|
||
state['playlist_data'] = None
|
||
state['discovered_tracks'] = []
|
||
state['discovery_modal'] = None
|
||
state['download_modal'] = None
|
||
|
||
# Reset card to initial state
|
||
if url in self.youtube_cards:
|
||
card = self.youtube_cards[url]
|
||
card.set_phase('discovering')
|
||
# Use original name instead of "Loading..." to keep playlist title visible
|
||
original_name = state.get('original_name', 'Loading...')
|
||
original_count = state.get('original_track_count', 0)
|
||
card.update_playlist_info(original_name, original_count)
|
||
card.update_progress(0, 0, 0)
|
||
|
||
def remove_youtube_playlist_card(self, url: str):
|
||
"""Remove a YouTube playlist card (for full cleanup)"""
|
||
if url in self.youtube_cards:
|
||
card = self.youtube_cards[url]
|
||
card.setParent(None)
|
||
del self.youtube_cards[url]
|
||
|
||
if url in self.youtube_playlist_states:
|
||
del self.youtube_playlist_states[url]
|
||
|
||
# Tidal state management methods (identical structure to YouTube)
|
||
def update_tidal_card_phase(self, playlist_id: str, phase: str):
|
||
"""Update the Tidal card's phase - cards are the single source of truth for state"""
|
||
if playlist_id not in self.tidal_cards or playlist_id not in self.tidal_playlist_states:
|
||
return
|
||
|
||
card = self.tidal_cards[playlist_id]
|
||
state = self.tidal_playlist_states[playlist_id]
|
||
|
||
# Update the internal state - card handles its own visual appearance
|
||
card.set_phase(phase)
|
||
state['phase'] = phase
|
||
|
||
# Clean up any existing status widgets for this playlist when changing phases
|
||
playlist_data = state.get('playlist_data')
|
||
if playlist_data and hasattr(playlist_data, 'id'):
|
||
if playlist_data.id in self.youtube_status_widgets: # Reuse existing status widget system
|
||
status_widget = self.youtube_status_widgets.pop(playlist_data.id, None)
|
||
if status_widget:
|
||
status_widget.setParent(None)
|
||
status_widget.deleteLater()
|
||
print(f"🧹 Cleaned up status widget for Tidal phase change to: {phase}")
|
||
|
||
def update_tidal_card_playlist_info(self, playlist_id: str, name: str, track_count: int):
|
||
"""Update Tidal card playlist information"""
|
||
if playlist_id in self.tidal_cards:
|
||
card = self.tidal_cards[playlist_id]
|
||
card.update_playlist_info(name, track_count)
|
||
|
||
def set_tidal_card_playlist_data(self, playlist_id: str, playlist_data):
|
||
"""Store playlist data for a Tidal card"""
|
||
if playlist_id in self.tidal_playlist_states:
|
||
self.tidal_playlist_states[playlist_id]['playlist_data'] = playlist_data
|
||
if hasattr(playlist_data, 'tracks'):
|
||
self.tidal_playlist_states[playlist_id]['discovered_tracks'] = playlist_data.tracks
|
||
|
||
# Update card with playlist info
|
||
if playlist_id in self.tidal_cards:
|
||
card = self.tidal_cards[playlist_id]
|
||
card.playlist_data = playlist_data
|
||
card.discovered_tracks = playlist_data.tracks
|
||
|
||
def get_tidal_playlist_state(self, playlist_id: str):
|
||
"""Get the current state data for a Tidal playlist"""
|
||
return self.tidal_playlist_states.get(playlist_id, None)
|
||
|
||
def reset_tidal_playlist_state(self, playlist_id: str):
|
||
"""Reset Tidal playlist state (for cancel operations)"""
|
||
if playlist_id in self.tidal_playlist_states:
|
||
state = self.tidal_playlist_states[playlist_id]
|
||
state['phase'] = 'discovering'
|
||
state['playlist_data'] = None
|
||
state['discovered_tracks'] = []
|
||
state['discovery_modal'] = None
|
||
state['download_modal'] = None
|
||
|
||
# Reset card to initial state
|
||
if playlist_id in self.tidal_cards:
|
||
card = self.tidal_cards[playlist_id]
|
||
card.set_phase('discovering')
|
||
# Use original name instead of "Loading..." to keep playlist title visible
|
||
original_name = state.get('original_name', 'Unknown Playlist')
|
||
original_count = state.get('original_track_count', 0)
|
||
card.update_playlist_info(original_name, original_count)
|
||
card.update_progress(0, 0, 0)
|
||
|
||
def remove_tidal_playlist_card(self, playlist_id: str):
|
||
"""Remove a Tidal playlist card (for full cleanup)"""
|
||
if playlist_id in self.tidal_cards:
|
||
card = self.tidal_cards[playlist_id]
|
||
card.setParent(None)
|
||
del self.tidal_cards[playlist_id]
|
||
|
||
if playlist_id in self.tidal_playlist_states:
|
||
del self.tidal_playlist_states[playlist_id]
|
||
|
||
def on_youtube_card_clicked(self, url: str, phase: str):
|
||
"""Handle YouTube playlist card clicks - route to appropriate modal"""
|
||
print(f"🎬 YouTube card clicked: URL={url}, Phase={phase}")
|
||
|
||
state = self.get_youtube_playlist_state(url)
|
||
if not state:
|
||
print(f"⚠️ No state found for URL: {url}")
|
||
return
|
||
|
||
# Route to appropriate modal based on current phase
|
||
if phase in ['discovering', 'discovery_complete']:
|
||
self.open_or_create_discovery_modal(url, state)
|
||
elif phase in ['sync_complete', 'downloading', 'download_complete']:
|
||
# For downloading phase, check if download modal actually exists
|
||
# If not, route back to discovery modal (handles case where download modal was closed)
|
||
playlist_data = state.get('playlist_data')
|
||
if (playlist_data and hasattr(playlist_data, 'id') and
|
||
playlist_data.id in self.active_youtube_download_modals):
|
||
self.open_or_create_download_modal(url, state)
|
||
else:
|
||
print(f"📍 Download modal not found, routing to discovery modal instead")
|
||
self.open_or_create_discovery_modal(url, state)
|
||
elif phase == 'syncing':
|
||
# Show sync progress - could be same as discovery modal or separate
|
||
self.open_or_create_discovery_modal(url, state)
|
||
|
||
def open_or_create_discovery_modal(self, url: str, state: dict):
|
||
"""Open or create the discovery modal for a YouTube playlist"""
|
||
# Check if modal already exists and is visible
|
||
if state.get('discovery_modal') and state['discovery_modal'].isVisible():
|
||
state['discovery_modal'].activateWindow()
|
||
state['discovery_modal'].raise_()
|
||
return
|
||
|
||
# Check if modal exists but is hidden - reopen it
|
||
if state.get('discovery_modal') and not state['discovery_modal'].isVisible():
|
||
print(f"🔍 Reopening existing hidden discovery modal for URL: {url}")
|
||
state['discovery_modal'].show()
|
||
state['discovery_modal'].activateWindow()
|
||
state['discovery_modal'].raise_()
|
||
return
|
||
|
||
# Check if we have playlist data already (discovery_complete state)
|
||
if state.get('playlist_data') and state['phase'] == 'discovery_complete':
|
||
print(f"🔍 Opening existing discovery modal with data for URL: {url}")
|
||
|
||
# Create a new modal with the existing data
|
||
dummy_playlist_item = type('DummyPlaylistItem', (), {
|
||
'playlist_name': state['playlist_data'].name,
|
||
'track_count': len(state['playlist_data'].tracks),
|
||
'download_modal': None,
|
||
'show_operation_status': lambda self, status_text="View Progress": None,
|
||
'hide_operation_status': lambda self: None
|
||
})()
|
||
|
||
modal = YouTubeDownloadMissingTracksModal(
|
||
state['playlist_data'],
|
||
dummy_playlist_item,
|
||
self,
|
||
self.downloads_page
|
||
)
|
||
|
||
# Store URL and register modal
|
||
modal.youtube_url = url
|
||
state['discovery_modal'] = modal
|
||
self.active_youtube_processes[url] = modal
|
||
|
||
modal.show()
|
||
modal.activateWindow()
|
||
modal.raise_()
|
||
|
||
else:
|
||
# No existing data - start new discovery process
|
||
print(f"🔍 Starting new discovery for URL: {url}")
|
||
|
||
# Store URL in input field
|
||
self.youtube_url_input.setText(url)
|
||
|
||
# Directly start the parsing worker instead of calling parse_youtube_playlist
|
||
# to avoid recursion loop
|
||
self.start_youtube_parsing_worker(url)
|
||
|
||
def start_youtube_parsing_worker(self, url: str):
|
||
"""Start YouTube parsing worker directly (used to avoid recursion)"""
|
||
# Show loading state
|
||
self.parse_btn.setEnabled(False)
|
||
self.parse_btn.setText("Parsing...")
|
||
|
||
# Show modal immediately with loading state
|
||
self.show_youtube_modal_loading(url)
|
||
|
||
# Store URL for later use in completion handlers
|
||
self.current_youtube_url = url
|
||
|
||
# Start parsing in a separate thread to avoid blocking UI
|
||
self.youtube_worker = YouTubeParsingWorker(url)
|
||
self.youtube_worker.finished.connect(self.on_youtube_parsing_finished)
|
||
self.youtube_worker.error.connect(self.on_youtube_parsing_error)
|
||
self.youtube_worker.start()
|
||
|
||
def open_or_create_download_modal(self, url: str, state: dict):
|
||
"""Open or create the download modal for a YouTube playlist"""
|
||
playlist_data = state.get('playlist_data')
|
||
if not playlist_data:
|
||
print(f"⚠️ No playlist data available for URL: {url}")
|
||
return
|
||
|
||
# Check if modal already exists
|
||
if state.get('download_modal') and state['download_modal'].isVisible():
|
||
state['download_modal'].activateWindow()
|
||
state['download_modal'].raise_()
|
||
return
|
||
|
||
# Create new download modal
|
||
print(f"📥 Opening download modal for URL: {url}")
|
||
|
||
# Check existing modal system first
|
||
if hasattr(playlist_data, 'id') and playlist_data.id in self.active_youtube_download_modals:
|
||
modal = self.active_youtube_download_modals[playlist_data.id]
|
||
modal.show()
|
||
modal.activateWindow()
|
||
modal.raise_()
|
||
state['download_modal'] = modal
|
||
else:
|
||
# Create new download modal using the existing modal creation pattern
|
||
# This would transition to the download missing tracks phase
|
||
# For now, route back to discovery modal (sync_complete means ready for download)
|
||
self.open_or_create_discovery_modal(url, state)
|
||
|
||
|
||
class OptimizedSpotifyDiscoveryWorkerSignals(QObject):
|
||
track_discovered = pyqtSignal(int, object, str) # row, spotify_track, status
|
||
progress_updated = pyqtSignal(int) # current progress
|
||
finished = pyqtSignal(int) # total successful discoveries
|
||
|
||
class OptimizedSpotifyDiscoveryWorker(QRunnable):
|
||
def __init__(self, youtube_tracks, spotify_client, matching_engine):
|
||
super().__init__()
|
||
self.youtube_tracks = youtube_tracks
|
||
self.spotify_client = spotify_client
|
||
self.matching_engine = matching_engine
|
||
self.signals = OptimizedSpotifyDiscoveryWorkerSignals()
|
||
self.is_cancelled = False
|
||
|
||
def cancel(self):
|
||
self.is_cancelled = True
|
||
|
||
def run(self):
|
||
"""Discover Spotify tracks for YouTube tracks with optimized timing"""
|
||
successful_discoveries = 0
|
||
|
||
for i, youtube_track in enumerate(self.youtube_tracks):
|
||
if self.is_cancelled:
|
||
break
|
||
|
||
try:
|
||
# Create search query from YouTube track data
|
||
if youtube_track.artists:
|
||
query = f"{youtube_track.artists[0]} {youtube_track.name}"
|
||
else:
|
||
query = youtube_track.name
|
||
|
||
# Debug logging for search queries
|
||
print(f"🔍 Spotify search query: '{query}' (track: '{youtube_track.name}', artist: '{youtube_track.artists[0] if youtube_track.artists else 'None'}')")
|
||
|
||
# Search Spotify - get more results for validation
|
||
spotify_results = self.spotify_client.search_tracks(query, limit=10)
|
||
|
||
# Debug logging for search results
|
||
if spotify_results:
|
||
print(f"📊 Found {len(spotify_results)} Spotify results:")
|
||
for idx, result in enumerate(spotify_results[:3]): # Show first 3
|
||
album_name = result.album if isinstance(result.album, str) else getattr(result.album, 'name', 'Unknown')
|
||
print(f" {idx+1}. '{result.name}' by '{result.artists[0] if result.artists else 'Unknown'}' from '{album_name}'")
|
||
else:
|
||
print(f"❌ No Spotify results for query: '{query}'")
|
||
|
||
if spotify_results:
|
||
# Use matching engine to find the best validated match
|
||
best_track = self.find_best_validated_match(youtube_track, spotify_results)
|
||
if best_track:
|
||
self.signals.track_discovered.emit(i, best_track, "found")
|
||
successful_discoveries += 1
|
||
else:
|
||
# Try swapping artist and track name (sometimes YouTube data is swapped)
|
||
best_track = self.retry_with_swapped_fields(youtube_track)
|
||
if best_track:
|
||
print(f"🔄 Found match after swapping artist/track for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'")
|
||
self.signals.track_discovered.emit(i, best_track, "found")
|
||
successful_discoveries += 1
|
||
else:
|
||
# Third resort: try with uncleaned original data
|
||
best_track = self.retry_with_uncleaned_data(youtube_track)
|
||
if best_track:
|
||
print(f"🔍 Found match with uncleaned data for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'")
|
||
self.signals.track_discovered.emit(i, best_track, "found")
|
||
successful_discoveries += 1
|
||
else:
|
||
# Final resort: try with raw title + raw artist combined
|
||
best_track = self.retry_with_raw_title_and_artist(youtube_track)
|
||
if best_track:
|
||
print(f"🎯 Found match with title+artist fallback for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'")
|
||
self.signals.track_discovered.emit(i, best_track, "found")
|
||
successful_discoveries += 1
|
||
else:
|
||
# No result met confidence threshold even after all retries
|
||
self.signals.track_discovered.emit(i, None, "low_confidence")
|
||
else:
|
||
# No Spotify search results found - try swapping before giving up
|
||
best_track = self.retry_with_swapped_fields(youtube_track)
|
||
if best_track:
|
||
print(f"🔄 Found match after swapping artist/track for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'")
|
||
self.signals.track_discovered.emit(i, best_track, "found")
|
||
successful_discoveries += 1
|
||
else:
|
||
# Third resort: try with uncleaned original data
|
||
best_track = self.retry_with_uncleaned_data(youtube_track)
|
||
if best_track:
|
||
print(f"🔍 Found match with uncleaned data for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'")
|
||
self.signals.track_discovered.emit(i, best_track, "found")
|
||
successful_discoveries += 1
|
||
else:
|
||
# Final resort: try with raw title + raw artist combined
|
||
best_track = self.retry_with_raw_title_and_artist(youtube_track)
|
||
if best_track:
|
||
print(f"🎯 Found match with title+artist fallback for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'")
|
||
self.signals.track_discovered.emit(i, best_track, "found")
|
||
successful_discoveries += 1
|
||
else:
|
||
self.signals.track_discovered.emit(i, None, "not_found")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error searching Spotify for track {i}: {e}")
|
||
self.signals.track_discovered.emit(i, None, f"error: {str(e)}")
|
||
|
||
# Update progress
|
||
self.signals.progress_updated.emit(i + 1)
|
||
|
||
# Reduced delay for faster processing - Spotify client has built-in rate limiting
|
||
if not self.is_cancelled:
|
||
import time
|
||
time.sleep(0.15) # 150ms between requests
|
||
|
||
self.signals.finished.emit(successful_discoveries)
|
||
|
||
def find_best_validated_match(self, youtube_track, spotify_results):
|
||
"""Find the best Spotify match using the matching engine for validation"""
|
||
if not spotify_results:
|
||
return None
|
||
|
||
# Clean YouTube track name for better matching (already cleaned in parsing, but ensure consistency)
|
||
cleaned_youtube_name = self.clean_for_youtube_matching(youtube_track.name)
|
||
|
||
# Create a mock Spotify track from YouTube data for comparison
|
||
youtube_as_spotify = type('Track', (), {
|
||
'name': cleaned_youtube_name,
|
||
'artists': youtube_track.artists if youtube_track.artists else ["Unknown"],
|
||
'album': getattr(youtube_track, 'album', 'Unknown Album'),
|
||
'duration_ms': getattr(youtube_track, 'duration_ms', 0)
|
||
})()
|
||
|
||
best_match = None
|
||
best_confidence = 0.0
|
||
best_match_type = "no_match"
|
||
|
||
# Score each Spotify result using your matching engine
|
||
for spotify_track in spotify_results:
|
||
try:
|
||
# Clean the Spotify track name for better YouTube-to-Spotify matching
|
||
cleaned_spotify_track = self.create_cleaned_spotify_track_for_matching(spotify_track)
|
||
|
||
# Debug logging for track cleaning
|
||
if cleaned_spotify_track.name != spotify_track.name:
|
||
print(f"🧹 Cleaned Spotify track: '{spotify_track.name}' -> '{cleaned_spotify_track.name}'")
|
||
|
||
# Use your matching engine to calculate confidence
|
||
confidence, match_type = self.matching_engine.calculate_match_confidence(
|
||
youtube_as_spotify,
|
||
self.convert_spotify_to_plex_format(cleaned_spotify_track)
|
||
)
|
||
|
||
# Apply album preference bonus (your existing logic)
|
||
album_bonus = self.calculate_album_preference_bonus(spotify_track)
|
||
adjusted_confidence = confidence + album_bonus
|
||
|
||
if adjusted_confidence > best_confidence:
|
||
best_confidence = adjusted_confidence
|
||
best_match = spotify_track
|
||
best_match_type = match_type
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error calculating match confidence: {e}")
|
||
continue
|
||
|
||
# Apply your matching engine's confidence threshold (0.8 for high confidence)
|
||
confidence_threshold = 0.75 # Slightly lower for YouTube discovery
|
||
|
||
if best_confidence >= confidence_threshold:
|
||
print(f"✅ Validated match: '{best_match.name}' by '{best_match.artists[0]}' (confidence: {best_confidence:.3f}, type: {best_match_type})")
|
||
return best_match
|
||
else:
|
||
print(f"❌ No high-confidence match found. Best was {best_confidence:.3f} < {confidence_threshold}")
|
||
if best_match:
|
||
print(f" Best candidate was: '{best_match.name}' by '{best_match.artists[0] if best_match.artists else 'Unknown'}'")
|
||
return None
|
||
|
||
def convert_spotify_to_plex_format(self, spotify_track):
|
||
"""Convert Spotify track to Plex format for matching engine compatibility"""
|
||
return type('PlexTrackInfo', (), {
|
||
'title': spotify_track.name,
|
||
'artist': spotify_track.artists[0] if spotify_track.artists else "Unknown",
|
||
'album': spotify_track.album if isinstance(spotify_track.album, str) else getattr(spotify_track.album, 'name', 'Unknown Album'),
|
||
'duration': getattr(spotify_track, 'duration_ms', 0)
|
||
})()
|
||
|
||
def calculate_album_preference_bonus(self, spotify_track):
|
||
"""Calculate album preference bonus (simplified version of your existing logic)"""
|
||
try:
|
||
album_info = spotify_track.album if hasattr(spotify_track, 'album') else None
|
||
if album_info and not isinstance(album_info, str):
|
||
album_type = getattr(album_info, 'album_type', album_info.get('album_type', 'unknown') if hasattr(album_info, 'get') else 'unknown')
|
||
|
||
if isinstance(album_type, str):
|
||
if album_type.lower() == 'album':
|
||
return 0.05 # Small bonus for albums
|
||
elif album_type.lower() == 'single':
|
||
return -0.02 # Small penalty for singles
|
||
elif album_type.lower() == 'compilation':
|
||
return 0.02 # Small bonus for compilations
|
||
|
||
return 0.0
|
||
except:
|
||
return 0.0
|
||
|
||
def choose_best_spotify_match(self, spotify_results):
|
||
"""Choose the best Spotify track from search results, preferring album versions"""
|
||
if not spotify_results:
|
||
return None
|
||
|
||
# If only one result, return it
|
||
if len(spotify_results) == 1:
|
||
return spotify_results[0]
|
||
|
||
# Score each track based on preference criteria
|
||
scored_tracks = []
|
||
|
||
for track in spotify_results:
|
||
score = 0
|
||
|
||
# 1. Prefer album tracks over singles (highest priority)
|
||
try:
|
||
# Access album type through the album attribute
|
||
album_info = track.album if hasattr(track, 'album') else None
|
||
if album_info:
|
||
# Handle both string and dict album info
|
||
if isinstance(album_info, str):
|
||
# If album is just a string name, we can't determine type
|
||
score += 50 # Medium score for unknown type
|
||
else:
|
||
# Try to get album_type from album object or dict
|
||
album_type = getattr(album_info, 'album_type', album_info.get('album_type', 'unknown') if hasattr(album_info, 'get') else 'unknown')
|
||
|
||
if isinstance(album_type, str):
|
||
if album_type.lower() == 'album':
|
||
score += 100 # Strong preference for albums
|
||
elif album_type.lower() == 'single':
|
||
score += 20 # Lower preference for singles
|
||
elif album_type.lower() == 'compilation':
|
||
score += 60 # Medium preference for compilations
|
||
else:
|
||
score += 50 # Unknown type gets medium score
|
||
else:
|
||
score += 30 # No album info gets low score
|
||
except Exception as e:
|
||
print(f"⚠️ Error accessing album type: {e}")
|
||
score += 30 # Error case gets low score
|
||
|
||
# 2. Prefer tracks with more total tracks in album (indicates full album)
|
||
try:
|
||
album_info = track.album if hasattr(track, 'album') else None
|
||
if album_info and not isinstance(album_info, str):
|
||
total_tracks = getattr(album_info, 'total_tracks', album_info.get('total_tracks', 0) if hasattr(album_info, 'get') else 0)
|
||
|
||
if total_tracks > 10:
|
||
score += 50 # Full album
|
||
elif total_tracks > 5:
|
||
score += 30 # EP
|
||
elif total_tracks > 1:
|
||
score += 10 # Multi-track release
|
||
# Singles (1 track) get no bonus
|
||
except Exception as e:
|
||
print(f"⚠️ Error accessing total_tracks: {e}")
|
||
pass
|
||
|
||
# 3. Consider popularity as tiebreaker
|
||
try:
|
||
popularity = getattr(track, 'popularity', 0)
|
||
score += popularity * 0.1 # Small influence from popularity
|
||
except:
|
||
pass
|
||
|
||
# 4. Prefer tracks with explicit marking if available (often more complete metadata)
|
||
try:
|
||
if hasattr(track, 'explicit') and track.explicit is not None:
|
||
score += 5
|
||
except:
|
||
pass
|
||
|
||
scored_tracks.append((score, track))
|
||
|
||
# Sort by score (highest first) and return the best match
|
||
scored_tracks.sort(key=lambda x: x[0], reverse=True)
|
||
best_track = scored_tracks[0][1]
|
||
|
||
# Debug logging for first few tracks
|
||
if len(self.youtube_tracks) <= 5 or len(scored_tracks) > 1:
|
||
try:
|
||
album_name = best_track.album if isinstance(best_track.album, str) else getattr(best_track.album, 'name', 'Unknown Album')
|
||
print(f"🎯 Chose: '{best_track.name}' from '{album_name}' (score: {scored_tracks[0][0]:.1f})")
|
||
if len(scored_tracks) > 1:
|
||
alt_album = scored_tracks[1][1].album if isinstance(scored_tracks[1][1].album, str) else getattr(scored_tracks[1][1].album, 'name', 'Unknown Album')
|
||
print(f" vs. '{scored_tracks[1][1].name}' from '{alt_album}' (score: {scored_tracks[1][0]:.1f})")
|
||
except:
|
||
pass # Don't let debug logging crash the worker
|
||
|
||
return best_track
|
||
|
||
def clean_for_youtube_matching(self, track_name):
|
||
"""Clean track name for YouTube-to-Spotify matching"""
|
||
if not track_name:
|
||
return ""
|
||
|
||
cleaned = track_name
|
||
|
||
# Remove all parentheses content for YouTube matching
|
||
# This handles cases like "MOUSTACHE (Feat. Netta)" -> "MOUSTACHE"
|
||
cleaned = re.sub(r'\s*\([^)]*\)', '', cleaned)
|
||
|
||
# Remove brackets content
|
||
cleaned = re.sub(r'\s*\[[^\]]*\]', '', cleaned)
|
||
|
||
# Remove extra whitespace and return
|
||
return cleaned.strip()
|
||
|
||
def create_cleaned_spotify_track_for_matching(self, spotify_track):
|
||
"""Create a cleaned version of Spotify track for better YouTube matching"""
|
||
# Clean the track name
|
||
cleaned_name = self.clean_for_youtube_matching(spotify_track.name)
|
||
|
||
# Create a copy of the track with cleaned name
|
||
cleaned_track = type('Track', (), {
|
||
'id': getattr(spotify_track, 'id', ''),
|
||
'name': cleaned_name, # Use cleaned name
|
||
'artists': spotify_track.artists,
|
||
'album': spotify_track.album,
|
||
'duration_ms': getattr(spotify_track, 'duration_ms', 0),
|
||
'popularity': getattr(spotify_track, 'popularity', 0),
|
||
'preview_url': getattr(spotify_track, 'preview_url', None),
|
||
'external_urls': getattr(spotify_track, 'external_urls', None)
|
||
})()
|
||
|
||
return cleaned_track
|
||
|
||
def retry_with_swapped_fields(self, youtube_track):
|
||
"""Retry search with artist and track names swapped (handles YouTube data inconsistencies)"""
|
||
if not youtube_track.artists or not youtube_track.artists[0]:
|
||
return None
|
||
|
||
try:
|
||
# Create swapped query: use track name as artist and artist as track
|
||
swapped_artist = youtube_track.name
|
||
swapped_track = youtube_track.artists[0]
|
||
|
||
# Clean the swapped values
|
||
swapped_artist_clean = clean_youtube_artist(swapped_artist)
|
||
swapped_track_clean = clean_youtube_track_title(swapped_track, swapped_artist_clean)
|
||
|
||
swapped_query = f"{swapped_artist_clean} {swapped_track_clean}"
|
||
|
||
print(f"🔄 Retrying with swapped fields: '{swapped_query}' (was '{youtube_track.artists[0]} {youtube_track.name}')")
|
||
|
||
# Search Spotify with swapped query
|
||
spotify_results = self.spotify_client.search_tracks(swapped_query, limit=10)
|
||
|
||
if spotify_results:
|
||
# Create a swapped YouTube track for matching
|
||
swapped_youtube_track = type('Track', (), {
|
||
'name': swapped_track_clean,
|
||
'artists': [swapped_artist_clean],
|
||
'album': getattr(youtube_track, 'album', 'Unknown Album'),
|
||
'duration_ms': getattr(youtube_track, 'duration_ms', 0)
|
||
})()
|
||
|
||
# Use matching engine to validate the swapped results
|
||
best_track = self.find_best_validated_match(swapped_youtube_track, spotify_results)
|
||
return best_track
|
||
|
||
return None
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error in retry with swapped fields: {e}")
|
||
return None
|
||
|
||
def retry_with_uncleaned_data(self, youtube_track):
|
||
"""Last resort: retry search with original uncleaned YouTube data"""
|
||
# Check if we have raw uncleaned data
|
||
if not hasattr(youtube_track, 'raw_title') or not hasattr(youtube_track, 'raw_uploader'):
|
||
print("🔍 No raw data available for uncleaned fallback search")
|
||
return None
|
||
|
||
try:
|
||
# Use completely uncleaned data
|
||
raw_title = youtube_track.raw_title
|
||
raw_uploader = youtube_track.raw_uploader
|
||
|
||
# Create query with minimal cleaning - just basic text normalization
|
||
uncleaned_query = f"{raw_uploader} {raw_title}".strip()
|
||
|
||
print(f"🔍 Last resort: Trying uncleaned data: '{uncleaned_query}' (was '{youtube_track.artists[0]} {youtube_track.name}')")
|
||
|
||
# Search Spotify with uncleaned query
|
||
spotify_results = self.spotify_client.search_tracks(uncleaned_query, limit=10)
|
||
|
||
if spotify_results:
|
||
print(f"📊 Found {len(spotify_results)} results with uncleaned data")
|
||
|
||
# Create an uncleaned YouTube track for comparison
|
||
uncleaned_youtube_track = type('Track', (), {
|
||
'name': raw_title, # Use raw title
|
||
'artists': [raw_uploader], # Use raw uploader
|
||
'album': getattr(youtube_track, 'album', 'Unknown Album'),
|
||
'duration_ms': getattr(youtube_track, 'duration_ms', 0)
|
||
})()
|
||
|
||
# Use matching engine to validate results with lower confidence threshold
|
||
# Note: We don't clean Spotify tracks here since we're using raw data
|
||
best_match = None
|
||
best_confidence = 0.0
|
||
|
||
for spotify_track in spotify_results:
|
||
try:
|
||
# Use original Spotify track names (no cleaning) for raw data matching
|
||
confidence, match_type = self.matching_engine.calculate_match_confidence(
|
||
uncleaned_youtube_track,
|
||
self.convert_spotify_to_plex_format(spotify_track)
|
||
)
|
||
|
||
if confidence > best_confidence:
|
||
best_confidence = confidence
|
||
best_match = spotify_track
|
||
except Exception as e:
|
||
print(f"⚠️ Error calculating confidence for uncleaned fallback: {e}")
|
||
continue
|
||
|
||
# Use lower confidence threshold for uncleaned fallback (0.6 instead of 0.75)
|
||
confidence_threshold = 0.6
|
||
|
||
if best_confidence >= confidence_threshold:
|
||
print(f"✅ Uncleaned fallback match: '{best_match.name}' by '{best_match.artists[0]}' (confidence: {best_confidence:.3f})")
|
||
return best_match
|
||
else:
|
||
print(f"❌ Uncleaned fallback: Best confidence {best_confidence:.3f} < {confidence_threshold}")
|
||
|
||
return None
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error in retry with uncleaned data: {e}")
|
||
return None
|
||
|
||
def retry_with_raw_title_and_artist(self, youtube_track):
|
||
"""Final fallback: search with raw title + raw artist as combined query"""
|
||
# Check if we have raw uncleaned data
|
||
if not hasattr(youtube_track, 'raw_title') or not hasattr(youtube_track, 'raw_uploader'):
|
||
print("🔍 No raw data available for title+artist fallback search")
|
||
return None
|
||
|
||
try:
|
||
raw_title = youtube_track.raw_title
|
||
raw_uploader = youtube_track.raw_uploader
|
||
|
||
# Create a combined query with raw title and raw artist
|
||
# This is different from the previous fallback which used "uploader title"
|
||
# This uses "title artist" order which sometimes works better
|
||
combined_query = f"{raw_title} {raw_uploader}".strip()
|
||
|
||
print(f"🔍 Final fallback: Trying raw title+artist: '{combined_query}'")
|
||
|
||
# Search Spotify with the combined query
|
||
spotify_results = self.spotify_client.search_tracks(combined_query, limit=10)
|
||
|
||
if spotify_results:
|
||
print(f"📊 Found {len(spotify_results)} results with title+artist search")
|
||
|
||
# Create a track object for matching with raw data in title+artist order
|
||
combined_youtube_track = type('Track', (), {
|
||
'name': raw_title,
|
||
'artists': [raw_uploader],
|
||
'album': getattr(youtube_track, 'album', 'Unknown Album'),
|
||
'duration_ms': getattr(youtube_track, 'duration_ms', 0)
|
||
})()
|
||
|
||
# Use matching engine with even lower confidence threshold
|
||
best_match = None
|
||
best_confidence = 0.0
|
||
|
||
for spotify_track in spotify_results:
|
||
try:
|
||
confidence, match_type = self.matching_engine.calculate_match_confidence(
|
||
combined_youtube_track,
|
||
self.convert_spotify_to_plex_format(spotify_track)
|
||
)
|
||
|
||
if confidence > best_confidence:
|
||
best_confidence = confidence
|
||
best_match = spotify_track
|
||
except Exception as e:
|
||
print(f"⚠️ Error calculating confidence for title+artist fallback: {e}")
|
||
continue
|
||
|
||
# Use very low confidence threshold for this final attempt (0.5 instead of 0.6)
|
||
confidence_threshold = 0.5
|
||
|
||
if best_confidence >= confidence_threshold:
|
||
print(f"✅ Title+artist fallback match: '{best_match.name}' by '{best_match.artists[0]}' (confidence: {best_confidence:.3f})")
|
||
return best_match
|
||
else:
|
||
print(f"❌ Title+artist fallback: Best confidence {best_confidence:.3f} < {confidence_threshold}")
|
||
else:
|
||
print(f"❌ No results found for title+artist query: '{combined_query}'")
|
||
|
||
return None
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error in retry with title+artist: {e}")
|
||
return None
|
||
|
||
class TidalSpotifyDiscoveryWorkerSignals(QObject):
|
||
track_discovered = pyqtSignal(int, object, str) # row, spotify_track, status
|
||
progress_updated = pyqtSignal(int) # current progress
|
||
finished = pyqtSignal(int) # total successful discoveries
|
||
|
||
class TidalSpotifyDiscoveryWorker(QRunnable):
|
||
def __init__(self, tidal_tracks, spotify_client, matching_engine):
|
||
super().__init__()
|
||
self.tidal_tracks = tidal_tracks
|
||
self.spotify_client = spotify_client
|
||
self.matching_engine = matching_engine
|
||
self.signals = TidalSpotifyDiscoveryWorkerSignals()
|
||
self.is_cancelled = False
|
||
|
||
def cancel(self):
|
||
self.is_cancelled = True
|
||
|
||
def run(self):
|
||
"""Discover Spotify tracks for Tidal tracks with optimized timing"""
|
||
successful_discoveries = 0
|
||
|
||
for i, tidal_track in enumerate(self.tidal_tracks):
|
||
if self.is_cancelled:
|
||
break
|
||
|
||
try:
|
||
# Create search query from Tidal track data
|
||
if tidal_track.artists:
|
||
query = f"{tidal_track.artists[0]} {tidal_track.name}"
|
||
else:
|
||
query = tidal_track.name
|
||
|
||
# Debug logging for search queries
|
||
print(f"🔍 Spotify search query for Tidal track: '{query}' (track: '{tidal_track.name}', artist: '{tidal_track.artists[0] if tidal_track.artists else 'None'}')")
|
||
|
||
# Search Spotify - get more results for validation
|
||
spotify_results = self.spotify_client.search_tracks(query, limit=10)
|
||
|
||
# Progress tracking
|
||
if spotify_results:
|
||
print(f"📊 Found {len(spotify_results)} Spotify results for Tidal track:")
|
||
for idx, result in enumerate(spotify_results[:3]): # Show first 3
|
||
print(f" {idx+1}. '{result.name}' by {', '.join(result.artists)}")
|
||
|
||
if spotify_results:
|
||
# Use the matching engine to find the best match
|
||
best_track = self.find_best_validated_match(tidal_track, spotify_results)
|
||
|
||
if not best_track:
|
||
# Try with swapped fields if no match found
|
||
print(f"🔄 No direct match found, trying swapped fields for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'")
|
||
best_track = self.retry_with_swapped_fields(tidal_track)
|
||
|
||
if best_track:
|
||
print(f"🔄 Found match after swapping artist/track for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'")
|
||
else:
|
||
# Final fallback: try with cleaned data
|
||
best_track = self.retry_with_uncleaned_data(tidal_track)
|
||
|
||
if best_track:
|
||
print(f"🔍 Found match with uncleaned data for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'")
|
||
else:
|
||
# Last resort: try title+artist combo
|
||
best_track = self.retry_with_raw_title_and_artist(tidal_track)
|
||
|
||
if best_track:
|
||
print(f"🎯 Found match with title+artist fallback for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'")
|
||
|
||
if best_track:
|
||
successful_discoveries += 1
|
||
self.signals.track_discovered.emit(i, best_track, "found")
|
||
print(f"✅ Matched Tidal track '{tidal_track.name}' to Spotify track '{best_track.name}' by {', '.join(best_track.artists)}")
|
||
else:
|
||
self.signals.track_discovered.emit(i, None, "not_found")
|
||
print(f"❌ No Spotify match found for Tidal track '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'")
|
||
else:
|
||
# No search results - try fallback approaches
|
||
best_track = self.retry_with_swapped_fields(tidal_track)
|
||
|
||
if best_track:
|
||
print(f"🔄 Found match after swapping artist/track for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'")
|
||
successful_discoveries += 1
|
||
self.signals.track_discovered.emit(i, best_track, "found")
|
||
else:
|
||
# Try with uncleaned data
|
||
best_track = self.retry_with_uncleaned_data(tidal_track)
|
||
|
||
if best_track:
|
||
print(f"🔍 Found match with uncleaned data for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'")
|
||
successful_discoveries += 1
|
||
self.signals.track_discovered.emit(i, best_track, "found")
|
||
else:
|
||
# Final fallback
|
||
best_track = self.retry_with_raw_title_and_artist(tidal_track)
|
||
|
||
if best_track:
|
||
print(f"🎯 Found match with title+artist fallback for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'")
|
||
successful_discoveries += 1
|
||
self.signals.track_discovered.emit(i, best_track, "found")
|
||
else:
|
||
self.signals.track_discovered.emit(i, None, "not_found")
|
||
print(f"❌ No Spotify match found for Tidal track '{tidal_track.name}'")
|
||
|
||
# Update progress
|
||
self.signals.progress_updated.emit(i + 1)
|
||
|
||
# Brief pause to avoid overwhelming the API
|
||
time.sleep(0.25) # 250ms delay
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error processing Tidal track '{tidal_track.name}': {str(e)}")
|
||
self.signals.track_discovered.emit(i, None, "error")
|
||
continue
|
||
|
||
print(f"🎵 Tidal discovery completed: {successful_discoveries} successful discoveries")
|
||
self.signals.finished.emit(successful_discoveries)
|
||
|
||
def find_best_validated_match(self, tidal_track, spotify_results):
|
||
"""Find the best validated match using the matching engine"""
|
||
if not spotify_results:
|
||
return None
|
||
|
||
# Clean the Tidal track name for matching (similar to YouTube logic)
|
||
cleaned_tidal_name = self.clean_for_tidal_matching(tidal_track.name)
|
||
|
||
# Create a fake track object that looks like a YouTube track for the matching engine
|
||
tidal_as_youtube = type('Track', (), {
|
||
'name': cleaned_tidal_name,
|
||
'artists': tidal_track.artists if tidal_track.artists else ["Unknown"],
|
||
'album': getattr(tidal_track, 'album', 'Unknown Album'),
|
||
'duration_ms': getattr(tidal_track, 'duration_ms', 0)
|
||
})()
|
||
|
||
confidence_threshold = 0.7
|
||
best_track = None
|
||
best_confidence = 0
|
||
|
||
# Test each Spotify result against the Tidal track
|
||
for spotify_track in spotify_results:
|
||
try:
|
||
# Use the matching engine to calculate confidence
|
||
cleaned_spotify_track = self.matching_engine.normalize_track_for_matching(spotify_track)
|
||
confidence = self.matching_engine.calculate_similarity_confidence(
|
||
tidal_as_youtube, cleaned_spotify_track
|
||
)
|
||
|
||
if confidence > best_confidence:
|
||
best_confidence = confidence
|
||
best_track = spotify_track
|
||
|
||
print(f"🎯 Tidal->Spotify match confidence: {confidence:.3f} for '{spotify_track.name}' by {', '.join(spotify_track.artists)}")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error validating match: {e}")
|
||
continue
|
||
|
||
if best_confidence >= confidence_threshold:
|
||
print(f"✅ Best validated Tidal->Spotify match: '{best_track.name}' (confidence: {best_confidence:.3f})")
|
||
return best_track
|
||
else:
|
||
print(f"❌ Best Tidal->Spotify confidence {best_confidence:.3f} < {confidence_threshold}")
|
||
return None
|
||
|
||
def clean_for_tidal_matching(self, title):
|
||
"""Clean Tidal track title for better matching (similar to YouTube logic)"""
|
||
if not title:
|
||
return ""
|
||
|
||
# Remove common Tidal-specific markers and clean the title
|
||
cleaned = title.lower()
|
||
cleaned = re.sub(r'\s*\(.*?\)\s*', ' ', cleaned) # Remove parenthetical content
|
||
cleaned = re.sub(r'\s*\[.*?\]\s*', ' ', cleaned) # Remove bracketed content
|
||
cleaned = re.sub(r'\s*-\s*remaster.*', ' ', cleaned, re.IGNORECASE) # Remove remaster info
|
||
cleaned = re.sub(r'\s*-\s*\d{4}.*', ' ', cleaned) # Remove year info
|
||
cleaned = re.sub(r'\s+', ' ', cleaned).strip() # Normalize whitespace
|
||
|
||
return cleaned
|
||
|
||
def retry_with_swapped_fields(self, tidal_track):
|
||
"""Retry search with artist/track fields swapped"""
|
||
try:
|
||
if not tidal_track.artists or len(tidal_track.artists) == 0:
|
||
return None
|
||
|
||
# Swap: use track name as artist and artist name as track
|
||
swapped_query = f"{tidal_track.name} {tidal_track.artists[0]}"
|
||
print(f"🔄 Trying swapped Tidal fields: '{swapped_query}'")
|
||
|
||
spotify_results = self.spotify_client.search_tracks(swapped_query, limit=5)
|
||
if spotify_results:
|
||
return self.find_best_validated_match(tidal_track, spotify_results)
|
||
return None
|
||
except Exception as e:
|
||
print(f"❌ Error in swapped fields retry for Tidal track: {e}")
|
||
return None
|
||
|
||
def retry_with_uncleaned_data(self, tidal_track):
|
||
"""Retry with original uncleaned Tidal track data"""
|
||
try:
|
||
# Use original, uncleaned title and artist
|
||
if tidal_track.artists:
|
||
raw_query = f"{tidal_track.artists[0]} {tidal_track.name}"
|
||
else:
|
||
raw_query = tidal_track.name
|
||
|
||
print(f"🔍 Trying uncleaned Tidal data: '{raw_query}'")
|
||
|
||
spotify_results = self.spotify_client.search_tracks(raw_query, limit=5)
|
||
if spotify_results:
|
||
return self.find_best_validated_match(tidal_track, spotify_results)
|
||
return None
|
||
except Exception as e:
|
||
print(f"❌ Error in uncleaned data retry for Tidal track: {e}")
|
||
return None
|
||
|
||
def retry_with_raw_title_and_artist(self, tidal_track):
|
||
"""Final fallback: combine raw title and artist in one query"""
|
||
try:
|
||
if not tidal_track.artists:
|
||
return None
|
||
|
||
# Combine everything into one search term
|
||
combined_query = f"{tidal_track.name} {tidal_track.artists[0]}"
|
||
print(f"🎯 Trying combined Tidal query: '{combined_query}'")
|
||
|
||
spotify_results = self.spotify_client.search_tracks(combined_query, limit=5)
|
||
if spotify_results:
|
||
best_confidence = 0
|
||
best_track = None
|
||
confidence_threshold = 0.6 # Lower threshold for final fallback
|
||
|
||
for track in spotify_results:
|
||
try:
|
||
# Basic string similarity as last resort
|
||
track_similarity = self.basic_string_similarity(
|
||
f"{tidal_track.name} {tidal_track.artists[0]}".lower(),
|
||
f"{track.name} {' '.join(track.artists)}".lower()
|
||
)
|
||
|
||
if track_similarity > best_confidence:
|
||
best_confidence = track_similarity
|
||
best_track = track
|
||
except Exception as e:
|
||
continue
|
||
|
||
if best_confidence >= confidence_threshold:
|
||
print(f"🎯 Title+artist fallback found match: confidence {best_confidence:.3f}")
|
||
return best_track
|
||
else:
|
||
print(f"❌ Title+artist fallback: Best confidence {best_confidence:.3f} < {confidence_threshold}")
|
||
else:
|
||
print(f"❌ No results found for title+artist query: '{combined_query}'")
|
||
|
||
return None
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error in retry with title+artist for Tidal track: {e}")
|
||
return None
|
||
|
||
def basic_string_similarity(self, s1, s2):
|
||
"""Calculate basic string similarity for fallback matching"""
|
||
try:
|
||
from difflib import SequenceMatcher
|
||
return SequenceMatcher(None, s1, s2).ratio()
|
||
except:
|
||
return 0.0
|
||
|
||
class SpotifyDiscoveryManagerSignals(QObject):
|
||
track_discovered = pyqtSignal(int, object, str) # row, spotify_track, status
|
||
progress_updated = pyqtSignal(int) # current progress
|
||
all_finished = pyqtSignal(int) # total successful discoveries
|
||
|
||
class SpotifyDiscoveryWorker(QRunnable):
|
||
def __init__(self, track_batch, spotify_client, worker_id, manager_signals):
|
||
super().__init__()
|
||
self.track_batch = track_batch # List of (index, youtube_track) tuples
|
||
self.spotify_client = spotify_client
|
||
self.worker_id = worker_id
|
||
self.signals = manager_signals
|
||
self.is_cancelled = False
|
||
|
||
def cancel(self):
|
||
self.is_cancelled = True
|
||
|
||
def run(self):
|
||
"""Process a batch of tracks with staggered delays to avoid rate limits"""
|
||
import time
|
||
|
||
# Stagger start times to spread out API calls
|
||
initial_delay = self.worker_id * 0.2 # 200ms stagger between workers
|
||
time.sleep(initial_delay)
|
||
|
||
successful_discoveries = 0
|
||
|
||
for track_index, youtube_track in self.track_batch:
|
||
if self.is_cancelled:
|
||
break
|
||
|
||
try:
|
||
# Create search query from YouTube track data
|
||
if youtube_track.artists:
|
||
query = f"{youtube_track.artists[0]} {youtube_track.name}"
|
||
else:
|
||
query = youtube_track.name
|
||
|
||
# Search Spotify with rate limiting (built into spotify_client)
|
||
spotify_results = self.spotify_client.search_tracks(query, limit=10)
|
||
|
||
if spotify_results:
|
||
# Choose the best match preferring album versions
|
||
best_track = choose_best_spotify_match(spotify_results)
|
||
self.signals.track_discovered.emit(track_index, best_track, "found")
|
||
successful_discoveries += 1
|
||
else:
|
||
# No Spotify match found
|
||
self.signals.track_discovered.emit(track_index, None, "not_found")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Worker {self.worker_id} error searching Spotify for track {track_index}: {e}")
|
||
self.signals.track_discovered.emit(track_index, None, f"error: {str(e)}")
|
||
|
||
# Update progress
|
||
self.signals.progress_updated.emit(track_index)
|
||
|
||
# Distributed rate limiting - longer delay since we have multiple workers
|
||
if not self.is_cancelled:
|
||
time.sleep(0.5) # 500ms delay with 3 workers = ~6 requests/second total
|
||
|
||
print(f"🎵 Worker {self.worker_id} completed: {successful_discoveries} discoveries")
|
||
|
||
class SpotifyDiscoveryManager:
|
||
def __init__(self, youtube_tracks, spotify_client, num_workers=3):
|
||
self.youtube_tracks = youtube_tracks
|
||
self.spotify_client = spotify_client
|
||
self.num_workers = num_workers
|
||
self.signals = SpotifyDiscoveryManagerSignals()
|
||
self.workers = []
|
||
self.completed_workers = 0
|
||
self.total_successful = 0
|
||
self.processed_tracks = set()
|
||
|
||
def start_discovery(self):
|
||
"""Start concurrent Spotify discovery with multiple workers"""
|
||
print(f"🚀 Starting Spotify discovery with {self.num_workers} concurrent workers")
|
||
|
||
# Divide tracks among workers
|
||
track_batches = self.distribute_tracks()
|
||
|
||
# Create and start workers
|
||
for worker_id, batch in enumerate(track_batches):
|
||
worker = SpotifyDiscoveryWorker(batch, self.spotify_client, worker_id, self.signals)
|
||
|
||
# Connect to progress tracking
|
||
worker.signals.track_discovered.connect(self.on_track_discovered)
|
||
worker.signals.progress_updated.connect(self.on_progress_updated)
|
||
|
||
self.workers.append(worker)
|
||
QThreadPool.globalInstance().start(worker)
|
||
|
||
def distribute_tracks(self):
|
||
"""Distribute tracks evenly among workers"""
|
||
total_tracks = len(self.youtube_tracks)
|
||
tracks_per_worker = total_tracks // self.num_workers
|
||
remainder = total_tracks % self.num_workers
|
||
|
||
batches = []
|
||
start_idx = 0
|
||
|
||
for worker_id in range(self.num_workers):
|
||
# Add one extra track to first 'remainder' workers
|
||
batch_size = tracks_per_worker + (1 if worker_id < remainder else 0)
|
||
end_idx = start_idx + batch_size
|
||
|
||
# Create batch with (index, track) tuples
|
||
batch = [(i, self.youtube_tracks[i]) for i in range(start_idx, end_idx)]
|
||
batches.append(batch)
|
||
|
||
print(f"📦 Worker {worker_id}: tracks {start_idx}-{end_idx-1} ({len(batch)} tracks)")
|
||
start_idx = end_idx
|
||
|
||
return batches
|
||
|
||
def on_track_discovered(self, track_index, spotify_track, status):
|
||
"""Handle track discovery from any worker"""
|
||
self.processed_tracks.add(track_index)
|
||
if status == "found":
|
||
self.total_successful += 1
|
||
|
||
# Forward to UI
|
||
self.signals.track_discovered.emit(track_index, spotify_track, status)
|
||
|
||
def on_progress_updated(self, track_index):
|
||
"""Handle progress updates"""
|
||
# Update overall progress based on completed tracks
|
||
completed_count = len(self.processed_tracks)
|
||
self.signals.progress_updated.emit(completed_count)
|
||
|
||
# Check if all tracks are processed
|
||
if completed_count >= len(self.youtube_tracks):
|
||
self.signals.all_finished.emit(self.total_successful)
|
||
|
||
def cancel_all(self):
|
||
"""Cancel all running workers"""
|
||
for worker in self.workers:
|
||
worker.cancel()
|
||
|
||
def choose_best_spotify_match(spotify_results):
|
||
"""Choose the best Spotify track from search results, preferring album versions"""
|
||
if not spotify_results:
|
||
return None
|
||
|
||
# If only one result, return it
|
||
if len(spotify_results) == 1:
|
||
return spotify_results[0]
|
||
|
||
# For now, just return the first result to avoid the complex scoring
|
||
# TODO: Re-implement the scoring logic once we identify the attribute access issue
|
||
return spotify_results[0]
|
||
|
||
class YouTubeParsingWorker(QThread):
|
||
"""Worker thread for parsing YouTube playlists without blocking the UI"""
|
||
finished = pyqtSignal(object) # Emits the playlist object
|
||
error = pyqtSignal(str) # Emits error message
|
||
|
||
def __init__(self, url):
|
||
super().__init__()
|
||
self.url = url
|
||
|
||
def run(self):
|
||
"""Parse the YouTube playlist in a separate thread"""
|
||
try:
|
||
print(f"🎵 Starting YouTube playlist parsing for: {self.url}")
|
||
|
||
# Parse tracks using yt-dlp
|
||
tracks_data, playlist_title = parse_youtube_playlist(self.url)
|
||
|
||
if not tracks_data:
|
||
self.error.emit("No tracks found in the playlist")
|
||
return
|
||
|
||
# Create playlist object with actual title
|
||
playlist = create_youtube_playlist_object(tracks_data, self.url, playlist_title)
|
||
|
||
print(f"✅ Successfully created playlist with {len(playlist.tracks)} tracks")
|
||
self.finished.emit(playlist)
|
||
|
||
except Exception as e:
|
||
error_message = str(e)
|
||
print(f"❌ YouTube parsing worker error: {error_message}")
|
||
self.error.emit(error_message)
|
||
|
||
|
||
class ManualMatchModal(QDialog):
|
||
"""
|
||
A completely redesigned modal for manually searching and resolving a failed track download.
|
||
Features controlled searching, cancellation, and a UI consistent with the main application.
|
||
This version dynamically updates its track list from the parent modal and has a live-updating count.
|
||
"""
|
||
track_resolved = pyqtSignal(object)
|
||
|
||
def __init__(self, parent_modal):
|
||
"""Initializes the modal with a direct reference to the parent."""
|
||
super().__init__(parent_modal)
|
||
self.parent_modal = parent_modal
|
||
|
||
# Handle different parent modal types with flexible attribute access
|
||
try:
|
||
# Try the standard structure first (DownloadMissingTracksModal, DownloadMissingAlbumTracksModal)
|
||
self.soulseek_client = parent_modal.parent_page.soulseek_client
|
||
self.downloads_page = parent_modal.downloads_page
|
||
except AttributeError:
|
||
# Fallback for dashboard wishlist modal or other structures
|
||
try:
|
||
# Dashboard wishlist modal might have soulseek_client directly
|
||
self.soulseek_client = getattr(parent_modal, 'soulseek_client', None)
|
||
self.downloads_page = getattr(parent_modal, 'downloads_page', None)
|
||
|
||
# If still not found, try to get from parent widget hierarchy
|
||
if not self.soulseek_client:
|
||
current_widget = parent_modal.parent()
|
||
while current_widget and not self.soulseek_client:
|
||
self.soulseek_client = getattr(current_widget, 'soulseek_client', None)
|
||
self.downloads_page = getattr(current_widget, 'downloads_page', None)
|
||
current_widget = current_widget.parent()
|
||
|
||
except AttributeError:
|
||
pass
|
||
|
||
# Validate we have the required clients
|
||
if not self.soulseek_client:
|
||
raise RuntimeError("Could not find soulseek_client in parent modal or widget hierarchy")
|
||
|
||
self.failed_tracks = []
|
||
self.current_track_index = 0
|
||
self.current_track_info = None
|
||
self.search_worker = None
|
||
self.thread_pool = QThreadPool.globalInstance()
|
||
|
||
# Timer to delay automatic search
|
||
self.search_delay_timer = QTimer(self)
|
||
self.search_delay_timer.setSingleShot(True)
|
||
self.search_delay_timer.timeout.connect(self.perform_manual_search)
|
||
|
||
# Timer to periodically check for updates to the total failed track count
|
||
self.live_update_timer = QTimer(self)
|
||
self.live_update_timer.timeout.connect(self._check_and_update_count)
|
||
self.live_update_timer.start(1000) # Check every second
|
||
|
||
self.setup_ui()
|
||
self.load_current_track()
|
||
|
||
def setup_ui(self):
|
||
"""Set up the visually redesigned UI."""
|
||
self.setWindowTitle("Manual Track Correction")
|
||
self.setMinimumSize(900, 700)
|
||
self.setStyleSheet("""
|
||
QDialog { background-color: #1e1e1e; color: #ffffff; }
|
||
QLabel { color: #ffffff; font-size: 14px; }
|
||
QLineEdit {
|
||
background-color: #3a3a3a;
|
||
border: 1px solid #555555;
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
color: #ffffff;
|
||
font-size: 13px;
|
||
}
|
||
QScrollArea { border: none; background-color: #2d2d2d; }
|
||
QWidget#resultsWidget { background-color: #2d2d2d; }
|
||
""")
|
||
|
||
main_layout = QVBoxLayout(self)
|
||
main_layout.setContentsMargins(20, 20, 20, 20)
|
||
main_layout.setSpacing(15)
|
||
|
||
# --- Failed Track Info Card ---
|
||
info_frame = QFrame()
|
||
info_frame.setStyleSheet("""
|
||
QFrame {
|
||
background-color: #2d2d2d;
|
||
border: 1px solid #444444;
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
}
|
||
""")
|
||
info_layout = QVBoxLayout(info_frame)
|
||
self.info_label = QLabel("Loading track...")
|
||
self.info_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
self.info_label.setStyleSheet("color: #ffc107;") # Amber color for warning
|
||
self.info_label.setWordWrap(True)
|
||
info_layout.addWidget(self.info_label)
|
||
main_layout.addWidget(info_frame)
|
||
|
||
# --- Search Input and Controls ---
|
||
search_frame = QFrame()
|
||
search_layout = QHBoxLayout(search_frame)
|
||
search_layout.setContentsMargins(0,0,0,0)
|
||
search_layout.setSpacing(10)
|
||
|
||
self.search_input = QLineEdit()
|
||
self.search_input.setPlaceholderText("Enter a new search query or use the suggestion...")
|
||
self.search_input.returnPressed.connect(self.perform_manual_search)
|
||
|
||
self.search_btn = QPushButton("Search")
|
||
self.search_btn.clicked.connect(self.perform_manual_search)
|
||
self.search_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #1db954; color: #000000; border: none;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 10px 20px;
|
||
}
|
||
QPushButton:hover { background-color: #1ed760; }
|
||
""")
|
||
|
||
self.cancel_search_btn = QPushButton("Cancel")
|
||
self.cancel_search_btn.clicked.connect(self.cancel_current_search)
|
||
self.cancel_search_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #d32f2f; color: #ffffff; border: none;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 10px 20px;
|
||
}
|
||
QPushButton:hover { background-color: #f44336; }
|
||
""")
|
||
self.cancel_search_btn.hide() # Initially hidden
|
||
|
||
search_layout.addWidget(self.search_input, 1)
|
||
search_layout.addWidget(self.search_btn)
|
||
search_layout.addWidget(self.cancel_search_btn)
|
||
main_layout.addWidget(search_frame)
|
||
|
||
# --- Search Results Area ---
|
||
self.results_scroll = QScrollArea()
|
||
self.results_scroll.setWidgetResizable(True)
|
||
self.results_widget = QWidget()
|
||
self.results_widget.setObjectName("resultsWidget")
|
||
self.results_layout = QVBoxLayout(self.results_widget)
|
||
self.results_layout.setSpacing(8)
|
||
self.results_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||
self.results_scroll.setWidget(self.results_widget)
|
||
main_layout.addWidget(self.results_scroll, 1)
|
||
|
||
# --- Navigation and Close Buttons ---
|
||
nav_layout = QHBoxLayout()
|
||
self.prev_btn = QPushButton("← Previous")
|
||
self.prev_btn.clicked.connect(self.load_previous_track)
|
||
|
||
self.track_position_label = QLabel()
|
||
self.track_position_label.setStyleSheet("color: #ffffff; font-weight: bold;")
|
||
|
||
self.next_btn = QPushButton("Next →")
|
||
self.next_btn.clicked.connect(self.load_next_track)
|
||
|
||
self.close_btn = QPushButton("Close")
|
||
self.close_btn.setStyleSheet("""
|
||
QPushButton { background-color: #616161; color: #ffffff; }
|
||
QPushButton:hover { background-color: #757575; }
|
||
""")
|
||
self.close_btn.clicked.connect(self.reject)
|
||
|
||
for btn in [self.prev_btn, self.next_btn, self.close_btn]:
|
||
btn.setFixedSize(120, 40)
|
||
|
||
nav_layout.addWidget(self.prev_btn)
|
||
nav_layout.addStretch()
|
||
nav_layout.addWidget(self.track_position_label)
|
||
nav_layout.addStretch()
|
||
nav_layout.addWidget(self.next_btn)
|
||
nav_layout.addWidget(self.close_btn)
|
||
|
||
main_layout.addLayout(nav_layout)
|
||
|
||
def _check_and_update_count(self):
|
||
"""
|
||
Periodically called by a timer to check if the total number of failed
|
||
tracks has changed and updates the navigation label if needed.
|
||
"""
|
||
try:
|
||
live_total = len(self.parent_modal.permanently_failed_tracks)
|
||
|
||
# Extract the current total from the label text "Track X of Y"
|
||
parts = self.track_position_label.text().split(' of ')
|
||
if len(parts) == 2:
|
||
displayed_total = int(parts[1])
|
||
if live_total != displayed_total:
|
||
# If the total has changed, refresh the navigation state
|
||
self.update_navigation_state()
|
||
else:
|
||
# If the label is not in the expected format, update it anyway
|
||
self.update_navigation_state()
|
||
except (ValueError, IndexError):
|
||
# Handle cases where the label text is not yet set or in an unexpected format
|
||
self.update_navigation_state()
|
||
|
||
|
||
def _update_track_list(self):
|
||
"""
|
||
Syncs the modal's internal track list with the parent's live list,
|
||
preserving the user's current position.
|
||
"""
|
||
live_failed_tracks = self.parent_modal.permanently_failed_tracks
|
||
old_count = len(self.failed_tracks) if hasattr(self, 'failed_tracks') else 0
|
||
|
||
current_track_id = None
|
||
if self.current_track_info:
|
||
current_track_id = self.current_track_info.get('download_index')
|
||
|
||
self.failed_tracks = list(live_failed_tracks)
|
||
new_count = len(self.failed_tracks)
|
||
|
||
print(f"🔄 Track list sync: {old_count} → {new_count} failed tracks, current_track_id={current_track_id}")
|
||
|
||
if not self.failed_tracks:
|
||
print("⚠️ No failed tracks remaining")
|
||
return
|
||
|
||
new_index = -1
|
||
if current_track_id is not None:
|
||
for i, track in enumerate(self.failed_tracks):
|
||
if track.get('download_index') == current_track_id:
|
||
new_index = i
|
||
break
|
||
|
||
old_index = self.current_track_index
|
||
if new_index != -1:
|
||
self.current_track_index = new_index
|
||
else:
|
||
# If the current track was resolved, stay at the same index
|
||
# but check bounds against the new list length.
|
||
if self.current_track_index >= len(self.failed_tracks):
|
||
self.current_track_index = len(self.failed_tracks) - 1
|
||
|
||
if self.current_track_index < 0:
|
||
self.current_track_index = 0
|
||
|
||
if old_index != self.current_track_index:
|
||
print(f"📍 Index changed: {old_index} → {self.current_track_index}")
|
||
|
||
def load_current_track(self):
|
||
"""Loads the current failed track's info and intelligently triggers a search."""
|
||
self.cancel_current_search()
|
||
self.clear_results()
|
||
|
||
# Only sync track list if we don't already have the current track loaded
|
||
# This prevents the index from being reset when navigating
|
||
if not hasattr(self, 'failed_tracks') or len(self.failed_tracks) == 0:
|
||
self._update_track_list()
|
||
|
||
if not self.failed_tracks:
|
||
QMessageBox.information(self, "Complete", "All failed tracks have been addressed.")
|
||
self.accept()
|
||
return
|
||
|
||
# Ensure current_track_index is still valid after any potential sync
|
||
if self.current_track_index >= len(self.failed_tracks):
|
||
self.current_track_index = len(self.failed_tracks) - 1
|
||
if self.current_track_index < 0:
|
||
self.current_track_index = 0
|
||
|
||
self.update_navigation_state()
|
||
|
||
self.current_track_info = self.failed_tracks[self.current_track_index]
|
||
spotify_track = self.current_track_info['spotify_track']
|
||
artist = spotify_track.artists[0] if spotify_track.artists else "Unknown"
|
||
|
||
print(f"📍 Loading track at index {self.current_track_index}: {spotify_track.name} by {artist}")
|
||
|
||
# Use the original track name for the info label
|
||
self.info_label.setText(f"Could not find: <b>{spotify_track.name}</b><br>by {artist}")
|
||
|
||
# Use the ORIGINAL, UNCLEANED track name for the initial search query
|
||
self.search_input.setText(f"{artist} {spotify_track.name}")
|
||
|
||
self.search_delay_timer.start(1000)
|
||
|
||
def load_next_track(self):
|
||
"""Navigate to the next failed track."""
|
||
# Sync the track list first to handle any resolved tracks
|
||
self._update_track_list()
|
||
|
||
print(f"🔄 Next clicked: current_index={self.current_track_index}, failed_tracks_count={len(self.failed_tracks)}")
|
||
|
||
if self.current_track_index < len(self.failed_tracks) - 1:
|
||
self.current_track_index += 1
|
||
print(f"✅ Moving to next track: new_index={self.current_track_index}")
|
||
self.load_current_track()
|
||
else:
|
||
print(f"⚠️ Already at last track (index {self.current_track_index} of {len(self.failed_tracks)})")
|
||
|
||
def load_previous_track(self):
|
||
"""Navigate to the previous failed track."""
|
||
# Sync the track list first to handle any resolved tracks
|
||
self._update_track_list()
|
||
|
||
if self.current_track_index > 0:
|
||
self.current_track_index -= 1
|
||
self.load_current_track()
|
||
|
||
def update_navigation_state(self):
|
||
"""Update the 'Track X of Y' label and enable/disable nav buttons."""
|
||
# Use the internal synchronized list for consistency
|
||
total_tracks = len(self.failed_tracks)
|
||
|
||
# Ensure current_track_index is valid even if list shrinks
|
||
if self.current_track_index >= total_tracks:
|
||
self.current_track_index = max(0, total_tracks - 1)
|
||
|
||
current_pos = self.current_track_index + 1 if total_tracks > 0 else 0
|
||
|
||
self.track_position_label.setText(f"Track {current_pos} of {total_tracks}")
|
||
self.prev_btn.setEnabled(self.current_track_index > 0)
|
||
self.next_btn.setEnabled(self.current_track_index < total_tracks - 1)
|
||
|
||
def perform_manual_search(self):
|
||
"""Initiates a search for the current query, cancelling any existing search."""
|
||
self.search_delay_timer.stop()
|
||
self.cancel_current_search()
|
||
|
||
query = self.search_input.text().strip()
|
||
if not query: return
|
||
|
||
self.clear_results()
|
||
self.results_layout.addWidget(QLabel(f"<h3>Searching for '{query}'...</h3>"))
|
||
self.search_btn.hide()
|
||
self.cancel_search_btn.show()
|
||
|
||
self.search_worker = self.SearchWorker(self.soulseek_client, query)
|
||
self.search_worker.signals.completed.connect(self.on_manual_search_completed)
|
||
self.search_worker.signals.failed.connect(self.on_manual_search_failed)
|
||
self.thread_pool.start(self.search_worker)
|
||
|
||
def cancel_current_search(self):
|
||
"""Stops the currently running search worker."""
|
||
if self.search_worker:
|
||
self.search_worker.cancel()
|
||
self.search_worker = None
|
||
self.search_btn.show()
|
||
self.cancel_search_btn.hide()
|
||
|
||
def on_manual_search_completed(self, results):
|
||
"""Handles successful search results."""
|
||
if not self.search_worker or self.search_worker.is_cancelled:
|
||
return
|
||
|
||
self.cancel_current_search()
|
||
self.clear_results()
|
||
|
||
if not results:
|
||
self.results_layout.addWidget(QLabel("<h3>No results found for this query.</h3>"))
|
||
return
|
||
|
||
for result in results:
|
||
self.results_layout.addWidget(self.create_result_widget(result))
|
||
|
||
def on_manual_search_failed(self, error):
|
||
"""Handles a failed search attempt."""
|
||
if not self.search_worker or self.search_worker.is_cancelled:
|
||
return
|
||
|
||
self.cancel_current_search()
|
||
self.clear_results()
|
||
self.results_layout.addWidget(QLabel(f"<h3>Search failed:</h3><p>{error}</p>"))
|
||
|
||
def create_result_widget(self, result: TrackResult):
|
||
"""Creates a styled widget for a single search result."""
|
||
widget = QFrame()
|
||
widget.setStyleSheet("""
|
||
QFrame {
|
||
background-color: #3a3a3a;
|
||
border: 1px solid #555555;
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
}
|
||
QFrame:hover {
|
||
border: 1px solid #1db954;
|
||
}
|
||
""")
|
||
layout = QHBoxLayout(widget)
|
||
|
||
path_parts = result.filename.replace('\\', '/').split('/')
|
||
filename = path_parts[-1]
|
||
path_structure = '/'.join(path_parts[:-1])
|
||
|
||
size_kb = result.size // 1024
|
||
info_text = (f"<b>{filename}</b><br>"
|
||
f"<i style='color:#aaaaaa;'>{path_structure}</i><br>"
|
||
f"Quality: <b>{result.quality.upper()}</b>, "
|
||
f"Size: <b>{size_kb:,} KB</b>, "
|
||
f"User: <b>{result.username}</b>")
|
||
info_label = QLabel(info_text)
|
||
info_label.setWordWrap(True)
|
||
|
||
select_btn = QPushButton("Select")
|
||
select_btn.setFixedWidth(100)
|
||
select_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #1db954; color: #000000;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: #1ed760;
|
||
}
|
||
""")
|
||
select_btn.clicked.connect(lambda: self.on_selection_made(result))
|
||
|
||
layout.addWidget(info_label, 1)
|
||
layout.addWidget(select_btn)
|
||
return widget
|
||
|
||
def on_selection_made(self, slskd_result):
|
||
"""
|
||
Handles user selecting a track. The parent modal removes the track from the
|
||
live list, and this modal will sync with that change on the next load.
|
||
"""
|
||
print(f"Manual selection made: {slskd_result.filename}")
|
||
|
||
self.parent_modal.start_validated_download_parallel(
|
||
slskd_result,
|
||
self.current_track_info['spotify_track'],
|
||
self.current_track_info['track_index'],
|
||
self.current_track_info['table_index'],
|
||
self.current_track_info['download_index']
|
||
)
|
||
|
||
self.track_resolved.emit(self.current_track_info)
|
||
|
||
# Auto-advance to the next failed track after successful selection
|
||
# Use a small delay to allow the parent modal to update the failed tracks list
|
||
QTimer.singleShot(100, self._advance_to_next_track_after_resolution)
|
||
|
||
def _advance_to_next_track_after_resolution(self):
|
||
"""
|
||
Advances to the next failed track after a successful manual resolution.
|
||
If no more tracks remain, closes the modal with a success message.
|
||
"""
|
||
# Sync the track list to reflect the resolved track being removed
|
||
self._update_track_list()
|
||
|
||
if not self.failed_tracks:
|
||
# No more failed tracks - show success and close
|
||
QMessageBox.information(self, "Complete", "All failed tracks have been resolved! 🎉")
|
||
self.accept()
|
||
return
|
||
|
||
# Check if we need to adjust the current index after removal
|
||
if self.current_track_index >= len(self.failed_tracks):
|
||
self.current_track_index = len(self.failed_tracks) - 1
|
||
|
||
# Load the next track (which might be at the same index if current was removed)
|
||
print(f"🔄 Auto-advancing after resolution: index {self.current_track_index} of {len(self.failed_tracks)} remaining")
|
||
self.load_current_track()
|
||
|
||
def clear_results(self):
|
||
"""Removes all widgets from the results layout."""
|
||
while self.results_layout.count():
|
||
child = self.results_layout.takeAt(0)
|
||
if child.widget():
|
||
child.widget().deleteLater()
|
||
|
||
def closeEvent(self, event):
|
||
"""Ensures any running search is cancelled when the modal is closed."""
|
||
self.cancel_current_search()
|
||
self.search_delay_timer.stop()
|
||
self.live_update_timer.stop() # Stop the live update timer
|
||
super().closeEvent(event)
|
||
|
||
# --- Inner classes for self-contained search worker ---
|
||
class SearchWorkerSignals(QObject):
|
||
completed = pyqtSignal(list)
|
||
failed = pyqtSignal(str)
|
||
|
||
class SearchWorker(QRunnable):
|
||
def __init__(self, soulseek_client, query):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self.query = query
|
||
self.signals = ManualMatchModal.SearchWorkerSignals()
|
||
self.is_cancelled = False
|
||
|
||
def cancel(self):
|
||
self.is_cancelled = True
|
||
|
||
def run(self):
|
||
if self.is_cancelled:
|
||
return
|
||
|
||
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))
|
||
|
||
if self.is_cancelled:
|
||
return
|
||
|
||
if isinstance(search_result, tuple) and len(search_result) >= 1:
|
||
results_list = search_result[0] if search_result[0] else []
|
||
else:
|
||
results_list = []
|
||
|
||
self.signals.completed.emit(results_list)
|
||
|
||
except Exception as e:
|
||
if not self.is_cancelled:
|
||
self.signals.failed.emit(str(e))
|
||
finally:
|
||
if loop:
|
||
loop.close()
|
||
|
||
|
||
class DownloadMissingTracksModal(QDialog):
|
||
"""Enhanced modal for downloading missing tracks with live progress tracking"""
|
||
process_finished = pyqtSignal()
|
||
def __init__(self, playlist, playlist_item, parent_page, downloads_page, is_youtube_workflow=False):
|
||
super().__init__(parent_page)
|
||
self.playlist = playlist
|
||
self.playlist_item = playlist_item
|
||
self.parent_page = parent_page
|
||
self.parent_sync_page = parent_page # Reference to sync page for scan manager
|
||
self.downloads_page = downloads_page
|
||
self.matching_engine = MusicMatchingEngine()
|
||
self.wishlist_service = get_wishlist_service()
|
||
self.is_youtube_workflow = is_youtube_workflow # Flag to track if this is from YouTube discovery
|
||
|
||
# State tracking
|
||
self.total_tracks = len(playlist.tracks)
|
||
self.matched_tracks_count = 0
|
||
self.tracks_to_download_count = 0
|
||
self.downloaded_tracks_count = 0
|
||
self.analysis_complete = False
|
||
|
||
# --- FIX: 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 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 UI...")
|
||
self.setup_ui()
|
||
print("✅ Modal initialization complete")
|
||
|
||
def generate_smart_search_queries(self, artist_name, track_name):
|
||
"""
|
||
Generate smart search query variations with album-in-title detection.
|
||
Enhanced version with fallback strategies.
|
||
"""
|
||
# 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
|
||
|
||
# Try to get album information from the track context if available
|
||
# In sync context, we might not always have album info, but try to extract it
|
||
album_title = None
|
||
# If track_name contains potential album info, we'll let the detection handle it
|
||
|
||
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())
|
||
|
||
# Add traditional cleaned queries
|
||
import re
|
||
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
|
||
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
|
||
|
||
if cleaned_name and cleaned_name.lower() != track_name.lower():
|
||
legacy_queries.append(cleaned_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.playlist.name}")
|
||
self.resize(1200, 900)
|
||
self.setWindowFlags(Qt.WindowType.Window)
|
||
# self.setWindowFlags(Qt.WindowType.Dialog)
|
||
|
||
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 is_downloading(self):
|
||
"""Check if any downloads are currently in progress"""
|
||
return (self.download_in_progress or
|
||
not self.analysis_complete or
|
||
len(self.active_workers) > 0 or
|
||
(hasattr(self, 'tracks_to_download_count') and
|
||
hasattr(self, 'downloaded_tracks_count') and
|
||
self.downloaded_tracks_count < self.tracks_to_download_count))
|
||
|
||
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 Tracks")
|
||
title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
title.setStyleSheet("color: #1db954;")
|
||
|
||
subtitle = QLabel(f"Playlist: {self.playlist.name}")
|
||
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("📋 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 playlist tracks"""
|
||
self.track_table.setRowCount(len(self.playlist.tracks))
|
||
for i, track in enumerate(self.playlist.tracks):
|
||
self.track_table.setItem(i, 0, QTableWidgetItem(track.name))
|
||
artist_name = track.artists[0] if track.artists else "Unknown"
|
||
self.track_table.setItem(i, 1, QTableWidgetItem(artist_name))
|
||
duration = self.format_duration(track.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 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.playlist.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)
|
||
# THIS IS THE FIX: The specific stylesheet for this button is restored below
|
||
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"""
|
||
# Only update refresh button state for Spotify workflows, not YouTube workflows
|
||
if not self.is_youtube_workflow:
|
||
# --- FIX: Trigger the UI change on the main page ---
|
||
# This is the correct point to signal that the process has started.
|
||
self.parent_page.on_download_process_started(self.playlist.id, self.playlist_item)
|
||
|
||
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 # Set flag
|
||
self.start_plex_analysis()
|
||
|
||
|
||
def start_plex_analysis(self):
|
||
"""Start media server analysis using existing worker"""
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
|
||
if active_server == "plex":
|
||
media_client = getattr(self.parent_page, 'plex_client', None)
|
||
else: # jellyfin
|
||
media_client = getattr(self.parent_page, 'jellyfin_client', None)
|
||
|
||
worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, media_client, active_server)
|
||
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"🔍 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"✅ Analysis complete: {len(self.missing_tracks)} to download")
|
||
if self.missing_tracks:
|
||
# --- FIX: This line was missing, which prevented downloads from starting. ---
|
||
self.start_download_progress()
|
||
else:
|
||
# Handle case where no tracks are missing
|
||
self.download_in_progress = False # Mark process as finished
|
||
self.cancel_btn.hide()
|
||
|
||
# If this is a YouTube workflow, clean up status widget (no downloads needed)
|
||
if self.is_youtube_workflow and hasattr(self.parent_page, 'show_youtube_placeholder'):
|
||
self.parent_page.show_youtube_placeholder()
|
||
if self.playlist.id in self.parent_page.active_youtube_download_modals:
|
||
del self.parent_page.active_youtube_download_modals[self.playlist.id]
|
||
|
||
# The modal now stays open.
|
||
# The process_finished signal is still emitted to unlock the main UI.
|
||
self.process_finished.emit()
|
||
# Get server name for message
|
||
try:
|
||
from config.settings import config_manager
|
||
active_server = config_manager.get_active_media_server()
|
||
server_name = active_server.title() if active_server else "Plex"
|
||
except:
|
||
server_name = "Plex"
|
||
|
||
QMessageBox.information(self, "Analysis Complete", f"All tracks already exist in {server_name}! No downloads needed.")
|
||
|
||
def on_analysis_failed(self, error_message):
|
||
print(f"❌ Analysis failed: {error_message}")
|
||
QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze 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_playlist(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
|
||
|
||
# Check if we're done: either all downloads completed OR all remaining work is done
|
||
downloads_complete = (self.download_queue_index >= len(self.missing_tracks) and self.active_parallel_downloads == 0)
|
||
all_work_complete = (self.completed_downloads >= len(self.missing_tracks))
|
||
|
||
if downloads_complete or all_work_complete:
|
||
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:
|
||
# IMPORTANT: 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. This is used for both initial downloads
|
||
and for manual retries from the 'Correct Failed Matches' modal.
|
||
"""
|
||
track_info = self.parallel_search_tracking[download_index]
|
||
|
||
# --- FIX ---
|
||
# If this track was previously marked as 'completed' (e.g., from a failure),
|
||
# we need to reset its state to allow the new download attempt to be tracked correctly.
|
||
if track_info.get('completed', False):
|
||
print(f"🔄 Resetting state for manually retried track (index: {download_index}).")
|
||
track_info['completed'] = False
|
||
|
||
# Decrement the failed count since we are retrying it.
|
||
if self.failed_downloads > 0:
|
||
self.failed_downloads -= 1
|
||
|
||
# This download is now active again. The counter was decremented when it failed,
|
||
# so we increment it here to reflect its new active status.
|
||
self.active_parallel_downloads += 1
|
||
|
||
# The 'completed_downloads' counter was incremented when the track originally failed.
|
||
# We decrement it here so the overall progress calculation remains accurate when
|
||
# this new download attempt completes.
|
||
if self.completed_downloads > 0:
|
||
self.completed_downloads -= 1
|
||
|
||
# Add the new download source to the used sources to prevent retrying with the 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)
|
||
print(f"🔧 Updating table at index {table_index} to '... Queued' for manual retry")
|
||
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 start_matched_download_via_infrastructure_parallel(self, spotify_based_result, track_index, table_index, download_index):
|
||
"""Start infrastructure download with parallel completion tracking"""
|
||
try:
|
||
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):
|
||
"""
|
||
Starts the background worker to process download statuses.
|
||
This version is updated to use the new worker and pass the correct data.
|
||
"""
|
||
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:
|
||
# Ensure slskd_result exists and has a filename
|
||
if d.get('slskd_result') and hasattr(d['slskd_result'], 'filename'):
|
||
# Pass the current missing count to the worker so it can be incremented
|
||
items_to_check.append({
|
||
'widget_id': d['download_index'],
|
||
'download_id': d.get('download_id'), # Use .get for safety
|
||
'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
|
||
|
||
# The new worker doesn't need the transfers directory.
|
||
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"Status Worker Error: {e}"))
|
||
self.download_status_pool.start(worker)
|
||
|
||
|
||
|
||
|
||
def _handle_processed_status_updates(self, results):
|
||
"""
|
||
Applies status updates from the background worker and triggers retry logic.
|
||
This version correctly handles the payload from the new worker and adds a timeout for stuck downloads.
|
||
"""
|
||
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 from the worker
|
||
# This is important for the grace period logic to work across polls.
|
||
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']
|
||
|
||
# --- FIX: Add timeout for downloads stuck at 0% ---
|
||
# This handles cases where the API reports "InProgress" but no data is moving.
|
||
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 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 download {download_id}")
|
||
else:
|
||
print(f"⚠️ Failed to cancel download {download_id}")
|
||
finally:
|
||
loop.close()
|
||
else:
|
||
print(f"⚠️ Missing download_id ({download_id}) or username ({username}) for cancellation")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error cancelling 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 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:
|
||
print(f"🔧 Track {download_index} completed successfully - updating table index {track_info['table_index']} to '✅ Downloaded'")
|
||
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
|
||
# --- FIX ---
|
||
# Corrected the label update to use the incremented counter variable.
|
||
self.downloaded_count_label.setText(str(self.downloaded_tracks_count))
|
||
self.successful_downloads += 1
|
||
|
||
# Update YouTube card progress if this is a YouTube workflow
|
||
if self.is_youtube_workflow and hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_progress'):
|
||
self.parent_page.update_youtube_card_progress(
|
||
self.youtube_url,
|
||
total=len(self.missing_tracks),
|
||
matched=self.successful_downloads,
|
||
failed=len(self.permanently_failed_tracks)
|
||
)
|
||
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:
|
||
print(f"🔧 Track {download_index} failed - updating table index {table_index} to '❌ Failed'")
|
||
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
|
||
|
||
# Update YouTube card progress if this is a YouTube workflow
|
||
if self.is_youtube_workflow and hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_progress'):
|
||
self.parent_page.update_youtube_card_progress(
|
||
self.youtube_url,
|
||
total=len(self.missing_tracks),
|
||
matched=self.successful_downloads,
|
||
failed=len(self.permanently_failed_tracks)
|
||
)
|
||
|
||
self.completed_downloads += 1
|
||
self.active_parallel_downloads -= 1
|
||
self.download_progress.setValue(self.completed_downloads)
|
||
self.start_next_batch_of_downloads()
|
||
|
||
def on_parallel_track_failed(self, download_index, reason):
|
||
"""Handle failure of a parallel track download"""
|
||
print(f"❌ 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 on_correct_failed_matches_clicked(self):
|
||
"""Opens the modal to manually correct failed downloads."""
|
||
if not self.permanently_failed_tracks: return
|
||
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):
|
||
"""Handles a track being successfully resolved by the ManualMatchModal."""
|
||
print(f"🔧 Manual match resolved - 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 - remaining: {len(self.permanently_failed_tracks)}")
|
||
|
||
# Update progress bar to account for manually resolved track
|
||
# The track was manually resolved, so we need to count it as "completed"
|
||
self.successful_downloads += 1
|
||
self.completed_downloads += 1
|
||
# Update the progress bar maximum to reflect the actual remaining work
|
||
total_remaining_work = len(self.missing_tracks) - (self.successful_downloads - len(self.permanently_failed_tracks))
|
||
if total_remaining_work > 0:
|
||
# Recalculate progress: completed work / total original work
|
||
progress_value = self.completed_downloads
|
||
self.download_progress.setValue(progress_value)
|
||
print(f"📊 Updated progress: {progress_value}/{self.download_progress.maximum()} (manual fix)")
|
||
else:
|
||
print("⚠️ Could not find original failed track to remove")
|
||
self.update_failed_matches_button()
|
||
|
||
def find_track_index_in_playlist(self, spotify_track):
|
||
"""Find the table row index for a given Spotify track"""
|
||
for i, playlist_track in enumerate(self.playlist.tracks):
|
||
if playlist_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 downloads completed!")
|
||
self.cancel_btn.hide()
|
||
|
||
# If this is a YouTube workflow, update card and clean up
|
||
if self.is_youtube_workflow:
|
||
# Update card to download_complete phase
|
||
if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_phase'):
|
||
self.parent_page.update_youtube_card_phase(self.youtube_url, 'download_complete')
|
||
|
||
# Clean up status widget
|
||
if hasattr(self.parent_page, 'show_youtube_placeholder'):
|
||
self.parent_page.show_youtube_placeholder()
|
||
if self.playlist.id in self.parent_page.active_youtube_download_modals:
|
||
del self.parent_page.active_youtube_download_modals[self.playlist.id]
|
||
|
||
# If this is a Tidal workflow, update card and clean up
|
||
if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'):
|
||
# Update Tidal card to download_complete phase
|
||
if hasattr(self.parent_page, 'update_tidal_card_phase'):
|
||
self.parent_page.update_tidal_card_phase(self.playlist_id, 'download_complete')
|
||
|
||
# Clean up download modal reference from Tidal state
|
||
if hasattr(self.parent_page, 'tidal_playlist_states') and self.playlist_id in self.parent_page.tidal_playlist_states:
|
||
state = self.parent_page.tidal_playlist_states[self.playlist_id]
|
||
if state.get('download_modal') == self:
|
||
state['download_modal'] = None
|
||
|
||
# Remove from active download modals
|
||
if self.playlist.id in self.parent_page.active_youtube_download_modals:
|
||
del self.parent_page.active_youtube_download_modals[self.playlist.id]
|
||
|
||
# The process_finished signal is still emitted to unlock the main UI.
|
||
self.process_finished.emit()
|
||
|
||
# Request Plex library scan if we have successful downloads
|
||
if self.successful_downloads > 0 and hasattr(self, 'parent_sync_page') and self.parent_sync_page.scan_manager:
|
||
self.parent_sync_page.scan_manager.request_scan(f"Playlist download completed ({self.successful_downloads} tracks)")
|
||
|
||
# Add 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.playlist.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
|
||
|
||
if self.permanently_failed_tracks:
|
||
try:
|
||
# Add failed tracks to wishlist
|
||
source_context = {
|
||
'playlist_name': getattr(self.playlist, 'name', 'Unknown Playlist'),
|
||
'playlist_id': getattr(self.playlist, 'id', None),
|
||
'added_from': 'sync_page_modal',
|
||
'timestamp': datetime.now().isoformat()
|
||
}
|
||
|
||
for failed_track_info in self.permanently_failed_tracks:
|
||
try:
|
||
success = self.wishlist_service.add_failed_track_from_modal(
|
||
track_info=failed_track_info,
|
||
source_type='playlist',
|
||
source_context=source_context
|
||
)
|
||
if success:
|
||
wishlist_added_count += 1
|
||
except Exception as e:
|
||
logger.error(f"Failed to add track to wishlist: {e}")
|
||
|
||
if wishlist_added_count > 0:
|
||
logger.info(f"Added {wishlist_added_count} failed tracks to wishlist from playlist '{self.playlist.name}'")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error adding failed tracks to wishlist: {e}")
|
||
|
||
# Determine the final message based on success or failure.
|
||
if self.permanently_failed_tracks:
|
||
final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\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_()
|
||
else:
|
||
final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\nAll tracks were downloaded successfully!"
|
||
|
||
QMessageBox.information(self, "Downloads Complete", final_message)
|
||
|
||
def on_cancel_clicked(self):
|
||
"""Handle Cancel button - cancels operations, resets state, and closes modal."""
|
||
print("🛑 Cancel button clicked - cancelling all operations and cleaning up")
|
||
|
||
self.cancel_operations()
|
||
self.download_in_progress = False # CRITICAL: Reset the state flag.
|
||
|
||
if self.is_youtube_workflow:
|
||
# Revert the main card to the discovery phase.
|
||
if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_phase'):
|
||
print("🔄 Returning YouTube playlist to discovery_complete state")
|
||
self.parent_page.update_youtube_card_phase(self.youtube_url, 'discovery_complete')
|
||
|
||
# Handle Tidal playlist cancel - revert to discovery_complete phase
|
||
if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'):
|
||
if hasattr(self.parent_page, 'update_tidal_card_phase'):
|
||
print("🔄 Returning Tidal playlist to discovery_complete state")
|
||
self.parent_page.update_tidal_card_phase(self.playlist_id, 'discovery_complete')
|
||
|
||
# Clean up download modal reference from Tidal state
|
||
if hasattr(self.parent_page, 'tidal_playlist_states') and self.playlist_id in self.parent_page.tidal_playlist_states:
|
||
state = self.parent_page.tidal_playlist_states[self.playlist_id]
|
||
if state.get('download_modal') == self:
|
||
state['download_modal'] = None
|
||
|
||
# Clean up this modal's reference.
|
||
if self.playlist.id in self.parent_page.active_youtube_download_modals:
|
||
del self.parent_page.active_youtube_download_modals[self.playlist.id]
|
||
|
||
# --- THE FIX ---
|
||
# This block now correctly finds and removes the temporary "green card" (the status widget)
|
||
# without affecting the main playlist card. This prevents the card from disappearing
|
||
# on subsequent cancellations.
|
||
if (hasattr(self.parent_page, 'youtube_status_widgets') and
|
||
self.playlist.id in self.parent_page.youtube_status_widgets):
|
||
print(f"🧹 Cleaning up YouTube status widget on cancel for playlist: {self.playlist.id}")
|
||
status_widget = self.parent_page.youtube_status_widgets.pop(self.playlist.id, None)
|
||
if status_widget:
|
||
status_widget.setParent(None)
|
||
status_widget.deleteLater()
|
||
|
||
self.process_finished.emit()
|
||
self.reject() # This properly closes and destroys the modal.
|
||
|
||
def on_close_clicked(self):
|
||
"""Handle the 'Close' button by triggering the modal's unified close event."""
|
||
self.close()
|
||
|
||
def cancel_operations(self):
|
||
"""Cancel any ongoing operations, including active slskd downloads."""
|
||
print("🛑 Cancelling all operations for this playlist...")
|
||
self.cancel_requested = True # Flag to stop any new workers from starting.
|
||
|
||
# --- FIX: Actively cancel downloads on the slskd server ---
|
||
if self.active_downloads:
|
||
print(f"Requesting cancellation for {len(self.active_downloads)} active download(s)...")
|
||
|
||
import asyncio
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
soulseek_client = self.parent_page.soulseek_client
|
||
|
||
# Create tasks to cancel all active downloads concurrently
|
||
tasks = []
|
||
for download_info in self.active_downloads:
|
||
download_id = download_info.get('download_id')
|
||
# Assumes the soulseek_client has a method to make raw API calls.
|
||
# A DELETE request is standard for cancellation in RESTful APIs like slskd's.
|
||
if download_id and hasattr(soulseek_client, '_make_request'):
|
||
tasks.append(
|
||
soulseek_client._make_request('DELETE', f'transfers/downloads/{download_id}')
|
||
)
|
||
|
||
if tasks:
|
||
try:
|
||
# Wait for all cancellation requests to be sent
|
||
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
|
||
print("All cancellation requests sent to slskd.")
|
||
except Exception as e:
|
||
print(f"An error occurred while sending cancellation requests: {e}")
|
||
|
||
# Cancel background workers (like the initial Plex analysis)
|
||
for worker in self.active_workers:
|
||
if hasattr(worker, 'cancel'):
|
||
worker.cancel()
|
||
self.active_workers.clear()
|
||
|
||
# Clean up any fallback thread pools
|
||
for pool in self.fallback_pools:
|
||
pool.waitForDone(1000)
|
||
self.fallback_pools.clear()
|
||
|
||
# Stop the status polling timer to prevent further checks
|
||
self.download_status_timer.stop()
|
||
print("🛑 Modal operations cancelled successfully.")
|
||
|
||
def closeEvent(self, event):
|
||
"""Override the window's close event to provide custom logic."""
|
||
if self.download_in_progress and not self.cancel_requested:
|
||
print("Download in progress. Hiding modal and updating card phase.")
|
||
self.hide()
|
||
event.ignore() # Prevent the modal from being destroyed.
|
||
|
||
# --- THE FIX ---
|
||
# Instead of showing the status widget directly, this now tells the main
|
||
# page to transition the card to the 'downloading' phase.
|
||
if self.is_youtube_workflow and hasattr(self, 'youtube_url'):
|
||
if hasattr(self.parent_page, 'update_youtube_card_phase'):
|
||
self.parent_page.update_youtube_card_phase(self.youtube_url, 'downloading')
|
||
|
||
# Handle Tidal playlist downloading phase update
|
||
if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'):
|
||
if hasattr(self.parent_page, 'update_tidal_card_phase'):
|
||
self.parent_page.update_tidal_card_phase(self.playlist_id, 'downloading')
|
||
return
|
||
|
||
print("No download in progress or cancel requested. Performing full cleanup.")
|
||
self.on_cancel_clicked()
|
||
# on_cancel_clicked() calls self.reject(), which will properly accept the close event.
|
||
|
||
# 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 get_valid_candidates(self, results, spotify_track, query):
|
||
"""
|
||
Scores and filters search results, then performs a strict artist verification
|
||
by checking the file path. This prevents downloading tracks from the wrong artist.
|
||
"""
|
||
if not results:
|
||
return []
|
||
|
||
# Step 1: Get initial confident matches with version-aware scoring
|
||
# This gives us a sorted list of potential candidates, preferring originals.
|
||
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...")
|
||
|
||
# Step 2: Perform strict artist verification on the initial candidates.
|
||
verified_candidates = []
|
||
spotify_artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
||
|
||
# **IMPROVEMENT**: More robust normalization for both artist name and file path.
|
||
# This removes all non-alphanumeric characters and converts to lowercase.
|
||
# e.g., "Virtual Mage" -> "virtualmage", "virtual-mage" -> "virtualmage"
|
||
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()
|
||
|
||
# **THE CRITICAL CHECK**: See if the cleaned artist's name is in the cleaned folder path.
|
||
if normalized_spotify_artist in normalized_slskd_path:
|
||
# Artist name was found in the path, this is a valid candidate.
|
||
print(f"✔️ Artist '{spotify_artist_name}' VERIFIED in path: '{slskd_full_path}'")
|
||
verified_candidates.append(candidate)
|
||
else:
|
||
# Artist name was NOT found. Discard this candidate.
|
||
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_page, 'soulseek_client'):
|
||
quality_filtered = self.parent_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
|
||
for candidate in verified_candidates[:3]: # Show top 3
|
||
version = getattr(candidate, 'version_type', 'unknown')
|
||
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[:80]}...")
|
||
|
||
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()
|
||
|
||
|
||
class YouTubeDownloadMissingTracksModal(QDialog):
|
||
"""Enhanced modal for downloading YouTube playlist tracks with Spotify discovery"""
|
||
process_finished = pyqtSignal()
|
||
|
||
def __init__(self, playlist, playlist_item, parent_page, downloads_page):
|
||
super().__init__(parent_page)
|
||
self.playlist = playlist # YouTube playlist with cleaned tracks
|
||
self.playlist_item = playlist_item
|
||
self.parent_page = parent_page
|
||
self.downloads_page = downloads_page
|
||
self.total_tracks = len(playlist.tracks) if playlist else 0
|
||
|
||
# Progress tracking
|
||
self.spotify_discovered_tracks = [None] * self.total_tracks # List of discovered Spotify tracks
|
||
self.spotify_search_completed = False
|
||
self.spotify_worker = None
|
||
|
||
# UI components
|
||
self.track_table = None
|
||
self.analysis_progress = None
|
||
self.spotify_progress = None
|
||
self.begin_search_btn = None
|
||
self.cancel_btn = None
|
||
self.sync_btn = None
|
||
|
||
# Sync state tracking
|
||
self.sync_in_progress = False
|
||
self.is_youtube_workflow = True
|
||
|
||
self.setup_ui()
|
||
if self.playlist and self.total_tracks > 0:
|
||
self.populate_initial_table()
|
||
self.start_spotify_discovery()
|
||
|
||
def setup_ui(self):
|
||
"""Set up the modal UI for YouTube or Tidal playlist discovery"""
|
||
if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist:
|
||
self.setWindowTitle(f"Tidal Playlist Discovery - {self.playlist.name}")
|
||
else:
|
||
self.setWindowTitle(f"YouTube Playlist Discovery - {self.playlist.name}")
|
||
self.resize(1400, 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)
|
||
|
||
# Header section
|
||
header_section = self.create_header_section()
|
||
main_layout.addWidget(header_section)
|
||
|
||
# Progress section
|
||
progress_section = self.create_progress_section()
|
||
main_layout.addWidget(progress_section)
|
||
|
||
# Table section
|
||
table_section = self.create_track_table()
|
||
main_layout.addWidget(table_section, stretch=1)
|
||
|
||
# Button section
|
||
button_section = self.create_buttons()
|
||
main_layout.addWidget(button_section)
|
||
|
||
def create_header_section(self):
|
||
"""Create header with title and summary"""
|
||
header_frame = QFrame()
|
||
header_frame.setStyleSheet("""
|
||
QFrame {
|
||
background-color: #2d2d2d; border: 1px solid #444444;
|
||
border-radius: 8px; padding: 15px;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(header_frame)
|
||
|
||
title = QLabel("🎵 YouTube Playlist Discovery")
|
||
title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
title.setStyleSheet("color: #1db954;")
|
||
|
||
subtitle = QLabel(f"Playlist: {self.playlist.name} ({self.total_tracks} tracks)")
|
||
subtitle.setFont(QFont("Arial", 11))
|
||
subtitle.setStyleSheet("color: #aaaaaa;")
|
||
|
||
description = QLabel("Discovering clean Spotify metadata for YouTube tracks...")
|
||
description.setFont(QFont("Arial", 10))
|
||
description.setStyleSheet("color: #888888;")
|
||
|
||
layout.addWidget(title)
|
||
layout.addWidget(subtitle)
|
||
layout.addWidget(description)
|
||
|
||
return header_frame
|
||
|
||
def create_progress_section(self):
|
||
"""Create progress tracking section"""
|
||
progress_frame = QFrame()
|
||
progress_frame.setStyleSheet("""
|
||
QFrame {
|
||
background-color: #2d2d2d; border: 1px solid #444444;
|
||
border-radius: 8px; padding: 15px;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(progress_frame)
|
||
|
||
# Spotify discovery progress
|
||
spotify_label = QLabel("🔍 Spotify Discovery Progress")
|
||
spotify_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||
|
||
self.spotify_progress = QProgressBar()
|
||
self.spotify_progress.setFixedHeight(20)
|
||
self.spotify_progress.setMaximum(self.total_tracks)
|
||
self.spotify_progress.setValue(0)
|
||
self.spotify_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; }
|
||
""")
|
||
|
||
# Plex analysis progress (hidden initially)
|
||
analysis_label = QLabel("📊 Plex Analysis Progress")
|
||
analysis_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||
|
||
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: #ff6b6b; border-radius: 9px; }
|
||
""")
|
||
self.analysis_progress.setVisible(False)
|
||
analysis_label.setVisible(False)
|
||
|
||
layout.addWidget(spotify_label)
|
||
layout.addWidget(self.spotify_progress)
|
||
layout.addWidget(analysis_label)
|
||
layout.addWidget(self.analysis_progress)
|
||
|
||
return progress_frame
|
||
|
||
def create_track_table(self):
|
||
"""Create track table with YouTube-specific columns"""
|
||
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("📋 Track Discovery & 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(7)
|
||
self.track_table.setHorizontalHeaderLabels([
|
||
"YT Track", "YT Artist", "Spotify Match Status",
|
||
"Spotify Track", "Spotify Artist", "Spotify Album", "Status"
|
||
])
|
||
|
||
# Set columns to span full width evenly
|
||
header = self.track_table.horizontalHeader()
|
||
|
||
for i in range(7):
|
||
header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch)
|
||
|
||
self.track_table.setAlternatingRowColors(True)
|
||
self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
self.track_table.setStyleSheet("""
|
||
QTableWidget {
|
||
background-color: #1e1e1e; color: #ffffff;
|
||
gridline-color: #404040; border: none;
|
||
}
|
||
QTableWidget::item { padding: 8px; border-bottom: 1px solid #333333; }
|
||
QTableWidget::item:selected { background-color: #404040; }
|
||
QHeaderView::section {
|
||
background-color: #333333; color: #ffffff; border: none;
|
||
padding: 10px; font-weight: bold; font-size: 11px;
|
||
}
|
||
""")
|
||
|
||
layout.addWidget(header_label)
|
||
layout.addWidget(self.track_table)
|
||
|
||
return table_frame
|
||
|
||
def create_buttons(self):
|
||
"""Create button section"""
|
||
button_frame = QFrame()
|
||
layout = QHBoxLayout(button_frame)
|
||
layout.setSpacing(10)
|
||
|
||
layout.addStretch()
|
||
|
||
# Create sync status display (hidden by default)
|
||
self.sync_status_widget = self.create_sync_status_display()
|
||
layout.addWidget(self.sync_status_widget)
|
||
|
||
# Sync button - appears to the left of Begin Search
|
||
self.sync_btn = QPushButton("🔄 Sync This Playlist")
|
||
self.sync_btn.setEnabled(False) # Disabled until Spotify discovery completes
|
||
self.sync_btn.clicked.connect(self.on_sync_clicked)
|
||
self.sync_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #ff6b6b; color: #ffffff; border: none;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 10px 20px; min-width: 120px;
|
||
}
|
||
QPushButton:hover { background-color: #ff5252; }
|
||
QPushButton:disabled { background-color: #404040; color: #888888; }
|
||
""")
|
||
|
||
self.begin_search_btn = QPushButton("🔍 Download Missing Tracks")
|
||
self.begin_search_btn.setEnabled(False) # Disabled until Spotify discovery completes
|
||
self.begin_search_btn.clicked.connect(self.on_begin_plex_analysis)
|
||
|
||
self.cancel_btn = QPushButton("❌ Cancel")
|
||
self.cancel_btn.clicked.connect(self.on_cancel_clicked)
|
||
|
||
# Close button - hides modal without clearing data
|
||
self.close_btn = QPushButton("🏠 Close")
|
||
self.close_btn.clicked.connect(self.on_close_clicked)
|
||
self.close_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #6c757d; color: #ffffff; border: none;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 10px 20px; min-width: 100px;
|
||
}
|
||
QPushButton:hover { background-color: #5a6268; }
|
||
""")
|
||
|
||
layout.addWidget(self.sync_btn)
|
||
layout.addWidget(self.begin_search_btn)
|
||
layout.addWidget(self.close_btn)
|
||
layout.addWidget(self.cancel_btn)
|
||
|
||
return button_frame
|
||
|
||
def create_sync_status_display(self):
|
||
"""Create sync status display widget (hidden by default) - same as Spotify modal"""
|
||
sync_status = QFrame()
|
||
sync_status.setStyleSheet("""
|
||
QFrame {
|
||
background: rgba(29, 185, 84, 0.1);
|
||
border: 1px solid rgba(29, 185, 84, 0.3);
|
||
border-radius: 12px;
|
||
}
|
||
""")
|
||
sync_status.setMinimumHeight(36)
|
||
sync_status.hide() # Hidden by default
|
||
|
||
layout = QHBoxLayout(sync_status)
|
||
layout.setContentsMargins(12, 8, 12, 8)
|
||
layout.setSpacing(12)
|
||
|
||
# Total tracks
|
||
self.total_tracks_label = QLabel("♪ 0")
|
||
self.total_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
self.total_tracks_label.setStyleSheet("color: #ffa500; background: transparent; border: none;")
|
||
|
||
# Matched tracks
|
||
self.matched_tracks_label = QLabel("✓ 0")
|
||
self.matched_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
self.matched_tracks_label.setStyleSheet("color: #1db954; background: transparent; border: none;")
|
||
|
||
# Failed tracks
|
||
self.failed_tracks_label = QLabel("✗ 0")
|
||
self.failed_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
self.failed_tracks_label.setStyleSheet("color: #e22134; background: transparent; border: none;")
|
||
|
||
# Percentage
|
||
self.percentage_label = QLabel("0%")
|
||
self.percentage_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Bold))
|
||
self.percentage_label.setStyleSheet("color: #1db954; background: transparent; border: none;")
|
||
|
||
layout.addWidget(self.total_tracks_label)
|
||
|
||
# Separator 1
|
||
sep1 = QLabel("/")
|
||
sep1.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
sep1.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(sep1)
|
||
|
||
layout.addWidget(self.matched_tracks_label)
|
||
|
||
# Separator 2
|
||
sep2 = QLabel("/")
|
||
sep2.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
sep2.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(sep2)
|
||
|
||
layout.addWidget(self.failed_tracks_label)
|
||
|
||
# Separator 3
|
||
sep3 = QLabel("/")
|
||
sep3.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
||
sep3.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||
layout.addWidget(sep3)
|
||
|
||
layout.addWidget(self.percentage_label)
|
||
|
||
return sync_status
|
||
|
||
def update_sync_status(self, total_tracks=0, matched_tracks=0, failed_tracks=0):
|
||
"""Update sync status display"""
|
||
if self.sync_status_widget:
|
||
self.total_tracks_label.setText(f"♪ {total_tracks}")
|
||
self.matched_tracks_label.setText(f"✓ {matched_tracks}")
|
||
self.failed_tracks_label.setText(f"✗ {failed_tracks}")
|
||
|
||
if total_tracks > 0:
|
||
processed_tracks = matched_tracks + failed_tracks
|
||
percentage = int((processed_tracks / total_tracks) * 100)
|
||
self.percentage_label.setText(f"{percentage}%")
|
||
else:
|
||
self.percentage_label.setText("0%")
|
||
|
||
def populate_initial_table(self):
|
||
"""Populate table with initial YouTube track data"""
|
||
self.track_table.setRowCount(self.total_tracks)
|
||
|
||
for i, track in enumerate(self.playlist.tracks):
|
||
# YT Track
|
||
yt_track_item = QTableWidgetItem(track.name)
|
||
yt_track_item.setFlags(yt_track_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.track_table.setItem(i, 0, yt_track_item)
|
||
|
||
# YT Artist
|
||
yt_artist = track.artists[0] if track.artists else "Unknown"
|
||
yt_artist_item = QTableWidgetItem(yt_artist)
|
||
yt_artist_item.setFlags(yt_artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.track_table.setItem(i, 1, yt_artist_item)
|
||
|
||
# Spotify Match Status
|
||
status_item = QTableWidgetItem("Pending...")
|
||
status_item.setFlags(status_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.track_table.setItem(i, 2, status_item)
|
||
|
||
# Empty cells for Spotify data (to be filled during discovery)
|
||
for col in [3, 4, 5, 6]:
|
||
empty_item = QTableWidgetItem("")
|
||
empty_item.setFlags(empty_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.track_table.setItem(i, col, empty_item)
|
||
|
||
def start_spotify_discovery(self):
|
||
"""Start the Spotify discovery process using background worker"""
|
||
print(f"🔍 Starting Spotify discovery for {self.total_tracks} tracks...")
|
||
|
||
# Update all rows to show "Searching..." status
|
||
for row in range(self.total_tracks):
|
||
status_item = self.track_table.item(row, 2)
|
||
if status_item:
|
||
status_item.setText("🔍 Pending...")
|
||
|
||
# Create and start a single optimized Spotify discovery worker
|
||
# Import matching engine for validation
|
||
from core.matching_engine import MusicMatchingEngine
|
||
matching_engine = MusicMatchingEngine()
|
||
|
||
# Use TidalSpotifyDiscoveryWorker if this is a Tidal playlist
|
||
if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist:
|
||
print("🎵 Using Tidal discovery worker for Tidal playlist")
|
||
self.spotify_worker = TidalSpotifyDiscoveryWorker(
|
||
self.playlist.tracks,
|
||
self.parent_page.spotify_client,
|
||
matching_engine
|
||
)
|
||
else:
|
||
print("🎥 Using YouTube discovery worker for YouTube playlist")
|
||
self.spotify_worker = OptimizedSpotifyDiscoveryWorker(
|
||
self.playlist.tracks,
|
||
self.parent_page.spotify_client,
|
||
matching_engine
|
||
)
|
||
|
||
# Connect signals
|
||
self.spotify_worker.signals.track_discovered.connect(self.on_track_discovered)
|
||
self.spotify_worker.signals.progress_updated.connect(self.on_discovery_progress)
|
||
self.spotify_worker.signals.finished.connect(self.on_spotify_discovery_finished)
|
||
|
||
# Start the worker
|
||
QThreadPool.globalInstance().start(self.spotify_worker)
|
||
|
||
def on_track_discovered(self, row, spotify_track, status):
|
||
"""Handle a track being discovered (or not) on Spotify"""
|
||
try:
|
||
if status == "found" and spotify_track:
|
||
self.spotify_discovered_tracks[row] = spotify_track
|
||
self.update_table_with_spotify_match(row, spotify_track)
|
||
elif status == "not_found":
|
||
self.update_table_with_no_match(row)
|
||
elif status == "low_confidence":
|
||
self.update_table_with_low_confidence(row)
|
||
else: # error
|
||
self.update_table_with_error(row, status.replace("error: ", ""))
|
||
except Exception as e:
|
||
print(f"❌ Error updating UI for track {row}: {e}")
|
||
|
||
def on_discovery_progress(self, current):
|
||
"""Update the discovery progress"""
|
||
self.spotify_progress.setValue(current)
|
||
|
||
def on_spotify_discovery_finished(self, successful_discoveries):
|
||
"""Handle Spotify discovery completion"""
|
||
self.spotify_discovery_completed()
|
||
print(f"🎵 Spotify discovery completed: {successful_discoveries}/{self.total_tracks} tracks found")
|
||
|
||
def update_table_with_spotify_match(self, row, spotify_track):
|
||
"""Update table row with successful Spotify match"""
|
||
# Spotify Match Status
|
||
status_item = self.track_table.item(row, 2)
|
||
status_item.setText("✅ Found")
|
||
status_item.setForeground(QBrush(QColor("#4CAF50")))
|
||
|
||
# Spotify Track
|
||
spotify_track_item = QTableWidgetItem(spotify_track.name)
|
||
spotify_track_item.setFlags(spotify_track_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.track_table.setItem(row, 3, spotify_track_item)
|
||
|
||
# Spotify Artist
|
||
spotify_artist = spotify_track.artists[0] if spotify_track.artists else "Unknown"
|
||
spotify_artist_item = QTableWidgetItem(spotify_artist)
|
||
spotify_artist_item.setFlags(spotify_artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.track_table.setItem(row, 4, spotify_artist_item)
|
||
|
||
# Spotify Album
|
||
album_name = spotify_track.album if isinstance(spotify_track.album, str) else getattr(spotify_track.album, 'name', 'Unknown Album')
|
||
spotify_album_item = QTableWidgetItem(album_name)
|
||
spotify_album_item.setFlags(spotify_album_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.track_table.setItem(row, 5, spotify_album_item)
|
||
|
||
# Status
|
||
status_col_item = self.track_table.item(row, 6)
|
||
status_col_item.setText("Ready for Plex analysis")
|
||
|
||
def update_table_with_no_match(self, row):
|
||
"""Update table row when no Spotify match found"""
|
||
# Spotify Match Status
|
||
status_item = self.track_table.item(row, 2)
|
||
status_item.setText("❌ Not Found")
|
||
status_item.setForeground(QBrush(QColor("#ff6b6b")))
|
||
|
||
# Status
|
||
status_col_item = self.track_table.item(row, 6)
|
||
status_col_item.setText("Skipped - No Spotify match")
|
||
status_col_item.setForeground(QBrush(QColor("#888888")))
|
||
|
||
def update_table_with_low_confidence(self, row):
|
||
"""Update table row when Spotify matches were found but confidence too low"""
|
||
# Spotify Match Status
|
||
status_item = self.track_table.item(row, 2)
|
||
status_item.setText("⚠️ Low Confidence")
|
||
status_item.setForeground(QBrush(QColor("#FFA500")))
|
||
|
||
# Status
|
||
status_col_item = self.track_table.item(row, 6)
|
||
status_col_item.setText("Skipped - No reliable match")
|
||
status_col_item.setForeground(QBrush(QColor("#FFA500")))
|
||
|
||
def update_table_with_error(self, row, error_msg):
|
||
"""Update table row when search error occurred"""
|
||
# Spotify Match Status
|
||
status_item = self.track_table.item(row, 2)
|
||
status_item.setText("⚠️ Error")
|
||
status_item.setForeground(QBrush(QColor("#FFA500")))
|
||
|
||
# Status
|
||
status_col_item = self.track_table.item(row, 6)
|
||
status_col_item.setText(f"Error: {error_msg[:30]}...")
|
||
status_col_item.setForeground(QBrush(QColor("#FFA500")))
|
||
|
||
def spotify_discovery_completed(self):
|
||
"""Called when Spotify discovery is complete"""
|
||
self.spotify_search_completed = True
|
||
|
||
# Count successful discoveries
|
||
successful_discoveries = sum(1 for track in self.spotify_discovered_tracks if track is not None)
|
||
|
||
print(f"🎵 Spotify discovery completed: {successful_discoveries}/{self.total_tracks} tracks found")
|
||
|
||
# Update card state for Tidal playlists (matches YouTube workflow)
|
||
if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'):
|
||
print(f"🎵 Updating Tidal card state to discovery_complete for playlist_id: {self.playlist_id}")
|
||
if hasattr(self.parent_page, 'update_tidal_card_phase'):
|
||
self.parent_page.update_tidal_card_phase(self.playlist_id, 'discovery_complete')
|
||
|
||
# Store playlist data in state for future modal reopening
|
||
if hasattr(self.parent_page, 'set_tidal_card_playlist_data'):
|
||
self.parent_page.set_tidal_card_playlist_data(self.playlist_id, self.playlist)
|
||
|
||
# Update card state for YouTube playlists (existing logic)
|
||
if hasattr(self, 'youtube_url'):
|
||
print(f"🎬 Updating YouTube card state to discovery_complete for URL: {self.youtube_url}")
|
||
if hasattr(self.parent_page, 'update_youtube_card_phase'):
|
||
self.parent_page.update_youtube_card_phase(self.youtube_url, 'discovery_complete')
|
||
|
||
# Enable the Plex analysis and sync buttons
|
||
self.begin_search_btn.setEnabled(True)
|
||
self.begin_search_btn.setText(f"🔍 Download Missing Tracks ({successful_discoveries} tracks)")
|
||
|
||
self.sync_btn.setEnabled(True)
|
||
|
||
def on_begin_plex_analysis(self):
|
||
"""Create discovered playlist and open regular download modal"""
|
||
# Filter out tracks that weren't found on Spotify
|
||
valid_spotify_tracks = [track for track in self.spotify_discovered_tracks if track is not None]
|
||
|
||
if not valid_spotify_tracks:
|
||
QMessageBox.warning(self, "No Tracks", "No tracks were successfully discovered on Spotify.")
|
||
return
|
||
|
||
print(f"🎵 Creating discovered playlist with {len(valid_spotify_tracks)} Spotify tracks...")
|
||
|
||
# Create a Spotify-compatible playlist from discovered tracks
|
||
discovered_playlist = self.create_discovered_playlist(valid_spotify_tracks)
|
||
|
||
# Mark that we're transitioning to download modal (don't clean up URL tracking)
|
||
self.transitioning_to_download = True
|
||
|
||
# Close this discovery modal
|
||
self.accept()
|
||
|
||
# Create a dummy playlist item for the regular modal
|
||
dummy_playlist_item = type('DummyPlaylistItem', (), {
|
||
'playlist_name': discovered_playlist.name,
|
||
'track_count': len(discovered_playlist.tracks),
|
||
'download_modal': None,
|
||
'show_operation_status': lambda self, status_text="View Progress": None,
|
||
'hide_operation_status': lambda self: None
|
||
})()
|
||
|
||
# Open the regular DownloadMissingTracksModal with the discovered playlist
|
||
print("🚀 Opening regular DownloadMissingTracksModal with discovered tracks...")
|
||
modal = DownloadMissingTracksModal(
|
||
discovered_playlist,
|
||
dummy_playlist_item,
|
||
self.parent_page,
|
||
self.downloads_page,
|
||
is_youtube_workflow=True # Flag to indicate this is from YouTube discovery
|
||
)
|
||
|
||
# Transfer URL tracking from discovery modal to download modal (YouTube)
|
||
if hasattr(self, 'youtube_url'):
|
||
modal.youtube_url = self.youtube_url
|
||
self.parent_page.active_youtube_processes[self.youtube_url] = modal
|
||
print(f"🔄 Transferred URL tracking to download modal: {self.youtube_url}")
|
||
|
||
# Update card to downloading phase
|
||
if hasattr(self.parent_page, 'update_youtube_card_phase'):
|
||
self.parent_page.update_youtube_card_phase(self.youtube_url, 'downloading')
|
||
|
||
# Transfer playlist tracking from discovery modal to download modal (Tidal)
|
||
if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'):
|
||
modal.playlist_id = self.playlist_id
|
||
modal.is_tidal_playlist = True
|
||
modal.tidal_playlist = discovered_playlist
|
||
print(f"🔄 Transferred Tidal playlist tracking to download modal: {self.playlist_id}")
|
||
|
||
# Update Tidal card to downloading phase
|
||
if hasattr(self.parent_page, 'update_tidal_card_phase'):
|
||
self.parent_page.update_tidal_card_phase(self.playlist_id, 'downloading')
|
||
|
||
# Update Tidal state to link download modal
|
||
if hasattr(self.parent_page, 'tidal_playlist_states') and self.playlist_id in self.parent_page.tidal_playlist_states:
|
||
state = self.parent_page.tidal_playlist_states[self.playlist_id]
|
||
state['download_modal'] = modal
|
||
|
||
# Store the modal reference using the ID of the NEWLY created playlist object.
|
||
print(f"📝 Storing modal with CORRECT discovered_playlist.id: {discovered_playlist.id}")
|
||
self.parent_page.active_youtube_download_modals[discovered_playlist.id] = modal
|
||
|
||
modal.exec()
|
||
|
||
def on_sync_clicked(self):
|
||
"""Handle Sync This Playlist button click"""
|
||
if self.sync_in_progress:
|
||
# Cancel ongoing sync
|
||
print(f"🛑 Cancelling sync for playlist: {self.playlist.name}")
|
||
|
||
if hasattr(self.parent_page, 'cancel_playlist_sync'):
|
||
self.parent_page.cancel_playlist_sync(self.playlist.id)
|
||
|
||
# Reset sync state immediately (don't wait for callback)
|
||
self.sync_in_progress = False
|
||
self.sync_btn.setText("🔄 Sync This Playlist")
|
||
self.sync_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #ff6b6b; color: #ffffff; border: none;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 10px 20px; min-width: 120px;
|
||
}
|
||
QPushButton:hover { background-color: #ff5252; }
|
||
QPushButton:disabled { background-color: #404040; color: #888888; }
|
||
""")
|
||
|
||
# Status widgets are no longer used for sync - cards handle their own state
|
||
print("🔄 Sync cancelled - card will update its own state")
|
||
|
||
else:
|
||
# Start sync using the parent page's sync infrastructure
|
||
print(f"🔄 Starting sync for playlist: {self.playlist.name}")
|
||
|
||
if hasattr(self.parent_page, 'start_playlist_sync') and self.parent_page.start_playlist_sync(self.playlist):
|
||
print(f"✅ Sync started successfully for: {self.playlist.name}")
|
||
|
||
# Update UI to show sync is active
|
||
self.sync_in_progress = True
|
||
self.sync_btn.setText("Cancel Sync")
|
||
self.sync_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #e22134; color: #ffffff; border: none;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 10px 20px; min-width: 120px;
|
||
}
|
||
QPushButton:hover { background-color: #d32f2f; }
|
||
""")
|
||
|
||
# Show sync status widget (same as Spotify modal)
|
||
if self.sync_status_widget:
|
||
self.sync_status_widget.show()
|
||
self.update_sync_status(len(self.playlist.tracks), 0, 0)
|
||
|
||
# Update card to syncing phase
|
||
if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_phase'):
|
||
print(f"🎬 Discovery modal: Setting card to syncing phase for URL: {self.youtube_url}")
|
||
print(f"🎬 Discovery modal: Using playlist.id: {self.playlist.id}")
|
||
self.parent_page.update_youtube_card_phase(self.youtube_url, 'syncing')
|
||
|
||
# The card itself will show sync progress - no need for separate status widget
|
||
|
||
else:
|
||
print(f"❌ Failed to start sync for: {self.playlist.name}")
|
||
QMessageBox.warning(self, "Sync Failed", "Failed to start playlist sync. Please try again.")
|
||
|
||
|
||
def on_cancel_clicked(self):
|
||
"""Handle cancel button click - cancel sync or close modal"""
|
||
print("🛑 Cancel button clicked")
|
||
|
||
# Cancel any running Spotify discovery worker
|
||
if self.spotify_worker:
|
||
print("🛑 Cancelling Spotify discovery worker")
|
||
self.spotify_worker.cancel()
|
||
self.spotify_worker = None
|
||
|
||
if self.sync_in_progress:
|
||
# Cancel sync operation
|
||
print("🛑 Cancelling sync operation")
|
||
if hasattr(self.parent_page, 'cancel_playlist_sync'):
|
||
self.parent_page.cancel_playlist_sync(self.playlist.id)
|
||
|
||
# Reset sync state
|
||
self.sync_in_progress = False
|
||
self.sync_btn.setText("🔄 Sync This Playlist")
|
||
self.sync_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #ff6b6b; color: #ffffff; border: none;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 10px 20px; min-width: 120px;
|
||
}
|
||
QPushButton:hover { background-color: #ff5252; }
|
||
QPushButton:disabled { background-color: #404040; color: #888888; }
|
||
""")
|
||
|
||
# Status widgets are no longer used for sync - cards handle their own state
|
||
print("🔄 Sync cancelled - card will update its own state")
|
||
|
||
# Clean up URL tracking before closing (but not during download transition)
|
||
if (hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'active_youtube_processes') and
|
||
not getattr(self, 'transitioning_to_download', False)):
|
||
if self.youtube_url in self.parent_page.active_youtube_processes:
|
||
print(f"🧹 Cleaning up URL tracking on cancel for: {self.youtube_url}")
|
||
del self.parent_page.active_youtube_processes[self.youtube_url]
|
||
|
||
# Always close/hide the modal when cancel is clicked
|
||
print("🛑 Closing modal")
|
||
|
||
# Update card state - reset to initial discovering state for Cancel
|
||
if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'reset_youtube_playlist_state'):
|
||
self.parent_page.reset_youtube_playlist_state(self.youtube_url)
|
||
|
||
# Update Tidal card state - reset to initial discovering state for Cancel
|
||
if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'):
|
||
if hasattr(self.parent_page, 'reset_tidal_playlist_state'):
|
||
print(f"🧹 Resetting Tidal playlist state to discovering on cancel for playlist_id: {self.playlist_id}")
|
||
self.parent_page.reset_tidal_playlist_state(self.playlist_id)
|
||
|
||
self.reject()
|
||
|
||
def on_close_clicked(self):
|
||
"""Handle Close button click - hide modal but preserve discovery data"""
|
||
print("🏠 Close button clicked - preserving discovery data")
|
||
|
||
# Check if sync is currently in progress - if so, preserve the syncing state
|
||
if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_phase'):
|
||
if self.sync_in_progress:
|
||
# Sync is running - keep the card in syncing state
|
||
print("🔄 Sync in progress - preserving syncing state")
|
||
# Don't change the card phase - it should stay as 'syncing'
|
||
elif self.spotify_search_completed:
|
||
# No sync running, discovery complete - safe to set to discovery_complete
|
||
self.parent_page.update_youtube_card_phase(self.youtube_url, 'discovery_complete')
|
||
else:
|
||
# Discovery still running - keep as discovering but hide modal
|
||
print("🔄 Discovery still in progress - keeping discovering state")
|
||
|
||
# Just hide the modal, don't reset any data
|
||
self.hide()
|
||
|
||
def on_sync_progress(self, playlist_id, progress):
|
||
"""Handle sync progress updates (called from parent page)"""
|
||
try:
|
||
print(f"🔍 YouTube modal sync progress called: playlist_id={playlist_id}, my_id={self.playlist.id}")
|
||
print(f"🔍 YouTube modal sync_in_progress={self.sync_in_progress}")
|
||
print(f"🔍 YouTube modal progress data: total={progress.total_tracks}, matched={progress.matched_tracks}, failed={progress.failed_tracks}")
|
||
|
||
if playlist_id == self.playlist.id:
|
||
print(f"🔄 ✅ Playlist ID matches - processing sync progress for YouTube playlist")
|
||
if self.sync_in_progress:
|
||
print(f"🔄 ✅ Sync in progress - updating status widget")
|
||
|
||
# Show and update the sync status widget (same as Spotify modal)
|
||
if self.sync_status_widget:
|
||
print(f"📊 ✅ Status widget exists - showing and updating")
|
||
self.sync_status_widget.show()
|
||
self.update_sync_status(
|
||
progress.total_tracks,
|
||
progress.matched_tracks,
|
||
progress.failed_tracks
|
||
)
|
||
print(f"📊 ✅ Status widget updated successfully")
|
||
|
||
# Update card progress as well
|
||
if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_progress'):
|
||
self.parent_page.update_youtube_card_progress(
|
||
self.youtube_url,
|
||
total=progress.total_tracks,
|
||
matched=progress.matched_tracks,
|
||
failed=progress.failed_tracks
|
||
)
|
||
else:
|
||
print("❌ sync_status_widget is None!")
|
||
else:
|
||
print(f"🔄 ❌ Sync not in progress (sync_in_progress={self.sync_in_progress})")
|
||
else:
|
||
print(f"🔄 ❌ Playlist ID mismatch: {playlist_id} != {self.playlist.id}")
|
||
|
||
except Exception as e:
|
||
print(f"💥 EXCEPTION in YouTube modal on_sync_progress: {e}")
|
||
import traceback
|
||
print(f"💥 Traceback: {traceback.format_exc()}")
|
||
|
||
# Update the card progress display instead of creating status widgets
|
||
if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_progress'):
|
||
self.parent_page.update_youtube_card_progress(
|
||
self.youtube_url,
|
||
total=progress.total_tracks,
|
||
matched=progress.matched_tracks,
|
||
failed=progress.failed_tracks
|
||
)
|
||
|
||
def on_sync_finished(self, playlist_id, result):
|
||
"""Handle sync completion (called from parent page)"""
|
||
if playlist_id == self.playlist.id:
|
||
print(f"🎉 Sync completed for YouTube playlist: {self.playlist.name}")
|
||
|
||
# Reset sync state
|
||
self.sync_in_progress = False
|
||
|
||
# Hide sync status widget (same as Spotify modal)
|
||
if self.sync_status_widget:
|
||
self.sync_status_widget.hide()
|
||
|
||
# Reset sync button to original state
|
||
self.sync_btn.setText("🔄 Sync This Playlist")
|
||
self.sync_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #ff6b6b; color: #ffffff; border: none;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 10px 20px; min-width: 120px;
|
||
}
|
||
QPushButton:hover { background-color: #ff5252; }
|
||
QPushButton:disabled { background-color: #404040; color: #888888; }
|
||
""")
|
||
|
||
# Update card to sync_complete phase
|
||
if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_phase'):
|
||
self.parent_page.update_youtube_card_phase(self.youtube_url, 'sync_complete')
|
||
|
||
def on_sync_error(self, playlist_id, error_msg):
|
||
"""Handle sync error (called from parent page)"""
|
||
if playlist_id == self.playlist.id:
|
||
print(f"❌ Sync error for YouTube playlist: {self.playlist.name} - {error_msg}")
|
||
|
||
# Reset sync state
|
||
self.sync_in_progress = False
|
||
|
||
# Hide sync status widget (same as Spotify modal)
|
||
if self.sync_status_widget:
|
||
self.sync_status_widget.hide()
|
||
|
||
# Reset sync button to original state
|
||
self.sync_btn.setText("🔄 Sync This Playlist")
|
||
self.sync_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #ff6b6b; color: #ffffff; border: none;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 10px 20px; min-width: 120px;
|
||
}
|
||
QPushButton:hover { background-color: #ff5252; }
|
||
QPushButton:disabled { background-color: #404040; color: #888888; }
|
||
""")
|
||
|
||
def create_discovered_playlist(self, spotify_tracks):
|
||
"""Create a playlist object from discovered Spotify tracks, reusing the original ID and name."""
|
||
playlist_id = self.playlist.id
|
||
|
||
print(f"🎵 Creating discovered playlist with consistent ID: {playlist_id}")
|
||
|
||
discovered_playlist = type('Playlist', (), {
|
||
'id': playlist_id,
|
||
# --- THE FIX ---
|
||
# This now uses the original, clean playlist name without adding any prefixes.
|
||
'name': self.playlist.name,
|
||
'description': f"Discovered from YouTube playlist with {len(spotify_tracks)} matched tracks",
|
||
'owner': "YouTube Discovery",
|
||
'public': False,
|
||
'collaborative': False,
|
||
'tracks': spotify_tracks,
|
||
'total_tracks': len(spotify_tracks)
|
||
})()
|
||
|
||
return discovered_playlist
|
||
|
||
def show_loading_state(self):
|
||
"""Show loading state in the modal"""
|
||
# Update window title
|
||
if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist:
|
||
self.setWindowTitle("Tidal Playlist Discovery - Loading...")
|
||
else:
|
||
self.setWindowTitle("YouTube Playlist Discovery - Loading...")
|
||
|
||
# Clear the table
|
||
self.track_table.setRowCount(0)
|
||
|
||
# Show loading message
|
||
loading_item = QTableWidgetItem("🔄 Parsing YouTube playlist...")
|
||
loading_item.setFlags(loading_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
|
||
self.track_table.setRowCount(1)
|
||
self.track_table.setSpan(0, 0, 1, 7) # Span all columns
|
||
self.track_table.setItem(0, 0, loading_item)
|
||
|
||
# Disable buttons
|
||
self.begin_search_btn.setEnabled(False)
|
||
self.begin_search_btn.setText("Waiting for playlist data...")
|
||
|
||
def populate_with_playlist_data(self, playlist):
|
||
"""Populate the modal with actual playlist data"""
|
||
print(f"📊 Populating modal with {len(playlist.tracks)} tracks")
|
||
|
||
# Update modal properties
|
||
self.playlist = playlist
|
||
|
||
# --- THE FIX ---
|
||
# The block of code that was here was incorrectly adding this discovery modal
|
||
# to the parent page's tracking dictionary for DOWNLOAD modals, often with
|
||
# multiple, inconsistent IDs. This was the root cause of the state corruption
|
||
# and the "No modal found" error. By removing it, we ensure that only the
|
||
# correct modal (the DownloadMissingTracksModal) is ever added to that list,
|
||
# which resolves the entire issue.
|
||
|
||
self.total_tracks = len(playlist.tracks)
|
||
self.spotify_discovered_tracks = [None] * self.total_tracks
|
||
|
||
# Update window title
|
||
if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist:
|
||
self.setWindowTitle(f"Tidal Playlist Discovery - {playlist.name}")
|
||
else:
|
||
self.setWindowTitle(f"YouTube Playlist Discovery - {playlist.name}")
|
||
|
||
# Update progress bars
|
||
self.spotify_progress.setMaximum(self.total_tracks)
|
||
self.spotify_progress.setValue(0)
|
||
|
||
# Populate the table with actual track data
|
||
self.populate_initial_table()
|
||
|
||
# Start Spotify discovery
|
||
self.start_spotify_discovery()
|
||
|
||
def closeEvent(self, event):
|
||
"""Handle modal closing - hide when sync is active, otherwise close"""
|
||
print(f"🔍 DEBUG: YouTube modal closeEvent - sync_in_progress: {self.sync_in_progress}")
|
||
|
||
# If sync is in progress, just hide the modal (don't close)
|
||
if self.sync_in_progress:
|
||
print("🔍 DEBUG: Sync in progress - hiding modal instead of closing")
|
||
event.ignore() # Prevent actual closing
|
||
self.hide()
|
||
return
|
||
|
||
# Normal close behavior - cancel any running workers
|
||
if self.spotify_worker:
|
||
print("🛑 closeEvent: Cancelling Spotify discovery worker")
|
||
self.spotify_worker.cancel()
|
||
self.spotify_worker = None
|
||
|
||
# Clean up URL tracking when modal is actually closed (not just hidden)
|
||
# But don't clean up if we're transitioning to download modal
|
||
if (hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'active_youtube_processes') and
|
||
not getattr(self, 'transitioning_to_download', False)):
|
||
if self.youtube_url in self.parent_page.active_youtube_processes:
|
||
print(f"🧹 Cleaning up URL tracking for: {self.youtube_url}")
|
||
del self.parent_page.active_youtube_processes[self.youtube_url]
|
||
|
||
# Clean up Tidal playlist state when modal is actually closed (not just hidden)
|
||
# But don't clean up if we're transitioning to download modal
|
||
if (hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and
|
||
hasattr(self, 'playlist_id') and hasattr(self.parent_page, 'tidal_playlist_states') and
|
||
not getattr(self, 'transitioning_to_download', False)):
|
||
|
||
playlist_id = self.playlist_id
|
||
if playlist_id in self.parent_page.tidal_playlist_states:
|
||
state = self.parent_page.tidal_playlist_states[playlist_id]
|
||
|
||
# Only clear the modal reference, don't reset the entire state
|
||
# This preserves discovery data for when user reopens the modal
|
||
if state.get('discovery_modal') == self:
|
||
print(f"🧹 Cleaning up Tidal discovery modal reference for playlist_id: {playlist_id}")
|
||
state['discovery_modal'] = None
|
||
|
||
# If discovery was completed, keep the state, otherwise reset it
|
||
if state.get('phase') == 'discovering':
|
||
print(f"🧹 Discovery incomplete, resetting Tidal state for playlist_id: {playlist_id}")
|
||
self.parent_page.reset_tidal_playlist_state(playlist_id)
|
||
|
||
super().closeEvent(event)
|
||
|
||
|
||
|