From 62e78f59f7ac8e0c24f8298669c0fd1b893ed010 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Wed, 13 Aug 2025 11:53:36 -0700 Subject: [PATCH] youtube playlist functionality. may have bugs. --- database/music_library.db-shm | Bin 0 -> 32768 bytes database/music_library.db-wal | 0 requirements.txt | 1 + .../__pycache__/downloads.cpython-312.pyc | Bin 538682 -> 538682 bytes ui/pages/__pycache__/sync.cpython-310.pyc | Bin 143618 -> 189968 bytes ui/pages/sync.py | 2555 ++++++++++++++++- 6 files changed, 2540 insertions(+), 16 deletions(-) create mode 100644 database/music_library.db-shm create mode 100644 database/music_library.db-wal diff --git a/database/music_library.db-shm b/database/music_library.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r3=1.47.0 Pillow>=10.0.0 aiohttp>=3.9.0 unidecode>=1.3.8 +yt-dlp>=2024.12.13 \ No newline at end of file diff --git a/ui/pages/__pycache__/downloads.cpython-312.pyc b/ui/pages/__pycache__/downloads.cpython-312.pyc index 478165054f9f863568158d481f74f15225687e8e..7735e26fede53bcf493a77ea6066dcf30683f21d 100644 GIT binary patch delta 59 zcmdn>L1EVi1+LS)yj%=GVD_w$tCfqfm5ZsBi@BAHrIm}dm5XgF7kh;{Q$yPJ7IStt OCPu~Wr!Cp@{{a92^AQsO delta 59 zcmdn>L1EVi1+LS)yj%=GAaJpftCfqfm5ZsBi@BAHrIm}dm5XgF7kh;{6NBCM7IStt OCPszrr!Cp@{{aB)b`UQB diff --git a/ui/pages/__pycache__/sync.cpython-310.pyc b/ui/pages/__pycache__/sync.cpython-310.pyc index 3b93e7d92b247fae226384864f75ebfdab2ed580..107d2d1f1357e80be745ec0476e867db3cfc6227 100644 GIT binary patch delta 77351 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◒G((OwGEUa)lPA6mL*rV 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 diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 9f9d45c8..608492f5 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -2,9 +2,9 @@ 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) + QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QLineEdit, QTabWidget) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve, QRunnable, QThreadPool, QObject -from PyQt6.QtGui import QFont +from PyQt6.QtGui import QFont, QBrush, QColor import os import json from datetime import datetime @@ -19,6 +19,8 @@ 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 logger = get_logger("sync") @@ -136,6 +138,372 @@ def clean_track_name_for_search(track_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""" @@ -2024,6 +2392,14 @@ class SyncPage(QWidget): 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 + # Initialize Plex scan manager self.scan_manager = None if self.plex_client: @@ -2590,39 +2966,62 @@ class SyncPage(QWidget): self.log_area.append(f"โŒ Sync failed: {error_msg}") def update_open_modals_progress(self, playlist_id, progress): - """Update any open PlaylistDetailsModal for this playlist with sync progress""" - # Find all open PlaylistDetailsModal instances for this playlist - # We need to check all top-level widgets that might be modals + """Update any open modals for this playlist with sync progress""" + # Find all open modal instances for this playlist 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 progress display widget.on_sync_progress(playlist_id, progress) + + # Handle YouTubeDownloadMissingTracksModal + elif (isinstance(widget, YouTubeDownloadMissingTracksModal) and + hasattr(widget, 'playlist') and + widget.playlist.id == playlist_id): + # Update the YouTube modal's progress display (even if hidden) + widget.on_sync_progress(playlist_id, progress) def update_open_modals_completion(self, playlist_id, result): - """Update any open PlaylistDetailsModal for this playlist with sync completion""" + """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 PlaylistDetailsModal for this playlist with sync error""" + """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): @@ -2653,6 +3052,11 @@ class SyncPage(QWidget): """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: @@ -2757,8 +3161,8 @@ class SyncPage(QWidget): content_layout = QHBoxLayout() content_layout.setSpacing(15) # Reduced from 25 to 15 for tighter spacing - # Left side - Playlist list - playlist_section = self.create_playlist_section() + # Left side - Tabbed playlist section + playlist_section = self.create_tabbed_playlist_section() content_layout.addWidget(playlist_section, 2) # Right side - Options and actions @@ -2857,6 +3261,484 @@ class SyncPage(QWidget): 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 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_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 no playlist_id provided, generate one from name (for backward compatibility) + if playlist_id is None: + playlist_id = f"youtube_{hash(playlist_name)}" + print(f"๐Ÿ“‹ Generated fallback playlist_id: {playlist_id}") + else: + print(f"๐Ÿ“‹ Using provided playlist_id: {playlist_id}") + + # Remove any existing status widget for this playlist + if playlist_id in self.youtube_status_widgets: + existing_widget = self.youtube_status_widgets[playlist_id] + existing_widget.setParent(None) + del self.youtube_status_widgets[playlist_id] + + # Clear placeholder content only if this is the first status widget + if not self.youtube_status_widgets: + for i in reversed(range(self.youtube_content_layout.count())): + child = self.youtube_content_layout.itemAt(i).widget() + if child: + child.setParent(None) + + # Create playlist-style status widget + 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) + + # Playlist name + name_label = QLabel(playlist_name) + name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + name_label.setStyleSheet("color: #ffffff;") + + # Info section + 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 (styled like Sync / Download 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; + } + """) + + # Add everything to main layout + layout.addWidget(status_icon) + layout.addLayout(content_layout) + layout.addStretch() + layout.addWidget(view_progress_btn) + + # Store widget reference for tracking + self.youtube_status_widgets[playlist_id] = status_widget + + # Add status widget at the top (multiple widgets will stack) + self.youtube_content_layout.insertWidget(len(self.youtube_status_widgets) - 1, status_widget) + + # Add stretch only if this is the first widget + if len(self.youtube_status_widgets) == 1: + self.youtube_content_layout.addStretch() # Fill remaining space + + def show_youtube_sync_status(self, playlist_name, track_count, playlist_id=None): + """Show sync status widget in YouTube tab - styled like PlaylistItem for sync operations""" + # If no playlist_id provided, generate one from name (for backward compatibility) + if playlist_id is None: + playlist_id = f"youtube_{hash(playlist_name)}" + + # Remove any existing status widget for this playlist + if playlist_id in self.youtube_status_widgets: + existing_widget = self.youtube_status_widgets[playlist_id] + existing_widget.setParent(None) + del self.youtube_status_widgets[playlist_id] + + # Clear placeholder content only if this is the first status widget + if not self.youtube_status_widgets: + for i in reversed(range(self.youtube_content_layout.count())): + child = self.youtube_content_layout.itemAt(i).widget() + if child: + child.setParent(None) + + # Create playlist-style sync status widget + 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; + } + """) + + # Main layout + layout = QHBoxLayout(status_widget) + layout.setContentsMargins(20, 15, 20, 15) + layout.setSpacing(15) + + # Status icon (sync icon) + status_icon = QLabel("๐Ÿ”„") + status_icon.setFont(QFont("Arial", 16)) + status_icon.setFixedSize(22, 22) + status_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Content section (playlist name and info) + content_layout = QVBoxLayout() + content_layout.setSpacing(5) + + # Playlist name + name_label = QLabel(playlist_name) + name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + name_label.setStyleSheet("color: #ffffff;") + + # Info section + 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("Syncing...") + status_label.setFont(QFont("Arial", 10)) + status_label.setStyleSheet("color: #ff6b6b;") + + 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 (styled like Sync / Download 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 #ff6b6b; + border-radius: 15px; + color: #ff6b6b; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #ff6b6b; + color: #ffffff; + } + """) + + # Add everything to main layout + layout.addWidget(status_icon) + layout.addLayout(content_layout) + layout.addStretch() + layout.addWidget(view_progress_btn) + + # Store widget reference for tracking + self.youtube_status_widgets[playlist_id] = status_widget + + # Add status widget at the top (multiple widgets will stack) + self.youtube_content_layout.insertWidget(len(self.youtube_status_widgets) - 1, status_widget) + + # Add stretch only if this is the first widget + if len(self.youtube_status_widgets) == 1: + self.youtube_content_layout.addStretch() # Fill remaining space + + 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) @@ -3271,6 +4153,862 @@ class SyncPage(QWidget): else: self.playlist_layout.removeItem(item) + def parse_youtube_playlist(self): + """Parse YouTube playlist URL and open the download missing tracks 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 is already being processed + 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() + + # Determine modal type for better error message + modal_type = "discovery" if isinstance(existing_modal, YouTubeDownloadMissingTracksModal) else "download" + self.show_youtube_error(f"This playlist is already being processed in the {modal_type} phase. The existing modal has been brought to the front.") + 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] + + # 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 + + # 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 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}") + + # 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() + + +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 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): """ @@ -3793,7 +5531,7 @@ class ManualMatchModal(QDialog): class DownloadMissingTracksModal(QDialog): """Enhanced modal for downloading missing tracks with live progress tracking""" process_finished = pyqtSignal() - def __init__(self, playlist, playlist_item, parent_page, downloads_page): + def __init__(self, playlist, playlist_item, parent_page, downloads_page, is_youtube_workflow=False): super().__init__(parent_page) self.playlist = playlist self.playlist_item = playlist_item @@ -3802,6 +5540,7 @@ class DownloadMissingTracksModal(QDialog): self.downloads_page = downloads_page self.matching_engine = MusicMatchingEngine() self.wishlist_service = get_wishlist_service() + self.is_youtube_workflow = is_youtube_workflow # Flag to track if this is from YouTube discovery # State tracking self.total_tracks = len(playlist.tracks) @@ -3940,6 +5679,15 @@ class DownloadMissingTracksModal(QDialog): button_section = self.create_buttons() main_layout.addWidget(button_section) + + def is_downloading(self): + """Check if any downloads are currently in progress""" + return (self.download_in_progress or + not self.analysis_complete or + len(self.active_workers) > 0 or + (hasattr(self, 'tracks_to_download_count') and + hasattr(self, 'downloaded_tracks_count') and + self.downloaded_tracks_count < self.tracks_to_download_count)) def create_compact_top_section(self): """Create compact top section with header and dashboard combined""" @@ -4331,9 +6079,11 @@ class DownloadMissingTracksModal(QDialog): def on_begin_search_clicked(self): """Handle Begin Search button click - starts Plex analysis""" - # --- FIX: Trigger the UI change on the main page --- - # This is the correct point to signal that the process has started. - self.parent_page.on_download_process_started(self.playlist.id, self.playlist_item) + # Only update refresh button state for Spotify workflows, not YouTube workflows + if not self.is_youtube_workflow: + # --- FIX: Trigger the UI change on the main page --- + # This is the correct point to signal that the process has started. + self.parent_page.on_download_process_started(self.playlist.id, self.playlist_item) self.begin_search_btn.hide() self.cancel_btn.show() @@ -4387,6 +6137,13 @@ class DownloadMissingTracksModal(QDialog): # Handle case where no tracks are missing self.download_in_progress = False # Mark process as finished self.cancel_btn.hide() + + # If this is a YouTube workflow, clean up status widget (no downloads needed) + if self.is_youtube_workflow and hasattr(self.parent_page, 'show_youtube_placeholder'): + self.parent_page.show_youtube_placeholder() + if self.playlist.id in self.parent_page.active_youtube_download_modals: + del self.parent_page.active_youtube_download_modals[self.playlist.id] + # The modal now stays open. # The process_finished signal is still emitted to unlock the main UI. self.process_finished.emit() @@ -4835,6 +6592,12 @@ class DownloadMissingTracksModal(QDialog): print("๐ŸŽ‰ All downloads completed!") self.cancel_btn.hide() + # If this is a YouTube workflow, clean up status widget + if self.is_youtube_workflow and hasattr(self.parent_page, 'show_youtube_placeholder'): + self.parent_page.show_youtube_placeholder() + if self.playlist.id in self.parent_page.active_youtube_download_modals: + del self.parent_page.active_youtube_download_modals[self.playlist.id] + # The process_finished signal is still emitted to unlock the main UI. self.process_finished.emit() @@ -4933,17 +6696,51 @@ class DownloadMissingTracksModal(QDialog): def on_cancel_clicked(self): """Handle Cancel button - cancels operations, emits finished signal, and closes modal.""" - # --- FIX: The full cancellation logic is now centralized here. --- + print("๐Ÿ›‘ Cancel button clicked - cancelling all operations and cleaning up") + + # Cancel all operations self.cancel_operations() + + # If this is a YouTube workflow, clean up status widget (user cancelled) + if self.is_youtube_workflow and hasattr(self.parent_page, 'show_youtube_placeholder'): + self.parent_page.show_youtube_placeholder() + if self.playlist.id in self.parent_page.active_youtube_download_modals: + del self.parent_page.active_youtube_download_modals[self.playlist.id] + + # Signal completion and close self.process_finished.emit() # Signal the main page to clean up and reset the button. self.reject() # Close the modal. def on_close_clicked(self): - # Use same logic as closeEvent - emit process_finished when no download is active + """Handle close button click - same logic as closeEvent""" + print(f"๐Ÿ” DEBUG: Close button clicked - cancel_requested: {self.cancel_requested}, download_in_progress: {self.download_in_progress}") + print(f"๐Ÿ” DEBUG: analysis_complete: {self.analysis_complete}, active_workers: {len(self.active_workers)}") + print(f"๐Ÿ” DEBUG: is_youtube_workflow: {self.is_youtube_workflow}") + if self.cancel_requested or not self.download_in_progress: + # If cancelled or finished, close for real and cancel operations + print("๐Ÿ” DEBUG: Closing modal and cancelling operations") self.cancel_operations() self.process_finished.emit() - self.reject() + self.reject() + + # If this was a YouTube workflow and modal is closing, clean up status widget + if self.is_youtube_workflow and hasattr(self.parent_page, 'show_youtube_placeholder'): + self.parent_page.show_youtube_placeholder() + if self.playlist.id in self.parent_page.active_youtube_download_modals: + del self.parent_page.active_youtube_download_modals[self.playlist.id] + else: + # If downloads are running, just hide the window (don't cancel) + print("๐Ÿ” DEBUG: Hiding modal (downloads still active)") + self.hide() + + # If this is a YouTube workflow, show status widget + if self.is_youtube_workflow and hasattr(self.parent_page, 'show_youtube_download_status'): + self.parent_page.show_youtube_download_status( + self.playlist.name, + len(self.playlist.tracks), + self.playlist.id + ) def cancel_operations(self): """Cancel any ongoing operations, including active slskd downloads.""" @@ -5008,10 +6805,31 @@ class DownloadMissingTracksModal(QDialog): self.cancel_operations() self.process_finished.emit() event.accept() + + # If this was a YouTube workflow and modal is closing, clean up status widget and URL tracking + if self.is_youtube_workflow and hasattr(self.parent_page, 'show_youtube_placeholder'): + self.parent_page.show_youtube_placeholder() + + # Clean up YouTube URL tracking when download modal closes + if (hasattr(self, 'youtube_url') and + hasattr(self.parent_page, 'active_youtube_processes') and + self.youtube_url in self.parent_page.active_youtube_processes): + print(f"๐Ÿงน Cleaning up YouTube URL tracking from download modal: {self.youtube_url}") + del self.parent_page.active_youtube_processes[self.youtube_url] + if self.playlist.id in self.parent_page.active_youtube_download_modals: + del self.parent_page.active_youtube_download_modals[self.playlist.id] else: # If downloads are running, just hide the window. self.hide() event.ignore() + + # If this is a YouTube workflow, show status widget + if self.is_youtube_workflow and hasattr(self.parent_page, 'show_youtube_download_status'): + self.parent_page.show_youtube_download_status( + self.playlist.name, + len(self.playlist.tracks), + self.playlist.id + ) # Inner class for the search worker class ParallelSearchWorker(QRunnable): @@ -5143,4 +6961,709 @@ class DownloadMissingTracksModal(QDialog): return SpotifyBasedSearchResult() +class YouTubeDownloadMissingTracksModal(QDialog): + """Enhanced modal for downloading YouTube playlist tracks with Spotify discovery""" + process_finished = pyqtSignal() + + def __init__(self, playlist, playlist_item, parent_page, downloads_page): + super().__init__(parent_page) + self.playlist = playlist # YouTube playlist with cleaned tracks + self.playlist_item = playlist_item + self.parent_page = parent_page + self.downloads_page = downloads_page + self.total_tracks = len(playlist.tracks) if playlist else 0 + + # Progress tracking + self.spotify_discovered_tracks = [None] * self.total_tracks # List of discovered Spotify tracks + self.spotify_search_completed = False + self.spotify_worker = None + + # UI components + self.track_table = None + self.analysis_progress = None + self.spotify_progress = None + self.begin_search_btn = None + self.cancel_btn = None + self.sync_btn = None + + # Sync state tracking + self.sync_in_progress = False + self.is_youtube_workflow = True + + self.setup_ui() + if self.playlist and self.total_tracks > 0: + self.populate_initial_table() + self.start_spotify_discovery() + + def setup_ui(self): + """Set up the YouTube-specific modal UI""" + self.setWindowTitle(f"YouTube Playlist Discovery - {self.playlist.name}") + self.resize(1400, 900) + self.setWindowFlags(Qt.WindowType.Window) + + self.setStyleSheet(""" + QDialog { background-color: #1e1e1e; color: #ffffff; } + QLabel { color: #ffffff; } + QPushButton { + background-color: #1db954; color: #000000; border: none; + border-radius: 6px; font-size: 13px; font-weight: bold; + padding: 10px 20px; min-width: 100px; + } + QPushButton:hover { background-color: #1ed760; } + QPushButton:disabled { background-color: #404040; color: #888888; } + """) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(25, 25, 25, 25) + main_layout.setSpacing(15) + + # Header section + header_section = self.create_header_section() + main_layout.addWidget(header_section) + + # Progress section + progress_section = self.create_progress_section() + main_layout.addWidget(progress_section) + + # Table section + table_section = self.create_track_table() + main_layout.addWidget(table_section, stretch=1) + + # Button section + button_section = self.create_buttons() + main_layout.addWidget(button_section) + + def create_header_section(self): + """Create header with title and summary""" + header_frame = QFrame() + header_frame.setStyleSheet(""" + QFrame { + background-color: #2d2d2d; border: 1px solid #444444; + border-radius: 8px; padding: 15px; + } + """) + + layout = QVBoxLayout(header_frame) + + title = QLabel("๐ŸŽต YouTube Playlist Discovery") + title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + title.setStyleSheet("color: #1db954;") + + subtitle = QLabel(f"Playlist: {self.playlist.name} ({self.total_tracks} tracks)") + subtitle.setFont(QFont("Arial", 11)) + subtitle.setStyleSheet("color: #aaaaaa;") + + description = QLabel("Discovering clean Spotify metadata for YouTube tracks...") + description.setFont(QFont("Arial", 10)) + description.setStyleSheet("color: #888888;") + + layout.addWidget(title) + layout.addWidget(subtitle) + layout.addWidget(description) + + return header_frame + + def create_progress_section(self): + """Create progress tracking section""" + progress_frame = QFrame() + progress_frame.setStyleSheet(""" + QFrame { + background-color: #2d2d2d; border: 1px solid #444444; + border-radius: 8px; padding: 15px; + } + """) + + layout = QVBoxLayout(progress_frame) + + # Spotify discovery progress + spotify_label = QLabel("๐Ÿ” Spotify Discovery Progress") + spotify_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + + self.spotify_progress = QProgressBar() + self.spotify_progress.setFixedHeight(20) + self.spotify_progress.setMaximum(self.total_tracks) + self.spotify_progress.setValue(0) + self.spotify_progress.setStyleSheet(""" + QProgressBar { + border: 1px solid #555555; border-radius: 10px; text-align: center; + background-color: #444444; color: #ffffff; font-size: 11px; font-weight: bold; + } + QProgressBar::chunk { background-color: #1db954; border-radius: 9px; } + """) + + # Plex analysis progress (hidden initially) + analysis_label = QLabel("๐Ÿ“Š Plex Analysis Progress") + analysis_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + + self.analysis_progress = QProgressBar() + self.analysis_progress.setFixedHeight(20) + self.analysis_progress.setStyleSheet(""" + QProgressBar { + border: 1px solid #555555; border-radius: 10px; text-align: center; + background-color: #444444; color: #ffffff; font-size: 11px; font-weight: bold; + } + QProgressBar::chunk { background-color: #ff6b6b; border-radius: 9px; } + """) + self.analysis_progress.setVisible(False) + analysis_label.setVisible(False) + + layout.addWidget(spotify_label) + layout.addWidget(self.spotify_progress) + layout.addWidget(analysis_label) + layout.addWidget(self.analysis_progress) + + return progress_frame + + def create_track_table(self): + """Create track table with YouTube-specific columns""" + table_frame = QFrame() + table_frame.setStyleSheet(""" + QFrame { + background-color: #2d2d2d; border: 1px solid #444444; + border-radius: 8px; padding: 0px; + } + """) + + layout = QVBoxLayout(table_frame) + layout.setContentsMargins(15, 15, 15, 15) + layout.setSpacing(10) + + header_label = QLabel("๐Ÿ“‹ Track Discovery & Analysis") + header_label.setFont(QFont("Arial", 13, QFont.Weight.Bold)) + header_label.setStyleSheet("color: #ffffff; padding: 5px;") + + self.track_table = QTableWidget() + self.track_table.setColumnCount(7) + self.track_table.setHorizontalHeaderLabels([ + "YT Track", "YT Artist", "Spotify Match Status", + "Spotify Track", "Spotify Artist", "Spotify Album", "Status" + ]) + + # Set columns to span full width evenly + header = self.track_table.horizontalHeader() + + for i in range(7): + header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch) + + self.track_table.setAlternatingRowColors(True) + self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.track_table.setStyleSheet(""" + QTableWidget { + background-color: #1e1e1e; color: #ffffff; + gridline-color: #404040; border: none; + } + QTableWidget::item { padding: 8px; border-bottom: 1px solid #333333; } + QTableWidget::item:selected { background-color: #404040; } + QHeaderView::section { + background-color: #333333; color: #ffffff; border: none; + padding: 10px; font-weight: bold; font-size: 11px; + } + """) + + layout.addWidget(header_label) + layout.addWidget(self.track_table) + + return table_frame + + def create_buttons(self): + """Create button section""" + button_frame = QFrame() + layout = QHBoxLayout(button_frame) + layout.setSpacing(10) + + layout.addStretch() + + # Sync button - appears to the left of Begin Search + self.sync_btn = QPushButton("๐Ÿ”„ Sync This Playlist") + self.sync_btn.setEnabled(False) # Disabled until Spotify discovery completes + self.sync_btn.clicked.connect(self.on_sync_clicked) + self.sync_btn.setStyleSheet(""" + QPushButton { + background-color: #ff6b6b; color: #ffffff; border: none; + border-radius: 6px; font-size: 13px; font-weight: bold; + padding: 10px 20px; min-width: 120px; + } + QPushButton:hover { background-color: #ff5252; } + QPushButton:disabled { background-color: #404040; color: #888888; } + """) + + self.begin_search_btn = QPushButton("๐Ÿ” Download Missing Tracks") + self.begin_search_btn.setEnabled(False) # Disabled until Spotify discovery completes + self.begin_search_btn.clicked.connect(self.on_begin_plex_analysis) + + self.cancel_btn = QPushButton("โŒ Cancel") + self.cancel_btn.clicked.connect(self.on_cancel_clicked) + + layout.addWidget(self.sync_btn) + layout.addWidget(self.begin_search_btn) + layout.addWidget(self.cancel_btn) + + return button_frame + + def populate_initial_table(self): + """Populate table with initial YouTube track data""" + self.track_table.setRowCount(self.total_tracks) + + for i, track in enumerate(self.playlist.tracks): + # YT Track + yt_track_item = QTableWidgetItem(track.name) + yt_track_item.setFlags(yt_track_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.track_table.setItem(i, 0, yt_track_item) + + # YT Artist + yt_artist = track.artists[0] if track.artists else "Unknown" + yt_artist_item = QTableWidgetItem(yt_artist) + yt_artist_item.setFlags(yt_artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.track_table.setItem(i, 1, yt_artist_item) + + # Spotify Match Status + status_item = QTableWidgetItem("Pending...") + status_item.setFlags(status_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.track_table.setItem(i, 2, status_item) + + # Empty cells for Spotify data (to be filled during discovery) + for col in [3, 4, 5, 6]: + empty_item = QTableWidgetItem("") + empty_item.setFlags(empty_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.track_table.setItem(i, col, empty_item) + + def start_spotify_discovery(self): + """Start the Spotify discovery process using background worker""" + print(f"๐Ÿ” Starting Spotify discovery for {self.total_tracks} tracks...") + + # Update all rows to show "Searching..." status + for row in range(self.total_tracks): + status_item = self.track_table.item(row, 2) + if status_item: + status_item.setText("๐Ÿ” Pending...") + + # Create and start a single optimized Spotify discovery worker + # Import matching engine for validation + from core.matching_engine import MusicMatchingEngine + matching_engine = MusicMatchingEngine() + + self.spotify_worker = OptimizedSpotifyDiscoveryWorker( + self.playlist.tracks, + self.parent_page.spotify_client, + matching_engine + ) + + # Connect signals + self.spotify_worker.signals.track_discovered.connect(self.on_track_discovered) + self.spotify_worker.signals.progress_updated.connect(self.on_discovery_progress) + self.spotify_worker.signals.finished.connect(self.on_spotify_discovery_finished) + + # Start the worker + QThreadPool.globalInstance().start(self.spotify_worker) + + def on_track_discovered(self, row, spotify_track, status): + """Handle a track being discovered (or not) on Spotify""" + try: + if status == "found" and spotify_track: + self.spotify_discovered_tracks[row] = spotify_track + self.update_table_with_spotify_match(row, spotify_track) + elif status == "not_found": + self.update_table_with_no_match(row) + elif status == "low_confidence": + self.update_table_with_low_confidence(row) + else: # error + self.update_table_with_error(row, status.replace("error: ", "")) + except Exception as e: + print(f"โŒ Error updating UI for track {row}: {e}") + + def on_discovery_progress(self, current): + """Update the discovery progress""" + self.spotify_progress.setValue(current) + + def on_spotify_discovery_finished(self, successful_discoveries): + """Handle Spotify discovery completion""" + self.spotify_discovery_completed() + print(f"๐ŸŽต Spotify discovery completed: {successful_discoveries}/{self.total_tracks} tracks found") + + def update_table_with_spotify_match(self, row, spotify_track): + """Update table row with successful Spotify match""" + # Spotify Match Status + status_item = self.track_table.item(row, 2) + status_item.setText("โœ… Found") + status_item.setForeground(QBrush(QColor("#4CAF50"))) + + # Spotify Track + spotify_track_item = QTableWidgetItem(spotify_track.name) + spotify_track_item.setFlags(spotify_track_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.track_table.setItem(row, 3, spotify_track_item) + + # Spotify Artist + spotify_artist = spotify_track.artists[0] if spotify_track.artists else "Unknown" + spotify_artist_item = QTableWidgetItem(spotify_artist) + spotify_artist_item.setFlags(spotify_artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.track_table.setItem(row, 4, spotify_artist_item) + + # Spotify Album + album_name = spotify_track.album if isinstance(spotify_track.album, str) else getattr(spotify_track.album, 'name', 'Unknown Album') + spotify_album_item = QTableWidgetItem(album_name) + spotify_album_item.setFlags(spotify_album_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.track_table.setItem(row, 5, spotify_album_item) + + # Status + status_col_item = self.track_table.item(row, 6) + status_col_item.setText("Ready for Plex analysis") + + def update_table_with_no_match(self, row): + """Update table row when no Spotify match found""" + # Spotify Match Status + status_item = self.track_table.item(row, 2) + status_item.setText("โŒ Not Found") + status_item.setForeground(QBrush(QColor("#ff6b6b"))) + + # Status + status_col_item = self.track_table.item(row, 6) + status_col_item.setText("Skipped - No Spotify match") + status_col_item.setForeground(QBrush(QColor("#888888"))) + + def update_table_with_low_confidence(self, row): + """Update table row when Spotify matches were found but confidence too low""" + # Spotify Match Status + status_item = self.track_table.item(row, 2) + status_item.setText("โš ๏ธ Low Confidence") + status_item.setForeground(QBrush(QColor("#FFA500"))) + + # Status + status_col_item = self.track_table.item(row, 6) + status_col_item.setText("Skipped - No reliable match") + status_col_item.setForeground(QBrush(QColor("#FFA500"))) + + def update_table_with_error(self, row, error_msg): + """Update table row when search error occurred""" + # Spotify Match Status + status_item = self.track_table.item(row, 2) + status_item.setText("โš ๏ธ Error") + status_item.setForeground(QBrush(QColor("#FFA500"))) + + # Status + status_col_item = self.track_table.item(row, 6) + status_col_item.setText(f"Error: {error_msg[:30]}...") + status_col_item.setForeground(QBrush(QColor("#FFA500"))) + + def spotify_discovery_completed(self): + """Called when Spotify discovery is complete""" + self.spotify_search_completed = True + + # Count successful discoveries + successful_discoveries = sum(1 for track in self.spotify_discovered_tracks if track is not None) + + print(f"๐ŸŽต Spotify discovery completed: {successful_discoveries}/{self.total_tracks} tracks found") + + # Enable the Plex analysis and sync buttons + self.begin_search_btn.setEnabled(True) + self.begin_search_btn.setText(f"๐Ÿ” Download Missing Tracks ({successful_discoveries} tracks)") + + self.sync_btn.setEnabled(True) + + def on_begin_plex_analysis(self): + """Create discovered playlist and open regular download modal""" + # Filter out tracks that weren't found on Spotify + valid_spotify_tracks = [track for track in self.spotify_discovered_tracks if track is not None] + + if not valid_spotify_tracks: + QMessageBox.warning(self, "No Tracks", "No tracks were successfully discovered on Spotify.") + return + + print(f"๐ŸŽต Creating discovered playlist with {len(valid_spotify_tracks)} Spotify tracks...") + + # Create a Spotify-compatible playlist from discovered tracks + discovered_playlist = self.create_discovered_playlist(valid_spotify_tracks) + + # Mark that we're transitioning to download modal (don't clean up URL tracking) + self.transitioning_to_download = True + + # Close this discovery modal + self.accept() + + # Create a dummy playlist item for the regular modal + dummy_playlist_item = type('DummyPlaylistItem', (), { + 'playlist_name': discovered_playlist.name, + 'track_count': len(discovered_playlist.tracks), + 'download_modal': None, + 'show_operation_status': lambda self, status_text="View Progress": None, + 'hide_operation_status': lambda self: None + })() + + # Open the regular DownloadMissingTracksModal with the discovered playlist + print("๐Ÿš€ Opening regular DownloadMissingTracksModal with discovered tracks...") + modal = DownloadMissingTracksModal( + discovered_playlist, + dummy_playlist_item, + self.parent_page, + self.downloads_page, + is_youtube_workflow=True # Flag to indicate this is from YouTube discovery + ) + + # Transfer URL tracking from discovery modal to download modal + if hasattr(self, 'youtube_url'): + modal.youtube_url = self.youtube_url + self.parent_page.active_youtube_processes[self.youtube_url] = modal + print(f"๐Ÿ”„ Transferred URL tracking to download modal: {self.youtube_url}") + + # Store the modal reference using the ID of the NEWLY created playlist object. + # This ensures the "View Progress" button can find it later. + print(f"๐Ÿ“ Storing modal with CORRECT discovered_playlist.id: {discovered_playlist.id}") + + print(f"๐Ÿ“ Available modals before storing: {list(self.parent_page.active_youtube_download_modals.keys())}") + self.parent_page.active_youtube_download_modals[discovered_playlist.id] = modal + print(f"๐Ÿ“ Available modals after storing: {list(self.parent_page.active_youtube_download_modals.keys())}") + + # Also update the status widget on the main page to use the correct new ID + self.parent_page.show_youtube_download_status( + discovered_playlist.name, + len(discovered_playlist.tracks), + discovered_playlist.id # Pass the correct ID here + ) + modal.exec() + + def on_sync_clicked(self): + """Handle Sync This Playlist button click""" + if self.sync_in_progress: + # Cancel ongoing sync + print(f"๐Ÿ›‘ Cancelling sync for playlist: {self.playlist.name}") + + if hasattr(self.parent_page, 'cancel_playlist_sync'): + self.parent_page.cancel_playlist_sync(self.playlist.id) + + # Reset sync state immediately (don't wait for callback) + self.sync_in_progress = False + self.sync_btn.setText("๐Ÿ”„ Sync This Playlist") + self.sync_btn.setStyleSheet(""" + QPushButton { + background-color: #ff6b6b; color: #ffffff; border: none; + border-radius: 6px; font-size: 13px; font-weight: bold; + padding: 10px 20px; min-width: 120px; + } + QPushButton:hover { background-color: #ff5252; } + QPushButton:disabled { background-color: #404040; color: #888888; } + """) + + # Hide status widget if it exists + if hasattr(self.parent_page, 'show_youtube_placeholder'): + self.parent_page.show_youtube_placeholder() + + else: + # Start sync using the parent page's sync infrastructure + print(f"๐Ÿ”„ Starting sync for playlist: {self.playlist.name}") + + if hasattr(self.parent_page, 'start_playlist_sync') and self.parent_page.start_playlist_sync(self.playlist): + print(f"โœ… Sync started successfully for: {self.playlist.name}") + + # Update UI to show sync is active + self.sync_in_progress = True + self.sync_btn.setText("Cancel Sync") + self.sync_btn.setStyleSheet(""" + QPushButton { + background-color: #e22134; color: #ffffff; border: none; + border-radius: 6px; font-size: 13px; font-weight: bold; + padding: 10px 20px; min-width: 120px; + } + QPushButton:hover { background-color: #d32f2f; } + """) + + # Show progress in status widget if modal is hidden + if self.is_youtube_workflow and hasattr(self.parent_page, 'show_youtube_download_status'): + self.update_youtube_status_for_sync() + + else: + print(f"โŒ Failed to start sync for: {self.playlist.name}") + QMessageBox.warning(self, "Sync Failed", "Failed to start playlist sync. Please try again.") + + def update_youtube_status_for_sync(self): + """Update YouTube status widget to show sync progress""" + if hasattr(self.parent_page, 'show_youtube_sync_status'): + self.parent_page.show_youtube_sync_status(self.playlist.name, len(self.playlist.tracks), self.playlist.id) + + def on_cancel_clicked(self): + """Handle cancel button click - cancel sync or close modal""" + print("๐Ÿ›‘ Cancel button clicked") + + # Cancel any running Spotify discovery worker + if self.spotify_worker: + print("๐Ÿ›‘ Cancelling Spotify discovery worker") + self.spotify_worker.cancel() + self.spotify_worker = None + + if self.sync_in_progress: + # Cancel sync operation + print("๐Ÿ›‘ Cancelling sync operation") + if hasattr(self.parent_page, 'cancel_playlist_sync'): + self.parent_page.cancel_playlist_sync(self.playlist.id) + + # Reset sync state + self.sync_in_progress = False + self.sync_btn.setText("๐Ÿ”„ Sync This Playlist") + self.sync_btn.setStyleSheet(""" + QPushButton { + background-color: #ff6b6b; color: #ffffff; border: none; + border-radius: 6px; font-size: 13px; font-weight: bold; + padding: 10px 20px; min-width: 120px; + } + QPushButton:hover { background-color: #ff5252; } + QPushButton:disabled { background-color: #404040; color: #888888; } + """) + + # Hide status widget if it exists + if hasattr(self.parent_page, 'show_youtube_placeholder'): + self.parent_page.show_youtube_placeholder() + + # Clean up URL tracking before closing (but not during download transition) + if (hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'active_youtube_processes') and + not getattr(self, 'transitioning_to_download', False)): + if self.youtube_url in self.parent_page.active_youtube_processes: + print(f"๐Ÿงน Cleaning up URL tracking on cancel for: {self.youtube_url}") + del self.parent_page.active_youtube_processes[self.youtube_url] + + # Always close/hide the modal when cancel is clicked + print("๐Ÿ›‘ Closing modal") + self.reject() + + def on_sync_finished(self, playlist_id, result): + """Handle sync completion (called from parent page)""" + if playlist_id == self.playlist.id: + print(f"๐ŸŽ‰ Sync completed for YouTube playlist: {self.playlist.name}") + + # Reset sync state + self.sync_in_progress = False + + # Reset sync button to original state + self.sync_btn.setText("๐Ÿ”„ Sync This Playlist") + self.sync_btn.setStyleSheet(""" + QPushButton { + background-color: #ff6b6b; color: #ffffff; border: none; + border-radius: 6px; font-size: 13px; font-weight: bold; + padding: 10px 20px; min-width: 120px; + } + QPushButton:hover { background-color: #ff5252; } + QPushButton:disabled { background-color: #404040; color: #888888; } + """) + + def on_sync_error(self, playlist_id, error_msg): + """Handle sync error (called from parent page)""" + if playlist_id == self.playlist.id: + print(f"โŒ Sync error for YouTube playlist: {self.playlist.name} - {error_msg}") + + # Reset sync state + self.sync_in_progress = False + + # Reset sync button to original state + self.sync_btn.setText("๐Ÿ”„ Sync This Playlist") + self.sync_btn.setStyleSheet(""" + QPushButton { + background-color: #ff6b6b; color: #ffffff; border: none; + border-radius: 6px; font-size: 13px; font-weight: bold; + padding: 10px 20px; min-width: 120px; + } + QPushButton:hover { background-color: #ff5252; } + QPushButton:disabled { background-color: #404040; color: #888888; } + """) + + def on_sync_progress(self, playlist_id, progress): + """Handle sync progress updates (called from parent page)""" + if playlist_id == self.playlist.id: + # Progress updates are handled by the status widget automatically + # This method exists for compatibility with the modal update system + pass + + def create_discovered_playlist(self, spotify_tracks): + """Create a playlist object from discovered Spotify tracks""" + import time + + # Create a playlist object compatible with existing system + discovered_playlist = type('Playlist', (), { + 'id': f"youtube_discovered_{int(time.time())}", + 'name': f"YouTube Discovered: {self.playlist.name}", + 'description': f"Discovered from YouTube playlist with {len(spotify_tracks)} matched tracks", + 'owner': "YouTube Discovery", + 'public': False, + 'collaborative': False, + 'tracks': spotify_tracks, + 'total_tracks': len(spotify_tracks) + })() + + return discovered_playlist + + def show_loading_state(self): + """Show loading state in the modal""" + # Update window title + self.setWindowTitle("YouTube Playlist Discovery - Loading...") + + # Clear the table + self.track_table.setRowCount(0) + + # Show loading message + loading_item = QTableWidgetItem("๐Ÿ”„ Parsing YouTube playlist...") + loading_item.setFlags(loading_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + + self.track_table.setRowCount(1) + self.track_table.setSpan(0, 0, 1, 7) # Span all columns + self.track_table.setItem(0, 0, loading_item) + + # Disable buttons + self.begin_search_btn.setEnabled(False) + self.begin_search_btn.setText("Waiting for playlist data...") + + def populate_with_playlist_data(self, playlist): + """Populate the modal with actual playlist data""" + print(f"๐Ÿ“Š Populating modal with {len(playlist.tracks)} tracks") + + # Update modal properties + self.playlist = playlist + + # IMPORTANT: Re-store the modal with the correct playlist.id now that we have real data + if hasattr(self.parent_page, 'active_youtube_download_modals'): + print(f"๐Ÿ“ Re-storing modal with correct playlist.id: {playlist.id}") + # Also store with fallback hash ID to match status widget expectations + fallback_id = f"youtube_{hash(playlist.name)}" + print(f"๐Ÿ“ Also storing with fallback hash ID: {fallback_id}") + self.parent_page.active_youtube_download_modals[playlist.id] = self + self.parent_page.active_youtube_download_modals[fallback_id] = self + self.total_tracks = len(playlist.tracks) + self.spotify_discovered_tracks = [None] * self.total_tracks + + # Update window title + self.setWindowTitle(f"YouTube Playlist Discovery - {playlist.name}") + + # Update progress bars + self.spotify_progress.setMaximum(self.total_tracks) + self.spotify_progress.setValue(0) + + # Populate the table with actual track data + self.populate_initial_table() + + # Start Spotify discovery + self.start_spotify_discovery() + + def closeEvent(self, event): + """Handle modal closing - hide when sync is active, otherwise close""" + print(f"๐Ÿ” DEBUG: YouTube modal closeEvent - sync_in_progress: {self.sync_in_progress}") + + # If sync is in progress, just hide the modal (don't close) + if self.sync_in_progress: + print("๐Ÿ” DEBUG: Sync in progress - hiding modal instead of closing") + event.ignore() # Prevent actual closing + self.hide() + return + + # Normal close behavior - cancel any running workers + if self.spotify_worker: + print("๐Ÿ›‘ closeEvent: Cancelling Spotify discovery worker") + self.spotify_worker.cancel() + self.spotify_worker = None + + # Clean up URL tracking when modal is actually closed (not just hidden) + # But don't clean up if we're transitioning to download modal + if (hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'active_youtube_processes') and + not getattr(self, 'transitioning_to_download', False)): + if self.youtube_url in self.parent_page.active_youtube_processes: + print(f"๐Ÿงน Cleaning up URL tracking for: {self.youtube_url}") + del self.parent_page.active_youtube_processes[self.youtube_url] + + super().closeEvent(event) + +