youtube shell
This commit is contained in:
parent
79da4ca7e4
commit
08c55217b6
3 changed files with 1439 additions and 0 deletions
513
web_server.py
513
web_server.py
|
|
@ -30,6 +30,7 @@ from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorke
|
||||||
from database.music_database import get_database
|
from database.music_database import get_database
|
||||||
from services.sync_service import PlaylistSyncService
|
from services.sync_service import PlaylistSyncService
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import yt_dlp
|
||||||
|
|
||||||
# --- Flask App Setup ---
|
# --- Flask App Setup ---
|
||||||
base_dir = os.path.abspath(os.path.dirname(__file__))
|
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
|
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)
|
# METADATA & COVER ART HELPERS (Ported from downloads.py)
|
||||||
|
|
@ -5219,6 +5475,263 @@ def get_playlist_tracks(playlist_id):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
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/<url_hash>', 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/<url_hash>', 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
|
# Add these new endpoints to the end of web_server.py
|
||||||
|
|
||||||
def _run_sync_task(playlist_id, playlist_name, tracks_json):
|
def _run_sync_task(playlist_id, playlist_name, tracks_json):
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ let spotifyPlaylistsLoaded = false;
|
||||||
let activeDownloadProcesses = {};
|
let activeDownloadProcesses = {};
|
||||||
let sequentialSyncManager = null;
|
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
|
// Sequential Sync Manager Class
|
||||||
class SequentialSyncManager {
|
class SequentialSyncManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -5604,6 +5608,22 @@ function initializeSyncPage() {
|
||||||
if (startSyncBtn) {
|
if (startSyncBtn) {
|
||||||
startSyncBtn.addEventListener('click', startSequentialSync);
|
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 = `
|
||||||
|
<div class="youtube-playlist-card" id="youtube-card-${tempHash}" data-url="${url}">
|
||||||
|
<div class="playlist-card-icon youtube-icon">▶</div>
|
||||||
|
<div class="playlist-card-content">
|
||||||
|
<div class="playlist-card-name">Parsing YouTube playlist...</div>
|
||||||
|
<div class="playlist-card-info">
|
||||||
|
<span class="playlist-card-track-count">-- tracks</span>
|
||||||
|
<span class="playlist-card-phase-text" style="color: #999;">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-card-progress hidden">
|
||||||
|
♪ 0 / ✓ 0 / ✗ 0 / 0%
|
||||||
|
</div>
|
||||||
|
<button class="playlist-card-action-btn" disabled>Parsing...</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="modal-overlay" id="youtube-discovery-modal-${urlHash}">
|
||||||
|
<div class="youtube-discovery-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>🎵 YouTube Playlist Discovery</h2>
|
||||||
|
<div class="modal-subtitle">${state.playlist.name} (${state.playlist.tracks.length} tracks)</div>
|
||||||
|
<div class="modal-description">${getModalDescription(state.phase)}</div>
|
||||||
|
<button class="modal-close-btn" onclick="closeYouTubeDiscoveryModal('${urlHash}')">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="progress-label">🔍 Spotify Discovery Progress</div>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar-fill" id="youtube-discovery-progress-${urlHash}" style="width: 0%;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text" id="youtube-discovery-progress-text-${urlHash}">${getInitialProgressText(state.phase)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="discovery-table-container">
|
||||||
|
<table class="discovery-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>YT Track</th>
|
||||||
|
<th>YT Artist</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Spotify Track</th>
|
||||||
|
<th>Spotify Artist</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="youtube-discovery-table-${urlHash}">
|
||||||
|
${generateTableRowsFromState(state)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="modal-btn modal-btn-secondary" onclick="closeYouTubeDiscoveryModal('${urlHash}')">🏠 Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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) => `
|
||||||
|
<tr id="youtube-discovery-row-${result.index}">
|
||||||
|
<td class="yt-track">${result.yt_track}</td>
|
||||||
|
<td class="yt-artist">${result.yt_artist}</td>
|
||||||
|
<td class="discovery-status ${result.status_class}">${result.status}</td>
|
||||||
|
<td class="spotify-track">${result.spotify_track || '-'}</td>
|
||||||
|
<td class="spotify-artist">${result.spotify_artist || '-'}</td>
|
||||||
|
<td class="spotify-album">${result.spotify_album || '-'}</td>
|
||||||
|
<td class="duration">${result.duration}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
// Generate initial rows from playlist tracks
|
||||||
|
return generateInitialTableRows(state.playlist.tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInitialTableRows(tracks) {
|
||||||
|
return tracks.map((track, index) => `
|
||||||
|
<tr id="youtube-discovery-row-${index}">
|
||||||
|
<td class="yt-track">${track.name}</td>
|
||||||
|
<td class="yt-artist">${track.artists[0] || 'Unknown Artist'}</td>
|
||||||
|
<td class="discovery-status">🔍 Pending...</td>
|
||||||
|
<td class="spotify-track">-</td>
|
||||||
|
<td class="spotify-artist">-</td>
|
||||||
|
<td class="spotify-album">-</td>
|
||||||
|
<td class="duration">${formatDuration(track.duration_ms)}</td>
|
||||||
|
</tr>
|
||||||
|
`).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 ---
|
// --- Global Cleanup on Page Unload ---
|
||||||
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed
|
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed
|
||||||
|
|
@ -4382,6 +4382,365 @@ body {
|
||||||
display: none;
|
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 Details Modal - Clean Premium Design */
|
||||||
.playlist-modal {
|
.playlist-modal {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue