youtube shell

This commit is contained in:
Broque Thomas 2025-08-31 13:36:24 -07:00
parent 79da4ca7e4
commit 08c55217b6
3 changed files with 1439 additions and 0 deletions

View file

@ -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):

View file

@ -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

View file

@ -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;