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:
wishlist_count = getattr(result, 'wishlist_added_count', 0)
if result.failed_tracks > 0:
msg = f"Sync completed: {result.matched_tracks}/{result.total_tracks} tracks added, {result.failed_tracks} failed"
if wishlist_count > 0:
msg += f". {wishlist_count} track{'s' if wishlist_count > 1 else ''} added to wishlist"
self.toast_manager.show_toast(msg, ToastType.WARNING)
else:
msg = f"Sync completed: {result.matched_tracks} tracks added to queue"
if wishlist_count > 0:
msg += f". {wishlist_count} missing track{'s' if wishlist_count > 1 else ''} added to wishlist"
self.toast_manager.show_toast(msg, 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: {spotify_track.name}
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"
{error}
")) 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"{filename}