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 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/<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
|
||||
|
||||
def _run_sync_task(playlist_id, playlist_name, tracks_json):
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<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 ---
|
||||
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue