From 08c55217b6d72aad5f528544f31e84427526a483 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sun, 31 Aug 2025 13:36:24 -0700 Subject: [PATCH] youtube shell --- web_server.py | 513 +++++++++++++++++++++++++++++++++++++ webui/static/script.js | 567 +++++++++++++++++++++++++++++++++++++++++ webui/static/style.css | 359 ++++++++++++++++++++++++++ 3 files changed, 1439 insertions(+) diff --git a/web_server.py b/web_server.py index b6c33152..174f3455 100644 --- a/web_server.py +++ b/web_server.py @@ -30,6 +30,7 @@ from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorke from database.music_database import get_database from services.sync_service import PlaylistSyncService from datetime import datetime +import yt_dlp # --- Flask App Setup --- base_dir = os.path.abspath(os.path.dirname(__file__)) @@ -2855,6 +2856,261 @@ def _clean_track_title_web(track_title: str, artist_name: str) -> str: return cleaned if cleaned else original +# =================================================================== +# YOUTUBE TRACK CLEANING FUNCTIONS (Ported from GUI sync.py) +# =================================================================== + +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: + print("❌ Could not extract playlist information") + return None + + playlist_name = playlist_info.get('title', 'Unknown Playlist') + playlist_id = playlist_info.get('id', 'unknown_id') + entries = playlist_info.get('entries', []) + + print(f"🎵 Found YouTube playlist: '{playlist_name}' with {len(entries)} entries") + + for entry in entries: + if not entry: + continue + + # Extract basic information from flat extraction + raw_title = entry.get('title', 'Unknown Track') + raw_uploader = entry.get('uploader', 'Unknown Artist') + duration = entry.get('duration', 0) + video_id = entry.get('id', '') + + # Clean the track title and artist using our cleaning functions + cleaned_artist = clean_youtube_artist(raw_uploader) + cleaned_title = clean_youtube_track_title(raw_title, cleaned_artist) + + # Create track object matching GUI structure + track_data = { + 'id': video_id, + 'name': cleaned_title, + 'artists': [cleaned_artist], + 'duration_ms': duration * 1000 if duration else 0, + 'raw_title': raw_title, # Keep original for reference + 'raw_artist': raw_uploader, # Keep original for reference + 'url': f"https://www.youtube.com/watch?v={video_id}" + } + + tracks.append(track_data) + + # Create playlist object matching GUI structure + playlist_data = { + 'id': playlist_id, + 'name': playlist_name, + 'tracks': tracks, + 'track_count': len(tracks), + 'url': url, + 'source': 'youtube' + } + + print(f"✅ Successfully parsed YouTube playlist: {len(tracks)} tracks extracted") + return playlist_data + + except Exception as e: + print(f"❌ Error parsing YouTube playlist: {e}") + return None + # =================================================================== # METADATA & COVER ART HELPERS (Ported from downloads.py) @@ -5219,6 +5475,263 @@ def get_playlist_tracks(playlist_id): except Exception as e: return jsonify({"error": str(e)}), 500 + +# =================================================================== +# YOUTUBE PLAYLIST API ENDPOINTS +# =================================================================== + +# Global state for YouTube discovery processes +youtube_discovery_states = {} +youtube_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="youtube_discovery") + +@app.route('/api/youtube/parse', methods=['POST']) +def parse_youtube_playlist_endpoint(): + """Parse a YouTube playlist URL and return structured track data""" + try: + data = request.get_json() + url = data.get('url', '').strip() + + if not url: + return jsonify({"error": "YouTube URL is required"}), 400 + + # Validate URL + if not ('youtube.com/playlist' in url or 'music.youtube.com/playlist' in url): + return jsonify({"error": "Invalid YouTube playlist URL"}), 400 + + print(f"🎬 Parsing YouTube playlist: {url}") + + # Parse the playlist using our function + playlist_data = parse_youtube_playlist(url) + + if not playlist_data: + return jsonify({"error": "Failed to parse YouTube playlist"}), 500 + + # Create URL hash for state tracking + url_hash = str(hash(url)) + + # Initialize discovery state + youtube_discovery_states[url_hash] = { + 'playlist': playlist_data, + 'phase': 'fresh', + 'discovery_results': [], + 'discovery_progress': 0, + 'spotify_matches': 0, + 'spotify_total': len(playlist_data['tracks']), + 'status': 'parsed', + 'url': url + } + + playlist_data['url_hash'] = url_hash + + print(f"✅ YouTube playlist parsed successfully: {playlist_data['name']} ({len(playlist_data['tracks'])} tracks)") + return jsonify(playlist_data) + + except Exception as e: + print(f"❌ Error parsing YouTube playlist: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/youtube/discovery/start/', methods=['POST']) +def start_youtube_discovery(url_hash): + """Start Spotify discovery process for a YouTube playlist""" + try: + if url_hash not in youtube_discovery_states: + return jsonify({"error": "YouTube playlist not found"}), 404 + + state = youtube_discovery_states[url_hash] + + if state['phase'] == 'discovering': + return jsonify({"error": "Discovery already in progress"}), 400 + + # Update phase to discovering + state['phase'] = 'discovering' + state['status'] = 'discovering' + state['discovery_progress'] = 0 + state['spotify_matches'] = 0 + + # Start discovery worker + future = youtube_discovery_executor.submit(_run_youtube_discovery_worker, url_hash) + state['discovery_future'] = future + + print(f"🔍 Started Spotify discovery for YouTube playlist: {state['playlist']['name']}") + return jsonify({"success": True, "message": "Discovery started"}) + + except Exception as e: + print(f"❌ Error starting YouTube discovery: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/youtube/discovery/status/', methods=['GET']) +def get_youtube_discovery_status(url_hash): + """Get real-time discovery status for a YouTube playlist""" + try: + if url_hash not in youtube_discovery_states: + return jsonify({"error": "YouTube playlist not found"}), 404 + + state = youtube_discovery_states[url_hash] + + response = { + 'phase': state['phase'], + 'status': state['status'], + 'progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'results': state['discovery_results'], + 'complete': state['phase'] == 'discovered' + } + + return jsonify(response) + + except Exception as e: + print(f"❌ Error getting YouTube discovery status: {e}") + return jsonify({"error": str(e)}), 500 + + +def _run_youtube_discovery_worker(url_hash): + """Background worker for YouTube Spotify discovery process""" + try: + state = youtube_discovery_states[url_hash] + playlist = state['playlist'] + tracks = playlist['tracks'] + + print(f"🔍 Starting Spotify discovery for {len(tracks)} YouTube tracks...") + + if not spotify_client or not spotify_client.is_authenticated(): + print("❌ Spotify client not authenticated") + state['status'] = 'error' + state['phase'] = 'fresh' + return + + # Process each track for Spotify discovery + for i, track in enumerate(tracks): + try: + # Update progress + state['discovery_progress'] = int((i / len(tracks)) * 100) + + # Search for track on Spotify using cleaned data + cleaned_title = track['name'] + cleaned_artist = track['artists'][0] if track['artists'] else 'Unknown Artist' + + print(f"🔍 Searching Spotify for: '{cleaned_artist}' - '{cleaned_title}'") + + # Try multiple search strategies + spotify_track = None + + # Strategy 1: Standard search + query = f"artist:{cleaned_artist} track:{cleaned_title}" + spotify_results = spotify_client.search_tracks(query, limit=5) + + if spotify_results: + # Find best match using similarity + best_match = None + best_score = 0 + + for spotify_result in spotify_results: + # Calculate similarity score + title_score = _calculate_similarity(cleaned_title.lower(), spotify_result.name.lower()) + artist_score = _calculate_similarity(cleaned_artist.lower(), spotify_result.artists[0].lower()) + combined_score = (title_score * 0.7) + (artist_score * 0.3) + + if combined_score > best_score and combined_score > 0.6: + best_match = spotify_result + best_score = combined_score + + spotify_track = best_match + + # Strategy 2: Swapped search (if first failed) + if not spotify_track: + query = f"artist:{cleaned_title} track:{cleaned_artist}" + spotify_results = spotify_client.search_tracks(query, limit=3) + if spotify_results: + spotify_track = spotify_results[0] + + # Strategy 3: Raw data search (if still failed) + if not spotify_track: + raw_title = track['raw_title'] + raw_artist = track['raw_artist'] + query = f"{raw_artist} {raw_title}" + spotify_results = spotify_client.search_tracks(query, limit=3) + if spotify_results: + spotify_track = spotify_results[0] + + # Create result entry + result = { + 'index': i, + 'yt_track': cleaned_title, + 'yt_artist': cleaned_artist, + 'status': '✅ Found' if spotify_track else '❌ Not Found', + 'status_class': 'found' if spotify_track else 'not-found', + 'spotify_track': spotify_track.name if spotify_track else '', + 'spotify_artist': spotify_track.artists[0] if spotify_track else '', + 'spotify_album': spotify_track.album if spotify_track else '', + 'duration': f"{track['duration_ms'] // 60000}:{(track['duration_ms'] % 60000) // 1000:02d}" if track['duration_ms'] else '0:00' + } + + if spotify_track: + state['spotify_matches'] += 1 + result['spotify_data'] = { + 'id': spotify_track.id, + 'name': spotify_track.name, + 'artists': spotify_track.artists, + 'album': spotify_track.album, + 'duration_ms': spotify_track.duration_ms + } + + state['discovery_results'].append(result) + + print(f" {'✅' if spotify_track else '❌'} Track {i+1}/{len(tracks)}: {result['status']}") + + except Exception as e: + print(f"❌ Error processing track {i}: {e}") + # Add failed result + result = { + 'index': i, + 'yt_track': track['name'], + 'yt_artist': track['artists'][0] if track['artists'] else 'Unknown', + 'status': '❌ Error', + 'status_class': 'error', + 'spotify_track': '', + 'spotify_artist': '', + 'spotify_album': '', + 'duration': '0:00' + } + state['discovery_results'].append(result) + + # Complete discovery + state['phase'] = 'discovered' + state['status'] = 'complete' + state['discovery_progress'] = 100 + + print(f"✅ YouTube discovery complete: {state['spotify_matches']}/{len(tracks)} tracks matched") + + except Exception as e: + print(f"❌ Error in YouTube discovery worker: {e}") + state['status'] = 'error' + state['phase'] = 'fresh' + +def _calculate_similarity(str1, str2): + """Calculate string similarity using simple character overlap""" + if not str1 or not str2: + return 0 + + # Convert to lowercase and remove extra spaces + str1 = str1.lower().strip() + str2 = str2.lower().strip() + + if str1 == str2: + return 1.0 + + # Calculate character overlap + set1 = set(str1.replace(' ', '')) + set2 = set(str2.replace(' ', '')) + + if not set1 or not set2: + return 0 + + intersection = len(set1.intersection(set2)) + union = len(set1.union(set2)) + + return intersection / union if union > 0 else 0 + + # Add these new endpoints to the end of web_server.py def _run_sync_task(playlist_id, playlist_name, tracks_json): diff --git a/webui/static/script.js b/webui/static/script.js index 9d3bc0f7..5d800aac 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -38,6 +38,10 @@ let spotifyPlaylistsLoaded = false; let activeDownloadProcesses = {}; let sequentialSyncManager = null; +// --- YouTube Playlist State Management --- +let youtubePlaylistStates = {}; // Key: url_hash, Value: playlist state +let activeYouTubePollers = {}; // Key: url_hash, Value: intervalId + // Sequential Sync Manager Class class SequentialSyncManager { constructor() { @@ -5604,6 +5608,22 @@ function initializeSyncPage() { if (startSyncBtn) { startSyncBtn.addEventListener('click', startSequentialSync); } + + // Logic for the YouTube parse button + const youtubeParseBtn = document.getElementById('youtube-parse-btn'); + if (youtubeParseBtn) { + youtubeParseBtn.addEventListener('click', parseYouTubePlaylist); + } + + // Logic for YouTube URL input (Enter key support) + const youtubeUrlInput = document.getElementById('youtube-url-input'); + if (youtubeUrlInput) { + youtubeUrlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + parseYouTubePlaylist(); + } + }); + } } @@ -5784,5 +5804,552 @@ async function clearWishlist(playlistId) { } } + +// =============================== +// YOUTUBE PLAYLIST FUNCTIONALITY +// =============================== + +async function parseYouTubePlaylist() { + const urlInput = document.getElementById('youtube-url-input'); + const url = urlInput.value.trim(); + + if (!url) { + showToast('Please enter a YouTube playlist URL', 'error'); + return; + } + + // Validate URL format + if (!url.includes('youtube.com/playlist') && !url.includes('music.youtube.com/playlist')) { + showToast('Please enter a valid YouTube playlist URL', 'error'); + return; + } + + try { + console.log('🎬 Parsing YouTube playlist:', url); + + // Create card immediately in 'fresh' phase + createYouTubeCard(url, 'fresh'); + + // Parse playlist via API + const response = await fetch('/api/youtube/parse', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ url: url }) + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error parsing YouTube playlist: ${result.error}`, 'error'); + removeYouTubeCard(url); + return; + } + + console.log('✅ YouTube playlist parsed:', result.name, `(${result.tracks.length} tracks)`); + + // Update card with parsed data and stay in 'fresh' phase + updateYouTubeCardData(result.url_hash, result); + updateYouTubeCardPhase(result.url_hash, 'fresh'); + + // Clear input + urlInput.value = ''; + + // Show success message + showToast(`YouTube playlist parsed: ${result.name} (${result.tracks.length} tracks)`, 'success'); + + } catch (error) { + console.error('❌ Error parsing YouTube playlist:', error); + showToast(`Error parsing YouTube playlist: ${error.message}`, 'error'); + removeYouTubeCard(url); + } +} + +function createYouTubeCard(url, phase = 'fresh') { + const container = document.getElementById('youtube-playlist-container'); + const placeholder = container.querySelector('.playlist-placeholder'); + + // Remove placeholder if it exists + if (placeholder) { + placeholder.style.display = 'none'; + } + + // Create temporary URL hash for initial card + const tempHash = btoa(url).substring(0, 8); + + const cardHtml = ` +
+
+
+
Parsing YouTube playlist...
+
+ -- tracks + Loading... +
+
+ + +
+ `; + + container.insertAdjacentHTML('beforeend', cardHtml); + + // Store temporary state + youtubePlaylistStates[tempHash] = { + phase: phase, + url: url, + cardElement: document.getElementById(`youtube-card-${tempHash}`), + tempHash: tempHash + }; + + console.log('🃏 Created YouTube card for URL:', url); +} + +function updateYouTubeCardData(urlHash, playlistData) { + // Find the card by URL or temp hash + let state = youtubePlaylistStates[urlHash]; + if (!state) { + // Look for temporary card by URL + const tempState = Object.values(youtubePlaylistStates).find(s => s.url === playlistData.url); + if (tempState) { + // Update the state with real hash + delete youtubePlaylistStates[tempState.tempHash]; + youtubePlaylistStates[urlHash] = tempState; + state = tempState; + + // Update card ID + if (state.cardElement) { + state.cardElement.id = `youtube-card-${urlHash}`; + } + } + } + + if (!state || !state.cardElement) { + console.error('❌ Could not find YouTube card for hash:', urlHash); + return; + } + + const card = state.cardElement; + + // Update card content + const nameElement = card.querySelector('.playlist-card-name'); + const trackCountElement = card.querySelector('.playlist-card-track-count'); + + nameElement.textContent = playlistData.name; + trackCountElement.textContent = `${playlistData.tracks.length} tracks`; + + // Store playlist data + state.playlist = playlistData; + state.urlHash = urlHash; + + // Add click handler for card and action button + const handleCardClick = () => handleYouTubeCardClick(urlHash); + const actionBtn = card.querySelector('.playlist-card-action-btn'); + + card.addEventListener('click', handleCardClick); + actionBtn.addEventListener('click', (e) => { + e.stopPropagation(); // Prevent card click + handleCardClick(); + }); + + console.log('🃏 Updated YouTube card data:', playlistData.name); +} + +function updateYouTubeCardPhase(urlHash, phase) { + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.cardElement) return; + + const card = state.cardElement; + const phaseTextElement = card.querySelector('.playlist-card-phase-text'); + const actionBtn = card.querySelector('.playlist-card-action-btn'); + const progressElement = card.querySelector('.playlist-card-progress'); + + state.phase = phase; + + switch (phase) { + case 'fresh': + phaseTextElement.textContent = 'Ready to discover'; + phaseTextElement.style.color = '#999'; + actionBtn.textContent = 'Start Discovery'; + actionBtn.disabled = false; + progressElement.classList.add('hidden'); + break; + + case 'discovering': + phaseTextElement.textContent = 'Discovering...'; + phaseTextElement.style.color = '#ffa500'; // Orange + actionBtn.textContent = 'View Progress'; + actionBtn.disabled = false; + progressElement.classList.remove('hidden'); + break; + + case 'discovered': + phaseTextElement.textContent = 'Discovery Complete'; + phaseTextElement.style.color = '#1db954'; // Green + actionBtn.textContent = 'View Details'; + actionBtn.disabled = false; + progressElement.classList.add('hidden'); + break; + } + + console.log('🃏 Updated YouTube card phase:', urlHash, phase); +} + +function handleYouTubeCardClick(urlHash) { + const state = youtubePlaylistStates[urlHash]; + if (!state) return; + + switch (state.phase) { + case 'fresh': + // First click: Start discovery and open modal + console.log('🎬 Starting YouTube discovery for first time:', urlHash); + updateYouTubeCardPhase(urlHash, 'discovering'); + startYouTubeDiscovery(urlHash); + openYouTubeDiscoveryModal(urlHash); + break; + + case 'discovering': + case 'discovered': + // Subsequent clicks: Just open modal with preserved state + console.log('🎬 Opening existing YouTube modal:', urlHash); + openYouTubeDiscoveryModal(urlHash); + break; + } +} + +function updateYouTubeCardProgress(urlHash, progress) { + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.cardElement) return; + + const card = state.cardElement; + const progressElement = card.querySelector('.playlist-card-progress'); + + const total = progress.spotify_total || 0; + const matches = progress.spotify_matches || 0; + const failed = total - matches; + const percentage = total > 0 ? Math.round((matches / total) * 100) : 0; + + progressElement.textContent = `♪ ${total} / ✓ ${matches} / ✗ ${failed} / ${percentage}%`; + + console.log('🃏 Updated YouTube card progress:', urlHash, `${matches}/${total} (${percentage}%)`); +} + +function removeYouTubeCard(url) { + const state = Object.values(youtubePlaylistStates).find(s => s.url === url); + if (state && state.cardElement) { + state.cardElement.remove(); + + // Remove from state + if (state.urlHash) { + delete youtubePlaylistStates[state.urlHash]; + } else if (state.tempHash) { + delete youtubePlaylistStates[state.tempHash]; + } + } + + // Show placeholder if no cards left + const container = document.getElementById('youtube-playlist-container'); + const cards = container.querySelectorAll('.youtube-playlist-card'); + const placeholder = container.querySelector('.playlist-placeholder'); + + if (cards.length === 0 && placeholder) { + placeholder.style.display = 'block'; + } +} + +async function startYouTubeDiscovery(urlHash) { + try { + console.log('🔍 Starting YouTube Spotify discovery for:', urlHash); + + const response = await fetch(`/api/youtube/discovery/start/${urlHash}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error starting discovery: ${result.error}`, 'error'); + return; + } + + // Start polling for progress + startYouTubeDiscoveryPolling(urlHash); + + // Open discovery modal + openYouTubeDiscoveryModal(urlHash); + + } catch (error) { + console.error('❌ Error starting YouTube discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + } +} + +function startYouTubeDiscoveryPolling(urlHash) { + // Stop any existing polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + const pollInterval = setInterval(async () => { + try { + const response = await fetch(`/api/youtube/discovery/status/${urlHash}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling YouTube discovery status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + // Update card progress + updateYouTubeCardProgress(urlHash, status); + + // Store discovery results and progress in state + const state = youtubePlaylistStates[urlHash]; + if (state) { + state.discoveryResults = status.results || []; + state.discoveryProgress = status.progress || 0; + state.spotifyMatches = status.spotify_matches || 0; + } + + // Update modal if open + updateYouTubeDiscoveryModal(urlHash, status); + + // Check if complete + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + + // Update card phase to discovered + updateYouTubeCardPhase(urlHash, 'discovered'); + + console.log('✅ YouTube discovery complete:', urlHash); + showToast('YouTube discovery complete!', 'success'); + } + + } catch (error) { + console.error('❌ Error polling YouTube discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + } + }, 1000); + + activeYouTubePollers[urlHash] = pollInterval; +} + +function stopYouTubeDiscoveryPolling(urlHash) { + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + console.log('⏹ Stopped YouTube discovery polling for:', urlHash); + } +} + +function openYouTubeDiscoveryModal(urlHash) { + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.playlist) { + console.error('❌ No YouTube playlist data found for hash:', urlHash); + return; + } + + console.log('🎵 Opening YouTube discovery modal for:', state.playlist.name); + + // Check if modal already exists + let modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + + if (modal) { + // Modal exists, just show it + modal.classList.remove('hidden'); + console.log('🔄 Showing existing modal with preserved state'); + + // Resume polling if discovery is in progress + if (state.phase === 'discovering' && !activeYouTubePollers[urlHash]) { + console.log('🔄 Resuming discovery polling...'); + startYouTubeDiscoveryPolling(urlHash); + } + } else { + // Create new modal + const modalHtml = ` + + `; + + // Add modal to DOM + document.body.insertAdjacentHTML('beforeend', modalHtml); + modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + + // Store modal reference + state.modalElement = modal; + + // Set initial progress if we have discovery results + if (state.discoveryResults && state.discoveryResults.length > 0) { + const progressData = { + progress: state.discoveryProgress || 0, + spotify_matches: state.spotifyMatches || 0, + spotify_total: state.playlist.tracks.length, + results: state.discoveryResults + }; + updateYouTubeDiscoveryModal(urlHash, progressData); + } + + console.log('✨ Created new modal with current state'); + } +} + +function getModalDescription(phase) { + switch (phase) { + case 'fresh': + return 'Ready to discover clean Spotify metadata for YouTube tracks...'; + case 'discovering': + return 'Discovering clean Spotify metadata for YouTube tracks...'; + case 'discovered': + return 'Discovery complete! View the results below.'; + default: + return 'Discovering clean Spotify metadata for YouTube tracks...'; + } +} + +function getInitialProgressText(phase) { + switch (phase) { + case 'fresh': + return 'Click Start Discovery to begin...'; + case 'discovering': + return 'Starting discovery...'; + case 'discovered': + return 'Discovery completed!'; + default: + return 'Starting discovery...'; + } +} + +function generateTableRowsFromState(state) { + if (state.discoveryResults && state.discoveryResults.length > 0) { + // Generate rows from existing discovery results + return state.discoveryResults.map((result, index) => ` + + ${result.yt_track} + ${result.yt_artist} + ${result.status} + ${result.spotify_track || '-'} + ${result.spotify_artist || '-'} + ${result.spotify_album || '-'} + ${result.duration} + + `).join(''); + } else { + // Generate initial rows from playlist tracks + return generateInitialTableRows(state.playlist.tracks); + } +} + +function generateInitialTableRows(tracks) { + return tracks.map((track, index) => ` + + ${track.name} + ${track.artists[0] || 'Unknown Artist'} + 🔍 Pending... + - + - + - + ${formatDuration(track.duration_ms)} + + `).join(''); +} + +function formatDuration(durationMs) { + if (!durationMs) return '0:00'; + const minutes = Math.floor(durationMs / 60000); + const seconds = Math.floor((durationMs % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function updateYouTubeDiscoveryModal(urlHash, status) { + const progressBar = document.getElementById(`youtube-discovery-progress-${urlHash}`); + const progressText = document.getElementById(`youtube-discovery-progress-text-${urlHash}`); + const tableBody = document.getElementById(`youtube-discovery-table-${urlHash}`); + + if (!progressBar || !progressText || !tableBody) return; + + // Update progress bar + progressBar.style.width = `${status.progress}%`; + progressText.textContent = `${status.spotify_matches} / ${status.spotify_total} tracks matched (${status.progress}%)`; + + // Update table rows + status.results.forEach(result => { + const row = document.getElementById(`youtube-discovery-row-${result.index}`); + if (!row) return; + + const statusCell = row.querySelector('.discovery-status'); + const spotifyTrackCell = row.querySelector('.spotify-track'); + const spotifyArtistCell = row.querySelector('.spotify-artist'); + const spotifyAlbumCell = row.querySelector('.spotify-album'); + + statusCell.textContent = result.status; + statusCell.className = `discovery-status ${result.status_class}`; + + spotifyTrackCell.textContent = result.spotify_track || '-'; + spotifyArtistCell.textContent = result.spotify_artist || '-'; + spotifyAlbumCell.textContent = result.spotify_album || '-'; + }); +} + +function closeYouTubeDiscoveryModal(urlHash) { + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (modal) { + // Hide modal instead of removing it to preserve state + modal.classList.add('hidden'); + console.log('🚪 Hidden YouTube discovery modal (preserving state):', urlHash); + } + + // Keep modal reference and all state intact + // Discovery polling continues in background if active +} + + // --- Global Cleanup on Page Unload --- // Note: Automatic wishlist processing now runs server-side and continues even when browser is closed \ No newline at end of file diff --git a/webui/static/style.css b/webui/static/style.css index ef56372b..ca0037df 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4382,6 +4382,365 @@ body { display: none; } +/* =============================== + YOUTUBE PLAYLIST CARD STYLES + =============================== */ + +.youtube-playlist-card { + /* Premium glassmorphic foundation matching existing cards */ + background: linear-gradient(135deg, + rgba(26, 26, 26, 0.95) 0%, + rgba(18, 18, 18, 0.98) 100%); + backdrop-filter: blur(12px) saturate(1.1); + border-radius: 20px; + border: 1px solid rgba(255, 0, 0, 0.15); /* Red YouTube border */ + border-top: 1px solid rgba(255, 0, 0, 0.25); + margin: 12px 8px; + padding: 24px; + cursor: pointer; + position: relative; + height: 80px; + display: flex; + align-items: center; + + /* Premium shadow effect with YouTube red accent */ + box-shadow: + 0 8px 32px rgba(255, 0, 0, 0.15), + 0 2px 8px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.youtube-playlist-card:hover { + /* Enhanced glassmorphic hover state */ + background: linear-gradient(135deg, + rgba(30, 30, 30, 0.98) 0%, + rgba(22, 22, 22, 1.0) 100%); + border-color: rgba(255, 0, 0, 0.25); + border-top-color: rgba(255, 0, 0, 0.4); + + box-shadow: + 0 12px 40px rgba(255, 0, 0, 0.2), + 0 4px 12px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.15); + + transform: translateY(-3px); +} + +.youtube-playlist-card .playlist-card-icon { + width: 48px; + height: 48px; + border-radius: 8px; + background: linear-gradient(135deg, #ff0000 0%, #cc0000 100%); + border: 2px solid rgba(255, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: #ffffff; + margin-right: 20px; + flex-shrink: 0; + + box-shadow: + 0 4px 16px rgba(255, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.2); +} + +.youtube-playlist-card .playlist-card-content { + flex: 1; + min-width: 0; +} + +.youtube-playlist-card .playlist-card-name { + font-size: 18px; + font-weight: 600; + color: #ffffff; + margin-bottom: 4px; + line-height: 1.3; +} + +.youtube-playlist-card .playlist-card-info { + font-size: 14px; + color: #b3b3b3; + display: flex; + align-items: center; + gap: 12px; +} + +.youtube-playlist-card .playlist-card-track-count { + color: #ffffff; +} + +.youtube-playlist-card .playlist-card-phase-text { + font-weight: 500; +} + +.youtube-playlist-card .playlist-card-progress { + font-size: 13px; + color: #ff6b6b; + margin-top: 6px; + font-weight: 500; +} + +.youtube-playlist-card .playlist-card-action-btn { + /* YouTube-themed action button */ + background: linear-gradient(135deg, + rgba(255, 0, 0, 0.9) 0%, + rgba(204, 0, 0, 0.95) 100%); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 0, 0, 0.3); + border-top: 1px solid rgba(255, 0, 0, 0.5); + color: #ffffff; + padding: 10px 20px; + border-radius: 12px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + + box-shadow: + 0 4px 16px rgba(255, 0, 0, 0.25), + 0 2px 4px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + margin-left: 20px; + flex-shrink: 0; +} + +.youtube-playlist-card .playlist-card-action-btn:hover:not(:disabled) { + background: linear-gradient(135deg, + rgba(255, 0, 0, 1.0) 0%, + rgba(204, 0, 0, 1.0) 100%); + border-color: rgba(255, 0, 0, 0.6); + border-top-color: rgba(255, 0, 0, 0.8); + + box-shadow: + 0 6px 20px rgba(255, 0, 0, 0.35), + 0 3px 6px rgba(0, 0, 0, 0.25), + inset 0 1px 0 rgba(255, 255, 255, 0.25); + + transform: translateY(-2px); +} + +.youtube-playlist-card .playlist-card-action-btn:disabled { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.5); + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +/* =============================== + YOUTUBE DISCOVERY MODAL STYLES + =============================== */ + +.youtube-discovery-modal { + max-width: 1200px; + width: 90%; + max-height: 90vh; + display: flex; + flex-direction: column; + + /* Premium glassmorphic foundation */ + background: linear-gradient(135deg, + rgba(20, 20, 20, 0.95) 0%, + rgba(12, 12, 12, 0.98) 100%); + backdrop-filter: blur(20px) saturate(1.2); + border-radius: 20px; + border: 1px solid rgba(255, 0, 0, 0.2); + border-top: 1px solid rgba(255, 0, 0, 0.3); + + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.5), + 0 8px 32px rgba(255, 0, 0, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.youtube-discovery-modal .modal-header { + padding: 30px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; +} + +.youtube-discovery-modal .modal-header h2 { + color: #ffffff; + font-size: 24px; + font-weight: 700; + margin: 0 0 8px 0; +} + +.youtube-discovery-modal .modal-subtitle { + color: rgba(255, 255, 255, 0.8); + font-size: 16px; + margin: 0 0 4px 0; +} + +.youtube-discovery-modal .modal-description { + color: rgba(255, 255, 255, 0.6); + font-size: 14px; + margin: 0; +} + +.youtube-discovery-modal .modal-close-btn { + position: absolute; + top: 30px; + right: 30px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #ffffff; + width: 32px; + height: 32px; + border-radius: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: all 0.2s ease; +} + +.youtube-discovery-modal .modal-close-btn:hover { + background: rgba(255, 0, 0, 0.2); + border-color: rgba(255, 0, 0, 0.4); +} + +.youtube-discovery-modal .modal-body { + flex: 1; + padding: 30px; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 20px; +} + +.youtube-discovery-modal .progress-section { + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 20px; +} + +.youtube-discovery-modal .progress-label { + color: #ffffff; + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; +} + +.youtube-discovery-modal .progress-bar-container { + background: rgba(0, 0, 0, 0.3); + border-radius: 8px; + overflow: hidden; + height: 8px; + margin-bottom: 8px; +} + +.youtube-discovery-modal .progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, #1db954 0%, #1ed760 100%); + width: 0%; + transition: width 0.5s ease; +} + +.youtube-discovery-modal .progress-text { + color: rgba(255, 255, 255, 0.8); + font-size: 14px; + text-align: center; +} + +.youtube-discovery-modal .discovery-table-container { + flex: 1; + overflow: auto; + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.youtube-discovery-modal .discovery-table { + width: 100%; + border-collapse: collapse; +} + +.youtube-discovery-modal .discovery-table th { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + font-weight: 600; + padding: 12px 8px; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + font-size: 14px; + position: sticky; + top: 0; + z-index: 1; +} + +.youtube-discovery-modal .discovery-table td { + padding: 10px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + font-size: 13px; +} + +.youtube-discovery-modal .discovery-table .yt-track, +.youtube-discovery-modal .discovery-table .yt-artist { + color: rgba(255, 255, 255, 0.8); +} + +.youtube-discovery-modal .discovery-table .discovery-status.found { + color: #1ed760; + font-weight: 600; +} + +.youtube-discovery-modal .discovery-table .discovery-status.not-found { + color: #ff6b6b; + font-weight: 600; +} + +.youtube-discovery-modal .discovery-table .discovery-status.error { + color: #ffb84d; + font-weight: 600; +} + +.youtube-discovery-modal .discovery-table .spotify-track, +.youtube-discovery-modal .discovery-table .spotify-artist, +.youtube-discovery-modal .discovery-table .spotify-album { + color: #ffffff; +} + +.youtube-discovery-modal .modal-footer { + padding: 30px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.youtube-discovery-modal .modal-btn { + padding: 12px 24px; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + border: none; +} + +.youtube-discovery-modal .modal-btn-secondary { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.youtube-discovery-modal .modal-btn-secondary:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); +} + +/* Modal state management */ +.modal-overlay.hidden { + display: none !important; +} + /* Playlist Details Modal - Clean Premium Design */ .playlist-modal { max-width: 800px;