From 483e45cbc0223ea222d812508c71cd9a98ad30d5 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:25:05 -0700 Subject: [PATCH] Add Spotify Link tab for public playlist/album scraping without API credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrapes Spotify's embed endpoint to extract track data from any public playlist or album URL. Full discovery flow with Deezer parity: parse → card → discovery modal → live progress → sync → download missing. - New scraper: core/spotify_public_scraper.py (embed endpoint parsing) - 12 API endpoints mirroring Deezer's discovery/sync/download flow - WebSocket live discovery updates via spotify_public_discovery_states - Green-branded tab, cards, and input styling (#1DB954) - Album vs playlist detection with distinct card icons (💿/🎵) - Download persistence: card click reopens download modal after close - Card phase reset on cancel/completion (closeDownloadMissingModal) - Backend download linking for spotify_public_ and deezer_ prefixes - Completion handlers (V2 + no-missing-tracks) for both platforms - Source URLs stored on mirrored playlists for future auto-refresh --- core/spotify_public_scraper.py | 153 ++++ web_server.py | 884 +++++++++++++++++++++- webui/index.html | 15 + webui/static/script.js | 1259 +++++++++++++++++++++++++++++++- webui/static/style.css | 59 +- 5 files changed, 2339 insertions(+), 31 deletions(-) create mode 100644 core/spotify_public_scraper.py diff --git a/core/spotify_public_scraper.py b/core/spotify_public_scraper.py new file mode 100644 index 00000000..fa299817 --- /dev/null +++ b/core/spotify_public_scraper.py @@ -0,0 +1,153 @@ +""" +Spotify Public Scraper - Fetches playlist/album data from Spotify's embed endpoint +without requiring API authentication. Uses the __NEXT_DATA__ JSON embedded in the page. +""" + +import re +import json +import logging +import hashlib +import requests + +logger = logging.getLogger(__name__) + + +def parse_spotify_url(url: str) -> dict: + """ + Parse a Spotify URL and extract the type (playlist/album) and ID. + + Supports: + - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M + - https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy + - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M + - URLs with query params (?si=...) or trailing paths + + Returns: {type: 'playlist'|'album', id: str} or None + """ + if not url: + return None + + url = url.strip() + + # Handle spotify: URIs + uri_match = re.match(r'spotify:(playlist|album):([a-zA-Z0-9]+)', url) + if uri_match: + return {'type': uri_match.group(1), 'id': uri_match.group(2)} + + # Handle web URLs + url_match = re.match( + r'https?://open\.spotify\.com/(playlist|album)/([a-zA-Z0-9]+)', + url + ) + if url_match: + return {'type': url_match.group(1), 'id': url_match.group(2)} + + return None + + +def scrape_spotify_embed(spotify_type: str, spotify_id: str) -> dict: + """ + Scrape track data from Spotify's embed endpoint. + + Returns: + { + 'id': str, + 'type': 'playlist' | 'album', + 'name': str, + 'subtitle': str (owner for playlists, artist for albums), + 'tracks': [ + { + 'id': str (Spotify track ID), + 'name': str, + 'artists': [{'name': str}], + 'duration_ms': int, + 'is_explicit': bool, + 'track_number': int + } + ], + 'url_hash': str + } + """ + embed_url = f'https://open.spotify.com/embed/{spotify_type}/{spotify_id}' + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + + try: + response = requests.get(embed_url, headers=headers, timeout=20) + response.raise_for_status() + except requests.RequestException as e: + logger.error(f"Failed to fetch Spotify embed: {e}") + return {'error': f'Failed to fetch Spotify page: {str(e)}'} + + # Extract __NEXT_DATA__ JSON + match = re.search( + r'', + response.text + ) + if not match: + logger.error("No __NEXT_DATA__ found in Spotify embed response") + return {'error': 'Could not parse Spotify page. The page format may have changed.'} + + try: + next_data = json.loads(match.group(1)) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse __NEXT_DATA__ JSON: {e}") + return {'error': 'Failed to parse Spotify data'} + + # Navigate to entity data + try: + entity = next_data['props']['pageProps']['state']['data']['entity'] + except (KeyError, TypeError) as e: + logger.error(f"Unexpected embed data structure: {e}") + return {'error': 'Unexpected Spotify data format'} + + track_list = entity.get('trackList', []) + if not track_list: + return {'error': 'No tracks found in this Spotify link'} + + # Parse tracks into standardized format + tracks = [] + for i, raw_track in enumerate(track_list): + # Extract track ID from URI (spotify:track:XXXX) + uri = raw_track.get('uri', '') + track_id_match = re.match(r'spotify:track:([a-zA-Z0-9]+)', uri) + if not track_id_match: + continue + + track_id = track_id_match.group(1) + + # Parse artists from subtitle (separated by non-breaking spaces or commas) + subtitle = raw_track.get('subtitle', '') + # Replace non-breaking spaces used as separators + artist_names = [a.strip() for a in subtitle.replace('\xa0', '').split(',') if a.strip()] + if not artist_names: + artist_names = ['Unknown Artist'] + + tracks.append({ + 'id': track_id, + 'name': raw_track.get('title', 'Unknown Track'), + 'artists': [{'name': name} for name in artist_names], + 'duration_ms': raw_track.get('duration', 0), + 'is_explicit': raw_track.get('isExplicit', False), + 'track_number': i + 1 + }) + + # Generate URL hash for state management + source_url = f'https://open.spotify.com/{spotify_type}/{spotify_id}' + url_hash = hashlib.md5(source_url.encode()).hexdigest()[:12] + + result = { + 'id': spotify_id, + 'type': entity.get('type', spotify_type), + 'name': entity.get('name', 'Unknown'), + 'subtitle': entity.get('subtitle', ''), + 'tracks': tracks, + 'url': source_url, + 'url_hash': url_hash + } + + logger.info(f"Scraped Spotify {spotify_type}: {result['name']} ({len(tracks)} tracks)") + return result diff --git a/web_server.py b/web_server.py index 22e185cf..66dbee04 100644 --- a/web_server.py +++ b/web_server.py @@ -20743,6 +20743,13 @@ def _on_download_completed(batch_id, task_id, success=True): deezer_discovery_states[deezer_playlist_id]['phase'] = 'download_complete' print(f"📋 Updated Deezer playlist {deezer_playlist_id} to download_complete phase") + # Update Spotify Public playlist phase to 'download_complete' if this is a Spotify Public playlist + if playlist_id and playlist_id.startswith('spotify_public_'): + spotify_public_url_hash = playlist_id.replace('spotify_public_', '') + if spotify_public_url_hash in spotify_public_discovery_states: + spotify_public_discovery_states[spotify_public_url_hash]['phase'] = 'download_complete' + print(f"📋 Updated Spotify Public playlist {spotify_public_url_hash} to download_complete phase") + print(f"🎉 [Batch Manager] Batch {batch_id} complete - stopping monitor") download_monitor.stop_monitoring(batch_id) @@ -20942,6 +20949,20 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete' print(f"📋 Updated Tidal playlist {tidal_playlist_id} to download_complete phase (no missing tracks)") + # Update Deezer playlist phase to 'download_complete' if this is a Deezer playlist + if playlist_id.startswith('deezer_'): + deezer_playlist_id = playlist_id.replace('deezer_', '') + if deezer_playlist_id in deezer_discovery_states: + deezer_discovery_states[deezer_playlist_id]['phase'] = 'download_complete' + print(f"📋 Updated Deezer playlist {deezer_playlist_id} to download_complete phase (no missing tracks)") + + # Update Spotify Public playlist phase to 'download_complete' if this is a Spotify Public playlist + if playlist_id.startswith('spotify_public_'): + spotify_public_url_hash = playlist_id.replace('spotify_public_', '') + if spotify_public_url_hash in spotify_public_discovery_states: + spotify_public_discovery_states[spotify_public_url_hash]['phase'] = 'download_complete' + print(f"📋 Updated Spotify Public playlist {spotify_public_url_hash} to download_complete phase (no missing tracks)") + # Handle auto-initiated wishlist completion even when no missing tracks if is_auto_batch and playlist_id == 'wishlist': print("🤖 [Auto-Wishlist] No missing tracks found - calling auto-completion handler to toggle cycle and reschedule") @@ -23256,7 +23277,21 @@ def _check_batch_completion_v2(batch_id): if tidal_playlist_id in tidal_discovery_states: tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete' print(f"📋 [Completion Check V2] Updated Tidal playlist {tidal_playlist_id} to download_complete phase") - + + # Update Deezer playlist phase to 'download_complete' if this is a Deezer playlist + if playlist_id and playlist_id.startswith('deezer_'): + deezer_playlist_id = playlist_id.replace('deezer_', '') + if deezer_playlist_id in deezer_discovery_states: + deezer_discovery_states[deezer_playlist_id]['phase'] = 'download_complete' + print(f"📋 [Completion Check V2] Updated Deezer playlist {deezer_playlist_id} to download_complete phase") + + # Update Spotify Public playlist phase to 'download_complete' if this is a Spotify Public playlist + if playlist_id and playlist_id.startswith('spotify_public_'): + spotify_public_url_hash = playlist_id.replace('spotify_public_', '') + if spotify_public_url_hash in spotify_public_discovery_states: + spotify_public_discovery_states[spotify_public_url_hash]['phase'] = 'download_complete' + print(f"📋 [Completion Check V2] Updated Spotify Public playlist {spotify_public_url_hash} to download_complete phase") + print(f"🎉 [Completion Check V2] Batch {batch_id} complete - stopping monitor") download_monitor.stop_monitoring(batch_id) @@ -23561,6 +23596,24 @@ def start_missing_tracks_process(playlist_id): tidal_discovery_states[tidal_playlist_id]['converted_spotify_playlist_id'] = playlist_id print(f"🔗 Linked Tidal playlist {tidal_playlist_id} to download process {batch_id} (converted ID: {playlist_id})") + # Link Spotify Public playlist to download process if this is a Spotify Public playlist + if playlist_id.startswith('spotify_public_'): + sp_url_hash = playlist_id.replace('spotify_public_', '') + if sp_url_hash in spotify_public_discovery_states: + spotify_public_discovery_states[sp_url_hash]['download_process_id'] = batch_id + spotify_public_discovery_states[sp_url_hash]['phase'] = 'downloading' + spotify_public_discovery_states[sp_url_hash]['converted_spotify_playlist_id'] = playlist_id + print(f"🔗 Linked Spotify Public playlist {sp_url_hash} to download process {batch_id} (converted ID: {playlist_id})") + + # Link Deezer playlist to download process if this is a Deezer playlist + if playlist_id.startswith('deezer_'): + deezer_playlist_id = playlist_id.replace('deezer_', '') + if deezer_playlist_id in deezer_discovery_states: + deezer_discovery_states[deezer_playlist_id]['download_process_id'] = batch_id + deezer_discovery_states[deezer_playlist_id]['phase'] = 'downloading' + deezer_discovery_states[deezer_playlist_id]['converted_spotify_playlist_id'] = playlist_id + print(f"🔗 Linked Deezer playlist {deezer_playlist_id} to download process {batch_id} (converted ID: {playlist_id})") + missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, tracks) return jsonify({ @@ -26116,6 +26169,14 @@ def update_deezer_playlist_phase(playlist_id): state['phase'] = new_phase state['last_accessed'] = time.time() + # Update download process ID if provided (for download persistence) + if 'download_process_id' in data: + state['download_process_id'] = data['download_process_id'] + + # Update converted Spotify playlist ID if provided (for download persistence) + if 'converted_spotify_playlist_id' in data: + state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id'] + print(f"🔄 Updated Deezer playlist {playlist_id} phase: {old_phase} → {new_phase}") return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase}) @@ -26523,6 +26584,826 @@ def cancel_deezer_sync(playlist_id): return jsonify({"error": str(e)}), 500 +# =================================================================== +# SPOTIFY PUBLIC PLAYLIST DISCOVERY API ENDPOINTS +# =================================================================== + +# Global state for Spotify Public playlist management +spotify_public_discovery_states = {} # Key: url_hash, Value: discovery state +spotify_public_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="spotify_public_discovery") + +@app.route('/api/spotify/parse-public', methods=['POST']) +def parse_spotify_public_endpoint(): + """Parse a public Spotify playlist or album URL without API auth""" + try: + data = request.get_json() + url = data.get('url', '').strip() + + if not url: + return jsonify({"error": "Spotify URL is required"}), 400 + + from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed + + parsed = parse_spotify_url(url) + if not parsed: + return jsonify({"error": "Invalid Spotify URL. Please use a playlist or album link from open.spotify.com"}), 400 + + print(f"🎵 Scraping public Spotify {parsed['type']}: {parsed['id']}") + + result = scrape_spotify_embed(parsed['type'], parsed['id']) + + if 'error' in result: + return jsonify(result), 400 + + # Convert scraped tracks to Spotify-compatible format + spotify_tracks = [] + for track in result['tracks']: + spotify_tracks.append({ + 'id': track['id'], + 'name': track['name'], + 'artists': track['artists'], + 'album': { + 'name': result['name'] if result['type'] == 'album' else '', + 'images': [] + }, + 'duration_ms': track['duration_ms'], + 'explicit': track.get('is_explicit', False), + 'track_number': track.get('track_number', 0) + }) + + url_hash = result['url_hash'] + + response_data = { + 'id': result['id'], + 'type': result['type'], + 'name': result['name'], + 'subtitle': result['subtitle'], + 'url': result['url'], + 'url_hash': url_hash, + 'track_count': len(spotify_tracks), + 'tracks': spotify_tracks + } + + # Store playlist data in state for discovery (if not already there) + if url_hash not in spotify_public_discovery_states: + spotify_public_discovery_states[url_hash] = { + 'playlist': response_data, + 'phase': 'fresh', + 'status': 'fresh', + 'discovery_progress': 0, + 'spotify_matches': 0, + 'spotify_total': len(spotify_tracks), + 'discovery_results': [], + 'sync_playlist_id': None, + 'converted_spotify_playlist_id': None, + 'download_process_id': None, + 'created_at': time.time(), + 'last_accessed': time.time(), + 'discovery_future': None, + 'sync_progress': {} + } + else: + # Update playlist data in existing state + spotify_public_discovery_states[url_hash]['playlist'] = response_data + spotify_public_discovery_states[url_hash]['last_accessed'] = time.time() + + print(f"✅ Spotify {parsed['type']} scraped: {result['name']} ({len(spotify_tracks)} tracks)") + return jsonify(response_data) + + except Exception as e: + print(f"❌ Error parsing Spotify URL: {e}") + import traceback + traceback.print_exc() + return jsonify({"error": str(e)}), 500 + +@app.route('/api/spotify-public/discovery/start/', methods=['POST']) +def start_spotify_public_discovery(url_hash): + """Start Spotify discovery process for a Spotify Public playlist""" + try: + # Initialize discovery state if it doesn't exist, or update existing state + if url_hash in spotify_public_discovery_states: + existing_state = spotify_public_discovery_states[url_hash] + if existing_state['phase'] == 'discovering': + return jsonify({"error": "Discovery already in progress"}), 400 + + if not existing_state.get('playlist'): + return jsonify({"error": "Spotify Public playlist not found. Please parse the URL first."}), 404 + + # Update existing state for discovery + existing_state['phase'] = 'discovering' + existing_state['status'] = 'discovering' + existing_state['last_accessed'] = time.time() + state = existing_state + else: + return jsonify({"error": "Spotify Public playlist not found. Please parse the URL first."}), 404 + + # Add activity for discovery start + playlist_name = state['playlist']['name'] + track_count = len(state['playlist']['tracks']) + add_activity_item("🔍", "Spotify Link Discovery Started", f"'{playlist_name}' - {track_count} tracks", "Now") + + # Start discovery worker + future = spotify_public_discovery_executor.submit(_run_spotify_public_discovery_worker, url_hash) + state['discovery_future'] = future + + print(f"🔍 Started Spotify discovery for Spotify Public playlist: {playlist_name}") + return jsonify({"success": True, "message": "Discovery started"}) + + except Exception as e: + print(f"❌ Error starting Spotify Public discovery: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/spotify-public/discovery/status/', methods=['GET']) +def get_spotify_public_discovery_status(url_hash): + """Get real-time discovery status for a Spotify Public playlist""" + try: + if url_hash not in spotify_public_discovery_states: + return jsonify({"error": "Spotify Public discovery not found"}), 404 + + state = spotify_public_discovery_states[url_hash] + state['last_accessed'] = time.time() + + 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 Spotify Public discovery status: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/spotify-public/discovery/update_match', methods=['POST']) +def update_spotify_public_discovery_match(): + """Update a Spotify Public discovery result with manually selected Spotify track""" + try: + data = request.get_json() + identifier = data.get('identifier') # url_hash + track_index = data.get('track_index') + spotify_track = data.get('spotify_track') + + if not identifier or track_index is None or not spotify_track: + return jsonify({'error': 'Missing required fields'}), 400 + + # Get the state + state = spotify_public_discovery_states.get(identifier) + + if not state: + return jsonify({'error': 'Discovery state not found'}), 404 + + if track_index >= len(state['discovery_results']): + return jsonify({'error': 'Invalid track index'}), 400 + + # Update the result + result = state['discovery_results'][track_index] + old_status = result.get('status') + + # Update with user-selected track + result['status'] = '✅ Found' + result['status_class'] = 'found' + result['spotify_track'] = spotify_track['name'] + result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists']) + result['spotify_album'] = spotify_track['album'] + result['spotify_id'] = spotify_track['id'] + + # Format duration + duration_ms = spotify_track.get('duration_ms', 0) + if duration_ms: + minutes = duration_ms // 60000 + seconds = (duration_ms % 60000) // 1000 + result['duration'] = f"{minutes}:{seconds:02d}" + else: + result['duration'] = '0:00' + + # IMPORTANT: Also set spotify_data for sync/download compatibility + result['spotify_data'] = { + 'id': spotify_track['id'], + 'name': spotify_track['name'], + 'artists': spotify_track['artists'], + 'album': spotify_track['album'], + 'duration_ms': spotify_track.get('duration_ms', 0) + } + + result['manual_match'] = True + + # Update match count if status changed from not found/error + if old_status != 'found' and old_status != '✅ Found': + state['spotify_matches'] = state.get('spotify_matches', 0) + 1 + + print(f"✅ Manual match updated: spotify_public - {identifier} - track {track_index}") + print(f" → {result['spotify_artist']} - {result['spotify_track']}") + + # Save manual fix to discovery cache so it appears in discovery pool + try: + original_track = result.get('spotify_public_track', {}) + original_name = original_track.get('name', spotify_track['name']) + original_artists = original_track.get('artists', []) + original_artist = original_artists[0] if original_artists else '' + + cache_key = _get_discovery_cache_key(original_name, original_artist) + # Normalize artists to plain strings for cache consistency + artists_list = spotify_track['artists'] + if isinstance(artists_list, list): + artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] + album_raw = spotify_track.get('album', '') + album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''} + + matched_data = { + 'id': spotify_track['id'], + 'name': spotify_track['name'], + 'artists': artists_list, + 'album': album_obj, + 'duration_ms': spotify_track.get('duration_ms', 0), + 'source': 'spotify', + } + cache_db = get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], 'spotify', 1.0, matched_data, + original_name, original_artist + ) + print(f"💾 Manual fix saved to discovery cache: {original_name} by {original_artist}") + except Exception as cache_err: + print(f"⚠️ Error saving manual fix to discovery cache: {cache_err}") + + return jsonify({'success': True, 'result': result}) + + except Exception as e: + print(f"❌ Error updating Spotify Public discovery match: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/spotify-public/playlists/states', methods=['GET']) +def get_spotify_public_playlist_states(): + """Get all stored Spotify Public playlist discovery states for frontend hydration""" + try: + states = [] + current_time = time.time() + + for url_hash, state in spotify_public_discovery_states.items(): + state['last_accessed'] = current_time + + state_info = { + 'playlist_id': url_hash, + 'phase': state['phase'], + 'status': state['status'], + 'discovery_progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'discovery_results': state['discovery_results'], + 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), + 'download_process_id': state.get('download_process_id'), + 'last_accessed': state['last_accessed'] + } + states.append(state_info) + + print(f"🎵 Returning {len(states)} stored Spotify Public playlist states for hydration") + return jsonify({"states": states}) + + except Exception as e: + print(f"❌ Error getting Spotify Public playlist states: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/spotify-public/state/', methods=['GET']) +def get_spotify_public_playlist_state(url_hash): + """Get specific Spotify Public playlist state (detailed version)""" + try: + if url_hash not in spotify_public_discovery_states: + return jsonify({"error": "Spotify Public playlist not found"}), 404 + + state = spotify_public_discovery_states[url_hash] + state['last_accessed'] = time.time() + + response = { + 'playlist_id': url_hash, + 'playlist': state['playlist'], + 'phase': state['phase'], + 'status': state['status'], + 'discovery_progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'discovery_results': state['discovery_results'], + 'sync_playlist_id': state.get('sync_playlist_id'), + 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), + 'download_process_id': state.get('download_process_id'), + 'sync_progress': state.get('sync_progress', {}), + 'last_accessed': state['last_accessed'] + } + + return jsonify(response) + + except Exception as e: + print(f"❌ Error getting Spotify Public playlist state: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/spotify-public/reset/', methods=['POST']) +def reset_spotify_public_playlist(url_hash): + """Reset Spotify Public playlist to fresh phase (clear discovery/sync data)""" + try: + if url_hash not in spotify_public_discovery_states: + return jsonify({"error": "Spotify Public playlist not found"}), 404 + + state = spotify_public_discovery_states[url_hash] + + # Stop any active discovery + if 'discovery_future' in state and state['discovery_future']: + state['discovery_future'].cancel() + + # Reset state to fresh (preserve original playlist data) + state['phase'] = 'fresh' + state['status'] = 'fresh' + state['discovery_results'] = [] + state['discovery_progress'] = 0 + state['spotify_matches'] = 0 + state['sync_playlist_id'] = None + state['converted_spotify_playlist_id'] = None + state['download_process_id'] = None + state['sync_progress'] = {} + state['discovery_future'] = None + state['last_accessed'] = time.time() + + print(f"🔄 Reset Spotify Public playlist to fresh: {url_hash}") + return jsonify({"success": True, "message": "Playlist reset to fresh phase"}) + + except Exception as e: + print(f"❌ Error resetting Spotify Public playlist: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/spotify-public/delete/', methods=['POST']) +def delete_spotify_public_playlist(url_hash): + """Delete Spotify Public playlist state completely""" + try: + if url_hash not in spotify_public_discovery_states: + return jsonify({"error": "Spotify Public playlist not found"}), 404 + + state = spotify_public_discovery_states[url_hash] + + # Stop any active discovery + if 'discovery_future' in state and state['discovery_future']: + state['discovery_future'].cancel() + + # Remove from state dictionary + del spotify_public_discovery_states[url_hash] + + print(f"🗑️ Deleted Spotify Public playlist state: {url_hash}") + return jsonify({"success": True, "message": "Playlist deleted"}) + + except Exception as e: + print(f"❌ Error deleting Spotify Public playlist: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/spotify-public/update_phase/', methods=['POST']) +def update_spotify_public_playlist_phase(url_hash): + """Update Spotify Public playlist phase (used when modal closes to reset from download_complete to discovered)""" + try: + if url_hash not in spotify_public_discovery_states: + return jsonify({"error": "Spotify Public playlist not found"}), 404 + + data = request.get_json() + if not data or 'phase' not in data: + return jsonify({"error": "Phase not provided"}), 400 + + new_phase = data['phase'] + valid_phases = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete'] + + if new_phase not in valid_phases: + return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400 + + state = spotify_public_discovery_states[url_hash] + old_phase = state.get('phase', 'unknown') + state['phase'] = new_phase + state['last_accessed'] = time.time() + + # Update download process ID if provided (for download persistence) + if 'download_process_id' in data: + state['download_process_id'] = data['download_process_id'] + + # Update converted Spotify playlist ID if provided (for download persistence) + if 'converted_spotify_playlist_id' in data: + state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id'] + + print(f"🔄 Updated Spotify Public playlist {url_hash} phase: {old_phase} → {new_phase}") + return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase}) + + except Exception as e: + print(f"❌ Error updating Spotify Public playlist phase: {e}") + return jsonify({"error": str(e)}), 500 + + +def _run_spotify_public_discovery_worker(url_hash): + """Background worker for Spotify Public discovery process (Spotify preferred, iTunes fallback)""" + _ew_state = {} + try: + _ew_state = _pause_enrichment_workers('Spotify Public discovery') + state = spotify_public_discovery_states[url_hash] + playlist = state['playlist'] + + # Determine which provider to use + use_spotify = spotify_client and spotify_client.is_spotify_authenticated() + discovery_source = 'spotify' if use_spotify else 'itunes' + + # Initialize iTunes client if needed + itunes_client_instance = None + if not use_spotify: + from core.itunes_client import iTunesClient + itunes_client_instance = iTunesClient() + + print(f"🎵 Starting Spotify Public discovery for: {playlist['name']} (using {discovery_source.upper()})") + + # Store discovery source in state for frontend + state['discovery_source'] = discovery_source + + successful_discoveries = 0 + tracks = playlist['tracks'] + + for i, sp_track in enumerate(tracks): + if state.get('cancelled', False): + break + + try: + track_name = sp_track['name'] + track_artists_raw = sp_track.get('artists', []) + # Normalize artists to list of strings + track_artists = [] + for a in track_artists_raw: + if isinstance(a, dict): + track_artists.append(a.get('name', '')) + else: + track_artists.append(str(a)) + track_id = sp_track.get('id', '') + track_album = sp_track.get('album', '') + if isinstance(track_album, dict): + track_album_name = track_album.get('name', '') + else: + track_album_name = track_album or '' + track_duration_ms = sp_track.get('duration_ms', 0) + + print(f"🔍 [{i+1}/{len(tracks)}] Searching {discovery_source.upper()}: {track_name} by {', '.join(track_artists)}") + + # Check discovery cache first + cache_key = _get_discovery_cache_key(track_name, track_artists[0] if track_artists else '') + try: + cache_db = get_database() + cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source) + if cached_match and _validate_discovery_cache_artist(track_artists[0] if track_artists else '', cached_match): + print(f"⚡ CACHE HIT [{i+1}/{len(tracks)}]: {track_name} by {', '.join(track_artists)}") + # Extract display-friendly artist string from cached match + cached_artists = cached_match.get('artists', []) + if cached_artists: + cached_artist_str = ', '.join( + a if isinstance(a, str) else a.get('name', '') for a in cached_artists + ) + else: + cached_artist_str = '' + cached_album = cached_match.get('album', '') + if isinstance(cached_album, dict): + cached_album = cached_album.get('name', '') + + result = { + 'spotify_public_track': { + 'id': track_id, + 'name': track_name, + 'artists': track_artists or [], + 'album': track_album_name, + 'duration_ms': track_duration_ms, + }, + 'spotify_data': cached_match, + 'match_data': cached_match, + 'status': '✅ Found', + 'status_class': 'found', + 'spotify_track': cached_match.get('name', ''), + 'spotify_artist': cached_artist_str, + 'spotify_album': cached_album, + 'spotify_id': cached_match.get('id', ''), + 'discovery_source': discovery_source, + 'index': i + } + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100) + continue + except Exception as cache_err: + print(f"⚠️ Cache lookup error: {cache_err}") + + # Create a SimpleNamespace duck-type object for _search_spotify_for_tidal_track + track_ns = types.SimpleNamespace( + id=track_id, + name=track_name, + artists=track_artists, + album=track_album_name, + duration_ms=track_duration_ms + ) + + # Use the search function with appropriate provider + track_result = _search_spotify_for_tidal_track( + track_ns, + use_spotify=use_spotify, + itunes_client=itunes_client_instance + ) + + # Create result entry + result = { + 'spotify_public_track': { + 'id': track_id, + 'name': track_name, + 'artists': track_artists or [], + 'album': track_album_name, + 'duration_ms': track_duration_ms, + }, + 'spotify_data': None, + 'match_data': None, + 'status': '❌ Not Found', + 'status_class': 'not-found', + 'spotify_track': '', + 'spotify_artist': '', + 'spotify_album': '', + 'discovery_source': discovery_source + } + + match_confidence = 0.0 + + if use_spotify and isinstance(track_result, tuple): + # Spotify: Function returns (Track, raw_data, confidence) + track_obj, raw_track_data, match_confidence = track_result + album_obj = raw_track_data.get('album', {}) if raw_track_data else {} + # Extract image URL from album data or track object + _album_images = album_obj.get('images', []) + _image_url = _album_images[0].get('url', '') if _album_images else (getattr(track_obj, 'image_url', '') or '') + + match_data = { + 'id': track_obj.id, + 'name': track_obj.name, + 'artists': track_obj.artists, + 'album': album_obj, + 'duration_ms': track_obj.duration_ms, + 'external_urls': track_obj.external_urls, + 'image_url': _image_url, + 'source': 'spotify' + } + result['spotify_data'] = match_data + result['match_data'] = match_data + result['status'] = '✅ Found' + result['status_class'] = 'found' + result['spotify_track'] = track_obj.name + result['spotify_artist'] = ', '.join(track_obj.artists) if isinstance(track_obj.artists, list) else str(track_obj.artists) + result['spotify_album'] = album_obj.get('name', '') if isinstance(album_obj, dict) else str(album_obj) + result['spotify_id'] = track_obj.id + result['confidence'] = match_confidence + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + + elif not use_spotify and track_result and isinstance(track_result, dict): + # iTunes: Function returns a dict with track data (includes 'confidence' key) + match_confidence = track_result.pop('confidence', 0.80) + match_data = track_result + match_data['source'] = 'itunes' + # Extract image URL from iTunes album images + _itunes_album = match_data.get('album', {}) + _itunes_images = _itunes_album.get('images', []) if isinstance(_itunes_album, dict) else [] + if _itunes_images and 'image_url' not in match_data: + match_data['image_url'] = _itunes_images[0].get('url', '') + result['spotify_data'] = match_data + result['match_data'] = match_data + result['status'] = '✅ Found' + result['status_class'] = 'found' + result['spotify_track'] = match_data.get('name', '') + itunes_artists = match_data.get('artists', []) + result['spotify_artist'] = ', '.join(a if isinstance(a, str) else a.get('name', '') for a in itunes_artists) if itunes_artists else '' + result['spotify_album'] = match_data.get('album', {}).get('name', '') if isinstance(match_data.get('album'), dict) else match_data.get('album', '') + result['spotify_id'] = match_data.get('id', '') + result['confidence'] = match_confidence + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + + # Save to discovery cache if match found + if result['status_class'] == 'found' and result.get('match_data'): + try: + cache_db = get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], discovery_source, match_confidence, + result['match_data'], track_name, + track_artists[0] if track_artists else '' + ) + print(f"💾 CACHE SAVED: {track_name} (confidence: {match_confidence:.3f})") + except Exception as cache_err: + print(f"⚠️ Cache save error: {cache_err}") + + result['index'] = i + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100) + + # Add delay between requests + time.sleep(0.1) + + except Exception as e: + print(f"❌ Error processing track {i+1}: {e}") + # Add error result + result = { + 'spotify_public_track': { + 'name': sp_track.get('name', 'Unknown'), + 'artists': sp_track.get('artists', []), + }, + 'spotify_data': None, + 'match_data': None, + 'status': '❌ Error', + 'status_class': 'error', + 'spotify_track': '', + 'spotify_artist': '', + 'spotify_album': '', + 'error': str(e), + 'discovery_source': discovery_source, + 'index': i + } + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100) + + # Mark as complete + state['phase'] = 'discovered' + state['status'] = 'discovered' + state['discovery_progress'] = 100 + + # Add activity for discovery completion + source_label = discovery_source.upper() + add_activity_item("✅", f"Spotify Link Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now") + + print(f"✅ Spotify Public discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found") + + except Exception as e: + print(f"❌ Error in Spotify Public discovery worker: {e}") + if url_hash in spotify_public_discovery_states: + spotify_public_discovery_states[url_hash]['phase'] = 'error' + spotify_public_discovery_states[url_hash]['status'] = f'error: {str(e)}' + finally: + _resume_enrichment_workers(_ew_state, 'Spotify Public discovery') + + +def convert_spotify_public_results_to_spotify_tracks(discovery_results): + """Convert Spotify Public discovery results to Spotify tracks format for sync""" + spotify_tracks = [] + + for result in discovery_results: + # Support both data formats: spotify_data (manual fixes) and individual fields (automatic discovery) + if result.get('spotify_data'): + spotify_data = result['spotify_data'] + + track = { + 'id': spotify_data['id'], + 'name': spotify_data['name'], + 'artists': spotify_data['artists'], + 'album': spotify_data['album'], + 'duration_ms': spotify_data.get('duration_ms', 0) + } + spotify_tracks.append(track) + elif result.get('spotify_track') and result.get('status_class') == 'found': + track = { + 'id': result.get('spotify_id', 'unknown'), + 'name': result.get('spotify_track', 'Unknown Track'), + 'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'], + 'album': result.get('spotify_album', 'Unknown Album'), + 'duration_ms': 0 + } + spotify_tracks.append(track) + + print(f"🔄 Converted {len(spotify_tracks)} Spotify Public matches to Spotify tracks for sync") + return spotify_tracks + + +# =================================================================== +# SPOTIFY PUBLIC SYNC API ENDPOINTS +# =================================================================== + +@app.route('/api/spotify-public/sync/start/', methods=['POST']) +def start_spotify_public_sync(url_hash): + """Start sync process for a Spotify Public playlist using discovered Spotify tracks""" + try: + if url_hash not in spotify_public_discovery_states: + return jsonify({"error": "Spotify Public playlist not found"}), 404 + + state = spotify_public_discovery_states[url_hash] + state['last_accessed'] = time.time() + + if state['phase'] not in ['discovered', 'sync_complete']: + return jsonify({"error": "Spotify Public playlist not ready for sync"}), 400 + + # Convert discovery results to Spotify tracks format + spotify_tracks = convert_spotify_public_results_to_spotify_tracks(state['discovery_results']) + + if not spotify_tracks: + return jsonify({"error": "No Spotify matches found for sync"}), 400 + + # Create a temporary playlist ID for sync tracking + sync_playlist_id = f"spotify_public_{url_hash}" + playlist_name = state['playlist']['name'] + + # Add activity for sync start + add_activity_item("🔄", "Spotify Link Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now") + + # Update Spotify Public state + state['phase'] = 'syncing' + state['sync_playlist_id'] = sync_playlist_id + state['sync_progress'] = {} + + # Start the sync using existing sync infrastructure + sync_data = { + 'playlist_id': sync_playlist_id, + 'playlist_name': playlist_name, + 'tracks': spotify_tracks + } + + with sync_lock: + sync_states[sync_playlist_id] = {"status": "starting", "progress": {}} + + # Submit sync task + future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks) + active_sync_workers[sync_playlist_id] = future + + print(f"🔄 Started Spotify Public sync for: {playlist_name} ({len(spotify_tracks)} tracks)") + return jsonify({"success": True, "sync_playlist_id": sync_playlist_id}) + + except Exception as e: + print(f"❌ Error starting Spotify Public sync: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/spotify-public/sync/status/', methods=['GET']) +def get_spotify_public_sync_status(url_hash): + """Get sync status for a Spotify Public playlist""" + try: + if url_hash not in spotify_public_discovery_states: + return jsonify({"error": "Spotify Public playlist not found"}), 404 + + state = spotify_public_discovery_states[url_hash] + state['last_accessed'] = time.time() + sync_playlist_id = state.get('sync_playlist_id') + + if not sync_playlist_id: + return jsonify({"error": "No sync in progress"}), 404 + + # Get sync status from existing sync infrastructure + with sync_lock: + sync_state = sync_states.get(sync_playlist_id, {}) + + response = { + 'phase': state['phase'], + 'sync_status': sync_state.get('status', 'unknown'), + 'progress': sync_state.get('progress', {}), + 'complete': sync_state.get('status') == 'finished', + 'error': sync_state.get('error') + } + + # Update Spotify Public state if sync completed + if sync_state.get('status') == 'finished': + state['phase'] = 'sync_complete' + state['sync_progress'] = sync_state.get('progress', {}) + playlist_name = state['playlist']['name'] + add_activity_item("🔄", "Sync Complete", f"Spotify Link playlist '{playlist_name}' synced successfully", "Now") + elif sync_state.get('status') == 'error': + state['phase'] = 'discovered' # Revert on error + playlist_name = state['playlist']['name'] + add_activity_item("❌", "Sync Failed", f"Spotify Link playlist '{playlist_name}' sync failed", "Now") + + return jsonify(response) + + except Exception as e: + print(f"❌ Error getting Spotify Public sync status: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/spotify-public/sync/cancel/', methods=['POST']) +def cancel_spotify_public_sync(url_hash): + """Cancel sync for a Spotify Public playlist""" + try: + if url_hash not in spotify_public_discovery_states: + return jsonify({"error": "Spotify Public playlist not found"}), 404 + + state = spotify_public_discovery_states[url_hash] + state['last_accessed'] = time.time() + sync_playlist_id = state.get('sync_playlist_id') + + if sync_playlist_id: + # Cancel the sync using existing sync infrastructure + with sync_lock: + sync_states[sync_playlist_id] = {"status": "cancelled"} + + # Clean up sync worker + if sync_playlist_id in active_sync_workers: + del active_sync_workers[sync_playlist_id] + + # Revert Spotify Public state + state['phase'] = 'discovered' + state['sync_playlist_id'] = None + state['sync_progress'] = {} + + return jsonify({"success": True, "message": "Spotify Public sync cancelled"}) + + except Exception as e: + print(f"❌ Error cancelling Spotify Public sync: {e}") + return jsonify({"error": str(e)}), 500 + + # =================================================================== # YOUTUBE PLAYLIST API ENDPOINTS # =================================================================== @@ -39092,6 +39973,7 @@ def _emit_discovery_progress_loop(): 'youtube': lambda: youtube_playlist_states, 'beatport': lambda: beatport_chart_states, 'listenbrainz': lambda: listenbrainz_playlist_states, + 'spotify_public': lambda: spotify_public_discovery_states, } while True: socketio.sleep(1) diff --git a/webui/index.html b/webui/index.html index 32fd7526..0fa55d21 100644 --- a/webui/index.html +++ b/webui/index.html @@ -989,6 +989,9 @@ + @@ -1055,6 +1058,18 @@ + +
+
+ + +
+
+
Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.
+
+
+
diff --git a/webui/static/script.js b/webui/static/script.js index 621cfbad..1ba6bec9 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -10035,13 +10035,14 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' : virtualPlaylistId.startsWith('tidal_') ? 'Tidal' : virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : - virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : - virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : - virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : - virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : - virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : - virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : - 'YouTube'; + virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' : + virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : + virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : + virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : + virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : + virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : + virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : + 'YouTube'; // Store metadata for discover download sidebar (will be added when Begin Analysis is clicked) if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_') || virtualPlaylistId.startsWith('listenbrainz_')) { @@ -10387,6 +10388,88 @@ async function closeDownloadMissingModal(playlistId) { } } + // Reset Spotify Public playlist phase to 'discovered' when modal is closed + if (playlistId.startsWith('spotify_public_')) { + const spUrlHash = playlistId.replace('spotify_public_', ''); + + console.log(`🧹 [Modal Close] Processing Spotify Public playlist close: playlistId="${playlistId}", urlHash="${spUrlHash}"`); + + if (spotifyPublicPlaylistStates[spUrlHash]) { + const currentPhase = spotifyPublicPlaylistStates[spUrlHash].phase; + console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); + + const preservedData = { + playlist: spotifyPublicPlaylistStates[spUrlHash].playlist, + discovery_results: spotifyPublicPlaylistStates[spUrlHash].discovery_results, + spotify_matches: spotifyPublicPlaylistStates[spUrlHash].spotify_matches, + discovery_progress: spotifyPublicPlaylistStates[spUrlHash].discovery_progress, + convertedSpotifyPlaylistId: spotifyPublicPlaylistStates[spUrlHash].convertedSpotifyPlaylistId + }; + + delete spotifyPublicPlaylistStates[spUrlHash].download_process_id; + delete spotifyPublicPlaylistStates[spUrlHash].phase; + + Object.assign(spotifyPublicPlaylistStates[spUrlHash], preservedData); + spotifyPublicPlaylistStates[spUrlHash].phase = 'discovered'; + + console.log(`🧹 [Modal Close] Reset Spotify Public playlist ${spUrlHash} - cleared download state, preserved discovery data`); + } + + updateSpotifyPublicCardPhase(spUrlHash, 'discovered'); + + try { + await fetch(`/api/spotify-public/update_phase/${spUrlHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + console.log(`✅ [Modal Close] Updated backend phase for Spotify Public playlist ${spUrlHash} to 'discovered'`); + } catch (error) { + console.error(`❌ [Modal Close] Error updating backend phase for Spotify Public playlist ${spUrlHash}:`, error); + } + } + + // Reset Deezer playlist phase to 'discovered' when modal is closed + if (playlistId.startsWith('deezer_')) { + const deezerPlaylistId = playlistId.replace('deezer_', ''); + + console.log(`🧹 [Modal Close] Processing Deezer playlist close: playlistId="${playlistId}", deezerPlaylistId="${deezerPlaylistId}"`); + + if (deezerPlaylistStates[deezerPlaylistId]) { + const currentPhase = deezerPlaylistStates[deezerPlaylistId].phase; + console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); + + const preservedData = { + playlist: deezerPlaylistStates[deezerPlaylistId].playlist, + discovery_results: deezerPlaylistStates[deezerPlaylistId].discovery_results, + spotify_matches: deezerPlaylistStates[deezerPlaylistId].spotify_matches, + discovery_progress: deezerPlaylistStates[deezerPlaylistId].discovery_progress, + convertedSpotifyPlaylistId: deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId + }; + + delete deezerPlaylistStates[deezerPlaylistId].download_process_id; + delete deezerPlaylistStates[deezerPlaylistId].phase; + + Object.assign(deezerPlaylistStates[deezerPlaylistId], preservedData); + deezerPlaylistStates[deezerPlaylistId].phase = 'discovered'; + + console.log(`🧹 [Modal Close] Reset Deezer playlist ${deezerPlaylistId} - cleared download state, preserved discovery data`); + } + + updateDeezerCardPhase(deezerPlaylistId, 'discovered'); + + try { + await fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + console.log(`✅ [Modal Close] Updated backend phase for Deezer playlist ${deezerPlaylistId} to 'discovered'`); + } catch (error) { + console.error(`❌ [Modal Close] Error updating backend phase for Deezer playlist ${deezerPlaylistId}:`, error); + } + } + // Clear wishlist modal state when modal is fully closed if (playlistId === 'wishlist') { WishlistModalState.clear(); // Clear all tracking since modal is fully closed @@ -11786,6 +11869,50 @@ async function startMissingTracksProcess(playlistId) { } } + // Update Spotify Public playlist phase to 'downloading' if this is a Spotify Public playlist + if (playlistId.startsWith('spotify_public_')) { + const urlHash = playlistId.replace('spotify_public_', ''); + if (spotifyPublicPlaylistStates[urlHash]) { + spotifyPublicPlaylistStates[urlHash].phase = 'downloading'; + spotifyPublicPlaylistStates[urlHash].convertedSpotifyPlaylistId = playlistId; + updateSpotifyPublicCardPhase(urlHash, 'downloading'); + + try { + fetch(`/api/spotify-public/update_phase/${urlHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId }) + }); + } catch (error) { + console.warn('Error updating backend Spotify Public phase to downloading:', error); + } + + console.log(`🔄 Updated Spotify Public playlist ${urlHash} to downloading phase`); + } + } + + // Update Deezer playlist phase to 'downloading' if this is a Deezer playlist + if (playlistId.startsWith('deezer_')) { + const deezerPlaylistId = playlistId.replace('deezer_', ''); + if (deezerPlaylistStates[deezerPlaylistId]) { + deezerPlaylistStates[deezerPlaylistId].phase = 'downloading'; + deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId = playlistId; + updateDeezerCardPhase(deezerPlaylistId, 'downloading'); + + try { + fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId }) + }); + } catch (error) { + console.warn('Error updating backend Deezer phase to downloading:', error); + } + + console.log(`🔄 Updated Deezer playlist ${deezerPlaylistId} to downloading phase`); + } + } + // Update ListenBrainz playlist phase to 'downloading' if this is a ListenBrainz playlist if (playlistId.startsWith('listenbrainz_')) { const playlistMbid = playlistId.replace('listenbrainz_', ''); @@ -15154,13 +15281,17 @@ async function selectDiscoveryFixTrack(track) { // For Deezer, backend expects the actual playlist_id, not url_hash const state = youtubePlaylistStates[identifier]; backendIdentifier = state?.deezer_playlist_id || identifier; + } else if (platform === 'spotify_public') { + // For Spotify Public, backend expects the url_hash + const state = youtubePlaylistStates[identifier]; + backendIdentifier = state?.spotify_public_playlist_id || identifier; } else if (platform === 'beatport') { // For Beatport, backend expects url_hash (same as identifier) backendIdentifier = identifier; } // Mirrored playlists route through the YouTube endpoint (which already handles mirrored_ prefixes) - const apiPlatform = platform === 'mirrored' ? 'youtube' : platform; + const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : platform); const requestBody = { identifier: backendIdentifier, @@ -15280,6 +15411,18 @@ async function selectDiscoveryFixTrack(track) { tidalState.spotifyMatches = state.spotifyMatches; } } + + // Also update the Spotify Public playlist card if this is a Spotify Public fix + if (platform === 'spotify_public' && state.spotify_public_playlist_id) { + const spState = spotifyPublicPlaylistStates?.[state.spotify_public_playlist_id]; + if (spState) { + spState.spotifyMatches = state.spotifyMatches; + updateSpotifyPublicCardProgress(state.spotify_public_playlist_id, { + spotify_matches: state.spotifyMatches, + spotify_total: spotify_total + }); + } + } } // Update UI - refresh the table row @@ -21890,13 +22033,14 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' : virtualPlaylistId.startsWith('tidal_') ? 'Tidal' : virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : - virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : - virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : - virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : - virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : - virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : - virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : - 'YouTube'; + virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' : + virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : + virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : + virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : + virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : + virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : + virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : + 'YouTube'; const heroContext = { type: 'playlist', @@ -22073,7 +22217,7 @@ async function loadDeezerPlaylist() { track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''), album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0, source_track_id: t.id || '' - })), { owner: playlist.owner, image_url: playlist.image_url, description: playlist.description }); + })), { owner: playlist.owner, image_url: playlist.image_url, description: rawUrl }); } renderDeezerPlaylists(); @@ -23188,6 +23332,22 @@ function initializeSyncPage() { }); } + // Logic for Spotify Public parse button + const spotifyPublicParseBtn = document.getElementById('spotify-public-parse-btn'); + if (spotifyPublicParseBtn) { + spotifyPublicParseBtn.addEventListener('click', parseSpotifyPublicUrl); + } + + // Logic for Spotify Public URL input (Enter key support) + const spotifyPublicUrlInput = document.getElementById('spotify-public-url-input'); + if (spotifyPublicUrlInput) { + spotifyPublicUrlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + parseSpotifyPublicUrl(); + } + }); + } + // Logic for Beatport Top 100 button const beatportTop100Btn = document.getElementById('beatport-top100-btn'); if (beatportTop100Btn) { @@ -26139,6 +26299,991 @@ async function handleGenreChartTypeClick(genreSlug, genreId, genreName, chartTyp } } +// =============================== +// SPOTIFY PUBLIC LINK FUNCTIONALITY +// =============================== + +let spotifyPublicPlaylists = []; // Array of loaded Spotify public playlist objects +let spotifyPublicPlaylistStates = {}; // Key: url_hash, Value: state dict + +async function parseSpotifyPublicUrl() { + const urlInput = document.getElementById('spotify-public-url-input'); + const url = urlInput.value.trim(); + + if (!url) { + showToast('Please enter a Spotify URL', 'error'); + return; + } + + // Basic URL validation + if (!url.includes('open.spotify.com/playlist') && !url.includes('open.spotify.com/album') && + !url.startsWith('spotify:playlist:') && !url.startsWith('spotify:album:')) { + showToast('Please enter a valid Spotify playlist or album URL', 'error'); + return; + } + + const parseBtn = document.getElementById('spotify-public-parse-btn'); + if (parseBtn) { + parseBtn.disabled = true; + parseBtn.textContent = 'Loading...'; + } + + try { + console.log('🎵 Parsing public Spotify URL:', url); + + const response = await fetch('/api/spotify/parse-public', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error: ${result.error}`, 'error'); + return; + } + + // Check if already loaded + if (spotifyPublicPlaylists.find(p => String(p.url_hash) === String(result.url_hash))) { + showToast('This playlist is already loaded', 'info'); + urlInput.value = ''; + return; + } + + console.log(`✅ Spotify ${result.type} parsed: ${result.name} (${result.track_count} tracks)`); + + spotifyPublicPlaylists.push(result); + + // Auto-mirror + if (result.tracks && result.tracks.length > 0) { + mirrorPlaylist('spotify_public', result.url_hash, result.name, result.tracks.map(t => ({ + track_name: t.name || '', + artist_name: Array.isArray(t.artists) ? t.artists.map(a => a.name).join(', ') : '', + album_name: t.album?.name || '', + duration_ms: t.duration_ms || 0, + source_track_id: t.id || '' + })), { owner: result.subtitle || '', image_url: '', description: result.url || '' }); + } + + renderSpotifyPublicPlaylists(); + await loadSpotifyPublicPlaylistStatesFromBackend(); + + urlInput.value = ''; + showToast(`Loaded: ${result.name} (${result.track_count} tracks)`, 'success'); + console.log(`🎵 Loaded Spotify playlist: ${result.name}`); + + } catch (error) { + console.error('❌ Error parsing Spotify URL:', error); + showToast(`Error parsing Spotify URL: ${error.message}`, 'error'); + } finally { + if (parseBtn) { + parseBtn.disabled = false; + parseBtn.textContent = 'Load'; + } + } +} + +function renderSpotifyPublicPlaylists() { + const container = document.getElementById('spotify-public-playlist-container'); + if (spotifyPublicPlaylists.length === 0) { + container.innerHTML = `
Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.
`; + return; + } + + container.innerHTML = spotifyPublicPlaylists.map(p => { + if (!spotifyPublicPlaylistStates[p.url_hash]) { + spotifyPublicPlaylistStates[p.url_hash] = { + phase: 'fresh', + playlist: p + }; + } + return createSpotifyPublicCard(p); + }).join(''); + + // Add click handlers to cards + spotifyPublicPlaylists.forEach(p => { + const card = document.getElementById(`spotify-public-card-${p.url_hash}`); + if (card) { + card.addEventListener('click', () => handleSpotifyPublicCardClick(p.url_hash)); + } + }); +} + +function createSpotifyPublicCard(playlist) { + const state = spotifyPublicPlaylistStates[playlist.url_hash]; + const phase = state ? state.phase : 'fresh'; + const isAlbum = playlist.type === 'album'; + + let buttonText = getActionButtonText(phase); + let phaseText = getPhaseText(phase); + let phaseColor = getPhaseColor(phase); + + return ` +
+
${isAlbum ? '💿' : '🎵'}
+
+
${escapeHtml(playlist.name)}
+
+ ${isAlbum ? 'Album' : 'Playlist'} + ${playlist.track_count || playlist.tracks.length} tracks + ${phaseText} +
+
+
+ +
+ +
+ `; +} + +async function handleSpotifyPublicCardClick(urlHash) { + const state = spotifyPublicPlaylistStates[urlHash]; + if (!state) { + console.error(`No state found for Spotify public playlist: ${urlHash}`); + showToast('Playlist state not found - try refreshing the page', 'error'); + return; + } + + if (!state.playlist) { + console.error(`No playlist data found for Spotify public playlist: ${urlHash}`); + showToast('Playlist data missing - try refreshing the page', 'error'); + return; + } + + if (!state.phase) { + state.phase = 'fresh'; + } + + console.log(`🎵 [Card Click] Spotify public card clicked: ${urlHash}, Phase: ${state.phase}`); + + if (state.phase === 'fresh') { + console.log(`🎵 Using pre-loaded Spotify public playlist data for: ${state.playlist.name}`); + openSpotifyPublicDiscoveryModal(urlHash, state.playlist); + + } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { + console.log(`🎵 [Card Click] Opening Spotify public discovery modal for ${state.phase} phase`); + + if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) { + try { + const stateResponse = await fetch(`/api/spotify-public/state/${urlHash}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results) { + state.discovery_results = fullState.discovery_results; + state.spotify_matches = fullState.spotify_matches || state.spotify_matches; + state.discovery_progress = fullState.discovery_progress || state.discovery_progress; + spotifyPublicPlaylistStates[urlHash] = { ...spotifyPublicPlaylistStates[urlHash], ...state }; + console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`); + } + } + } catch (error) { + console.error(`Failed to fetch discovery results from backend: ${error}`); + } + } + + openSpotifyPublicDiscoveryModal(urlHash, state.playlist); + } else if (state.phase === 'downloading' || state.phase === 'download_complete') { + if (state.convertedSpotifyPlaylistId) { + if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) { + const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId]; + if (process.modalElement) { + process.modalElement.style.display = 'flex'; + } else { + await rehydrateSpotifyPublicDownloadModal(urlHash, state); + } + } else { + await rehydrateSpotifyPublicDownloadModal(urlHash, state); + } + } else { + if (state.discovery_results && state.discovery_results.length > 0) { + openSpotifyPublicDiscoveryModal(urlHash, state.playlist); + } else { + showToast('Unable to open download modal - missing playlist data', 'error'); + } + } + } +} + +async function rehydrateSpotifyPublicDownloadModal(urlHash, state) { + try { + if (!state || !state.playlist) { + showToast('Cannot open download modal - invalid playlist data', 'error'); + return; + } + + const spotifyTracks = state.discovery_results + ?.filter(result => result.spotify_data) + ?.map(result => result.spotify_data) || []; + + if (spotifyTracks.length > 0) { + const virtualPlaylistId = state.convertedSpotifyPlaylistId || `spotify_public_${urlHash}`; + await openDownloadMissingModalForTidal(virtualPlaylistId, state.playlist.name, spotifyTracks); + + if (state.download_process_id) { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = state.download_process_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + startModalDownloadPolling(virtualPlaylistId); + } + } + } else { + showToast('No Spotify tracks found for download', 'error'); + } + } catch (error) { + console.error(`Error rehydrating Spotify public download modal: ${error}`); + } +} + +async function openSpotifyPublicDiscoveryModal(urlHash, playlistData) { + console.log(`🎵 Opening Spotify public discovery modal (reusing YouTube modal): ${playlistData.name}`); + + const fakeUrlHash = `spotifypublic_${urlHash}`; + + const cardState = spotifyPublicPlaylistStates[urlHash]; + const isAlreadyDiscovered = cardState && (cardState.phase === 'discovered' || cardState.phase === 'syncing' || cardState.phase === 'sync_complete'); + const isCurrentlyDiscovering = cardState && cardState.phase === 'discovering'; + + let transformedResults = []; + let actualMatches = 0; + if (isAlreadyDiscovered && cardState.discovery_results) { + transformedResults = cardState.discovery_results.map((result, index) => { + const isFound = result.status === 'found' || + result.status === '✅ Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; + if (isFound) actualMatches++; + + return { + index: index, + yt_track: result.spotify_public_track ? result.spotify_public_track.name : 'Unknown', + yt_artist: result.spotify_public_track ? (result.spotify_public_track.artists ? result.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', + status_class: isFound ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists ? + (Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + spotify_id: result.spotify_id, + manual_match: result.manual_match + }; + }); + console.log(`🎵 Spotify public modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`); + } + + // Normalize artist objects to strings for the discovery modal table + const normalizedTracks = playlistData.tracks.map(t => ({ + ...t, + artists: Array.isArray(t.artists) + ? t.artists.map(a => typeof a === 'object' ? a.name : a) + : t.artists + })); + + const modalPhase = cardState ? cardState.phase : 'fresh'; + youtubePlaylistStates[fakeUrlHash] = { + phase: modalPhase, + playlist: { + name: playlistData.name, + tracks: normalizedTracks + }, + is_spotify_public_playlist: true, + spotify_public_playlist_id: urlHash, + discovery_progress: isAlreadyDiscovered ? 100 : 0, + spotify_matches: isAlreadyDiscovered ? actualMatches : 0, + spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, + spotify_total: playlistData.tracks.length, + discovery_results: transformedResults, + discoveryResults: transformedResults, + discoveryProgress: isAlreadyDiscovered ? 100 : 0 + }; + + if (!isAlreadyDiscovered && !isCurrentlyDiscovering) { + try { + console.log(`🔍 Starting Spotify public discovery for: ${playlistData.name}`); + + const response = await fetch(`/api/spotify-public/discovery/start/${urlHash}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + console.error('Error starting Spotify public discovery:', result.error); + showToast(`Error starting discovery: ${result.error}`, 'error'); + return; + } + + console.log('Spotify public discovery started, beginning polling...'); + + spotifyPublicPlaylistStates[urlHash].phase = 'discovering'; + updateSpotifyPublicCardPhase(urlHash, 'discovering'); + youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; + + startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash); + + } catch (error) { + console.error('Error starting Spotify public discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + } + } else if (isCurrentlyDiscovering) { + console.log(`🔄 Resuming Spotify public discovery polling for: ${playlistData.name}`); + startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash); + } else if (cardState && cardState.phase === 'syncing') { + console.log(`🔄 Resuming Spotify public sync polling for: ${playlistData.name}`); + startSpotifyPublicSyncPolling(fakeUrlHash); + } else { + console.log('Using existing results - no need to re-discover'); + } + + openYouTubeDiscoveryModal(fakeUrlHash); +} + +function startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash) { + console.log(`🔄 Starting Spotify public discovery polling for: ${urlHash}`); + + if (activeYouTubePollers[fakeUrlHash]) { + clearInterval(activeYouTubePollers[fakeUrlHash]); + } + + // WebSocket subscription + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [urlHash] }); + _discoveryProgressCallbacks[urlHash] = (data) => { + if (data.error) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + return; + } + const transformed = { + progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total, + results: (data.results || []).map((r, i) => { + const isFound = r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track; + return { + index: i, yt_track: r.spotify_public_track ? r.spotify_public_track.name : 'Unknown', + yt_artist: r.spotify_public_track ? (r.spotify_public_track.artists ? r.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', status_class: isFound ? 'found' : 'not-found', + spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), + spotify_artist: r.spotify_data && r.spotify_data.artists ? (Array.isArray(r.spotify_data.artists) ? r.spotify_data.artists.join(', ') : r.spotify_data.artists) : (r.spotify_artist || '-'), + spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'), + spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match + }; + }) + }; + const st = youtubePlaylistStates[fakeUrlHash]; + if (st) { + st.discovery_progress = data.progress; st.discoveryProgress = data.progress; + st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches; + st.discovery_results = data.results; st.discoveryResults = transformed.results; + st.phase = data.phase; + updateYouTubeDiscoveryModal(fakeUrlHash, transformed); + } + if (spotifyPublicPlaylistStates[urlHash]) { + spotifyPublicPlaylistStates[urlHash].phase = data.phase; + spotifyPublicPlaylistStates[urlHash].discovery_results = data.results; + spotifyPublicPlaylistStates[urlHash].spotify_matches = data.spotify_matches; + spotifyPublicPlaylistStates[urlHash].discovery_progress = data.progress; + updateSpotifyPublicCardPhase(urlHash, data.phase); + } + updateSpotifyPublicCardProgress(urlHash, data); + if (data.complete) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + } + }; + } + + const pollInterval = setInterval(async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/spotify-public/discovery/status/${urlHash}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling Spotify public discovery status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + return; + } + + const transformedStatus = { + progress: status.progress, + spotify_matches: status.spotify_matches, + spotify_total: status.spotify_total, + results: status.results.map((result, index) => { + const isFound = result.status === 'found' || + result.status === '✅ Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; + + return { + index: index, + yt_track: result.spotify_public_track ? result.spotify_public_track.name : 'Unknown', + yt_artist: result.spotify_public_track ? (result.spotify_public_track.artists ? result.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', + status_class: isFound ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists ? + (Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + spotify_id: result.spotify_id, + manual_match: result.manual_match + }; + }) + }; + + const state = youtubePlaylistStates[fakeUrlHash]; + if (state) { + state.discovery_progress = status.progress; + state.discoveryProgress = status.progress; + state.spotify_matches = status.spotify_matches; + state.spotifyMatches = status.spotify_matches; + state.discovery_results = status.results; + state.discoveryResults = transformedStatus.results; + state.phase = status.phase; + + updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus); + + if (spotifyPublicPlaylistStates[urlHash]) { + spotifyPublicPlaylistStates[urlHash].phase = status.phase; + spotifyPublicPlaylistStates[urlHash].discovery_results = status.results; + spotifyPublicPlaylistStates[urlHash].spotify_matches = status.spotify_matches; + spotifyPublicPlaylistStates[urlHash].discovery_progress = status.progress; + updateSpotifyPublicCardPhase(urlHash, status.phase); + } + + updateSpotifyPublicCardProgress(urlHash, status); + + console.log(`🔄 Spotify public discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`); + } + + if (status.complete) { + console.log(`Spotify public discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + + } catch (error) { + console.error('Error polling Spotify public discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + }, 1000); + + activeYouTubePollers[fakeUrlHash] = pollInterval; +} + +async function loadSpotifyPublicPlaylistStatesFromBackend() { + try { + console.log('🎵 Loading Spotify public playlist states from backend...'); + + const response = await fetch('/api/spotify-public/playlists/states'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch Spotify public playlist states'); + } + + const data = await response.json(); + const states = data.states || []; + + console.log(`🎵 Found ${states.length} stored Spotify public playlist states in backend`); + + if (states.length === 0) return; + + for (const stateInfo of states) { + await applySpotifyPublicPlaylistState(stateInfo); + } + + // Rehydrate download modals for playlists in downloading/download_complete phases + for (const stateInfo of states) { + if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') && + stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) { + + const convertedPlaylistId = stateInfo.converted_spotify_playlist_id; + + if (!activeDownloadProcesses[convertedPlaylistId]) { + console.log(`Rehydrating download modal for Spotify public playlist: ${stateInfo.playlist_id}`); + try { + const playlistData = spotifyPublicPlaylists.find(p => String(p.url_hash) === String(stateInfo.playlist_id)); + if (!playlistData) continue; + + const spotifyTracks = spotifyPublicPlaylistStates[stateInfo.playlist_id]?.discovery_results + ?.filter(result => result.spotify_data) + ?.map(result => result.spotify_data) || []; + + if (spotifyTracks.length > 0) { + await openDownloadMissingModalForTidal( + convertedPlaylistId, + playlistData.name, + spotifyTracks + ); + + const process = activeDownloadProcesses[convertedPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = stateInfo.download_process_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + startModalDownloadPolling(convertedPlaylistId); + } + } + } catch (error) { + console.error(`Error rehydrating Spotify public download modal for ${stateInfo.playlist_id}:`, error); + } + } + } + } + + console.log('Spotify public playlist states loaded and applied'); + + } catch (error) { + console.error('Error loading Spotify public playlist states:', error); + } +} + +async function applySpotifyPublicPlaylistState(stateInfo) { + const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo; + + try { + console.log(`🎵 Applying saved state for Spotify public playlist: ${playlist_id}, Phase: ${phase}`); + + const playlistData = spotifyPublicPlaylists.find(p => String(p.url_hash) === String(playlist_id)); + if (!playlistData) { + console.warn(`Playlist data not found for state ${playlist_id} - skipping`); + return; + } + + if (!spotifyPublicPlaylistStates[playlist_id]) { + spotifyPublicPlaylistStates[playlist_id] = { + playlist: playlistData, + phase: 'fresh' + }; + } + + spotifyPublicPlaylistStates[playlist_id].phase = phase; + spotifyPublicPlaylistStates[playlist_id].discovery_progress = discovery_progress; + spotifyPublicPlaylistStates[playlist_id].spotify_matches = spotify_matches; + spotifyPublicPlaylistStates[playlist_id].discovery_results = discovery_results; + spotifyPublicPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id; + spotifyPublicPlaylistStates[playlist_id].download_process_id = download_process_id; + spotifyPublicPlaylistStates[playlist_id].playlist = playlistData; + + if (phase !== 'fresh' && phase !== 'discovering') { + try { + const stateResponse = await fetch(`/api/spotify-public/state/${playlist_id}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results && spotifyPublicPlaylistStates[playlist_id]) { + spotifyPublicPlaylistStates[playlist_id].discovery_results = fullState.discovery_results; + spotifyPublicPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress; + spotifyPublicPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches; + spotifyPublicPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; + spotifyPublicPlaylistStates[playlist_id].download_process_id = fullState.download_process_id; + } + } + } catch (error) { + console.warn(`Error fetching full discovery results for Spotify public playlist ${playlistData.name}:`, error.message); + } + } + + updateSpotifyPublicCardPhase(playlist_id, phase); + + if (phase === 'discovered' && spotifyPublicPlaylistStates[playlist_id]) { + const progressInfo = { + spotify_total: playlistData.track_count || playlistData.tracks?.length || 0, + spotify_matches: spotifyPublicPlaylistStates[playlist_id].spotify_matches || 0 + }; + updateSpotifyPublicCardProgress(playlist_id, progressInfo); + } + + if (phase === 'discovering') { + const fakeUrlHash = `spotifypublic_${playlist_id}`; + startSpotifyPublicDiscoveryPolling(fakeUrlHash, playlist_id); + } else if (phase === 'syncing') { + const fakeUrlHash = `spotifypublic_${playlist_id}`; + startSpotifyPublicSyncPolling(fakeUrlHash); + } + + } catch (error) { + console.error(`Error applying Spotify public playlist state for ${playlist_id}:`, error); + } +} + +function updateSpotifyPublicCardPhase(urlHash, phase) { + const state = spotifyPublicPlaylistStates[urlHash]; + if (!state) return; + + state.phase = phase; + + const card = document.getElementById(`spotify-public-card-${urlHash}`); + if (card) { + const newCardHtml = createSpotifyPublicCard(state.playlist); + card.outerHTML = newCardHtml; + + const newCard = document.getElementById(`spotify-public-card-${urlHash}`); + if (newCard) { + newCard.addEventListener('click', () => handleSpotifyPublicCardClick(urlHash)); + } + + if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { + setTimeout(() => { + updateSpotifyPublicCardSyncProgress(urlHash, state.lastSyncProgress); + }, 0); + } + } +} + +function updateSpotifyPublicCardProgress(urlHash, progress) { + const state = spotifyPublicPlaylistStates[urlHash]; + if (!state) return; + + const card = document.getElementById(`spotify-public-card-${urlHash}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + if (!progressElement) return; + + progressElement.classList.remove('hidden'); + + const total = progress.spotify_total || 0; + const matches = progress.spotify_matches || 0; + + if (total > 0) { + progressElement.innerHTML = ` +
+ ✓ ${matches} + / + ♪ ${total} +
+ `; + } +} + +// =============================== +// SPOTIFY PUBLIC SYNC FUNCTIONALITY +// =============================== + +async function startSpotifyPublicPlaylistSync(urlHash) { + try { + console.log('🎵 Starting Spotify public playlist sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_spotify_public_playlist) { + console.error('Invalid Spotify public playlist state for sync'); + return; + } + + const playlistId = state.spotify_public_playlist_id; + const response = await fetch(`/api/spotify-public/sync/start/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error starting sync: ${result.error}`, 'error'); + return; + } + + const syncPlaylistId = result.sync_playlist_id; + if (state) state.syncPlaylistId = syncPlaylistId; + + updateSpotifyPublicCardPhase(playlistId, 'syncing'); + updateSpotifyPublicModalButtons(urlHash, 'syncing'); + + startSpotifyPublicSyncPolling(urlHash, syncPlaylistId); + + showToast('Spotify public playlist sync started!', 'success'); + + } catch (error) { + console.error('Error starting Spotify public sync:', error); + showToast(`Error starting sync: ${error.message}`, 'error'); + } +} + +function startSpotifyPublicSyncPolling(urlHash, syncPlaylistId) { + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + const state = youtubePlaylistStates[urlHash]; + const playlistId = state.spotify_public_playlist_id; + + syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId); + + // WebSocket subscription + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateSpotifyPublicCardSyncProgress(playlistId, progress); + updateSpotifyPublicModalSyncProgress(urlHash, progress); + + if (data.status === 'finished') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateSpotifyPublicCardPhase(playlistId, 'sync_complete'); + updateSpotifyPublicModalButtons(urlHash, 'sync_complete'); + showToast('Spotify public playlist sync complete!', 'success'); + } else if (data.status === 'error' || data.status === 'cancelled') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateSpotifyPublicCardPhase(playlistId, 'discovered'); + updateSpotifyPublicModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + const pollFunction = async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/spotify-public/sync/status/${playlistId}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling Spotify public sync status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + updateSpotifyPublicCardSyncProgress(playlistId, status.progress); + updateSpotifyPublicModalSyncProgress(urlHash, status.progress); + + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateSpotifyPublicCardPhase(playlistId, 'sync_complete'); + updateSpotifyPublicModalButtons(urlHash, 'sync_complete'); + showToast('Spotify public playlist sync complete!', 'success'); + } else if (status.sync_status === 'error') { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateSpotifyPublicCardPhase(playlistId, 'discovered'); + updateSpotifyPublicModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); + } + } catch (error) { + console.error('Error polling Spotify public sync:', error); + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + } + }; + + if (!socketConnected) pollFunction(); + + const pollInterval = setInterval(pollFunction, 1000); + activeYouTubePollers[urlHash] = pollInterval; +} + +async function cancelSpotifyPublicSync(urlHash) { + try { + console.log('Cancelling Spotify public sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_spotify_public_playlist) { + console.error('Invalid Spotify public playlist state'); + return; + } + + const playlistId = state.spotify_public_playlist_id; + const response = await fetch(`/api/spotify-public/sync/cancel/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error cancelling sync: ${result.error}`, 'error'); + return; + } + + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + const syncId = state && state.syncPlaylistId; + if (syncId && _syncProgressCallbacks[syncId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] }); + delete _syncProgressCallbacks[syncId]; + } + + updateSpotifyPublicCardPhase(playlistId, 'discovered'); + updateSpotifyPublicModalButtons(urlHash, 'discovered'); + + showToast('Spotify public sync cancelled', 'info'); + + } catch (error) { + console.error('Error cancelling Spotify public sync:', error); + showToast(`Error cancelling sync: ${error.message}`, 'error'); + } +} + +function updateSpotifyPublicCardSyncProgress(urlHash, progress) { + const state = spotifyPublicPlaylistStates[urlHash]; + if (!state || !state.playlist || !progress) return; + + state.lastSyncProgress = progress; + + const card = document.getElementById(`spotify-public-card-${urlHash}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + + let statusCounterHTML = ''; + if (progress && progress.total_tracks > 0) { + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const processed = matched + failed; + const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + statusCounterHTML = ` +
+ ♪ ${total} + / + ✓ ${matched} + / + ✗ ${failed} + (${percentage}%) +
+ `; + } + + if (statusCounterHTML) { + progressElement.innerHTML = statusCounterHTML; + } +} + +function updateSpotifyPublicModalSyncProgress(urlHash, progress) { + const statusDisplay = document.getElementById(`spotify-public-sync-status-${urlHash}`); + if (!statusDisplay || !progress) return; + + const totalEl = document.getElementById(`spotify-public-total-${urlHash}`); + const matchedEl = document.getElementById(`spotify-public-matched-${urlHash}`); + const failedEl = document.getElementById(`spotify-public-failed-${urlHash}`); + const percentageEl = document.getElementById(`spotify-public-percentage-${urlHash}`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + + if (total > 0) { + const processed = matched + failed; + const percentage = Math.round((processed / total) * 100); + if (percentageEl) percentageEl.textContent = percentage; + } +} + +function updateSpotifyPublicModalButtons(urlHash, phase) { + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (!modal) return; + + const footerLeft = modal.querySelector('.modal-footer-left'); + if (footerLeft) { + footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + } +} + +async function startSpotifyPublicDownloadMissing(urlHash) { + try { + console.log('🔍 Starting download missing tracks for Spotify public playlist:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_spotify_public_playlist) { + console.error('Invalid Spotify public playlist state for download'); + return; + } + + const discoveryResults = state.discoveryResults || state.discovery_results; + + if (!discoveryResults) { + showToast('No discovery results available for download', 'error'); + return; + } + + const spotifyTracks = []; + for (const result of discoveryResults) { + if (result.spotify_data) { + spotifyTracks.push(result.spotify_data); + } else if (result.spotify_track && result.status_class === 'found') { + const albumData = result.spotify_album || 'Unknown Album'; + const albumObject = typeof albumData === 'object' && albumData !== null + ? albumData + : { + name: typeof albumData === 'string' ? albumData : 'Unknown Album', + album_type: 'album', + images: [] + }; + + spotifyTracks.push({ + id: result.spotify_id || 'unknown', + name: result.spotify_track || 'Unknown Track', + artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], + album: albumObject, + duration_ms: 0 + }); + } + } + + if (spotifyTracks.length === 0) { + showToast('No Spotify matches found for download', 'error'); + return; + } + + const realUrlHash = state.spotify_public_playlist_id; + const virtualPlaylistId = `spotify_public_${realUrlHash}`; + const playlistName = state.playlist.name; + + state.convertedSpotifyPlaylistId = virtualPlaylistId; + + // Sync convertedSpotifyPlaylistId to spotifyPublicPlaylistStates for card click routing + if (realUrlHash && spotifyPublicPlaylistStates[realUrlHash]) { + spotifyPublicPlaylistStates[realUrlHash].convertedSpotifyPlaylistId = virtualPlaylistId; + } + + const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (discoveryModal) { + discoveryModal.classList.add('hidden'); + } + + await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); + + } catch (error) { + console.error('Error starting Spotify public download missing:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + // =============================== // YOUTUBE PLAYLIST FUNCTIONALITY // =============================== @@ -26191,7 +27336,7 @@ async function parseYouTubePlaylist() { mirrorPlaylist('youtube', result.url_hash, result.name, result.tracks.map(t => ({ track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''), album_name: '', duration_ms: t.duration_ms || 0, source_track_id: t.id || '' - }))); + })), { description: url }); // Clear input urlInput.value = ''; @@ -26640,6 +27785,8 @@ function openYouTubeDiscoveryModal(urlHash) { startTidalSyncPolling(urlHash); } else if (state.is_deezer_playlist) { startDeezerSyncPolling(urlHash); + } else if (state.is_spotify_public_playlist) { + startSpotifyPublicSyncPolling(urlHash); } else if (state.is_beatport_playlist) { startBeatportSyncPolling(urlHash); } else if (state.is_listenbrainz_playlist) { @@ -26649,19 +27796,22 @@ function openYouTubeDiscoveryModal(urlHash) { } } } else { - // Create new modal (support YouTube, Tidal, Deezer, Beatport, ListenBrainz, and Mirrored) + // Create new modal (support YouTube, Tidal, Deezer, Beatport, ListenBrainz, Spotify Public, and Mirrored) const isTidal = state.is_tidal_playlist; const isDeezer = state.is_deezer_playlist; + const isSpotifyPublic = state.is_spotify_public_playlist; const isBeatport = state.is_beatport_playlist; const isListenBrainz = state.is_listenbrainz_playlist; const isMirrored = state.is_mirrored_playlist; const modalTitle = isMirrored ? '🎵 Mirrored Playlist Discovery' : + isSpotifyPublic ? '🎵 Spotify Playlist Discovery' : isDeezer ? '🎵 Deezer Playlist Discovery' : isTidal ? '🎵 Tidal Playlist Discovery' : isBeatport ? '🎵 Beatport Chart Discovery' : isListenBrainz ? '🎵 ListenBrainz Playlist Discovery' : '🎵 YouTube Playlist Discovery'; const sourceLabel = isMirrored ? (state.mirrored_source ? state.mirrored_source.charAt(0).toUpperCase() + state.mirrored_source.slice(1) : 'Source') : + isSpotifyPublic ? 'Spotify' : isDeezer ? 'Deezer' : isTidal ? 'Tidal' : isBeatport ? 'Beatport' : @@ -26674,7 +27824,7 @@ function openYouTubeDiscoveryModal(urlHash) { @@ -26809,6 +27959,8 @@ function openYouTubeDiscoveryModal(urlHash) { startTidalSyncPolling(urlHash); } else if (state.is_deezer_playlist) { startDeezerSyncPolling(urlHash); + } else if (state.is_spotify_public_playlist) { + startSpotifyPublicSyncPolling(urlHash); } else if (state.is_beatport_playlist) { startBeatportSyncPolling(urlHash); } else { @@ -26828,6 +27980,7 @@ function getModalActionButtons(urlHash, phase, state = null) { const isTidal = state && state.is_tidal_playlist; const isDeezer = state && state.is_deezer_playlist; + const isSpotifyPublic = state && state.is_spotify_public_playlist; const isBeatport = state && state.is_beatport_playlist; const isListenBrainz = state && state.is_listenbrainz_playlist; @@ -26847,6 +28000,8 @@ function getModalActionButtons(urlHash, phase, state = null) { return ``; } else if (isDeezer) { return ``; + } else if (isSpotifyPublic) { + return ``; } else if (isBeatport) { return ``; } else { @@ -26875,6 +28030,8 @@ function getModalActionButtons(urlHash, phase, state = null) { buttons += ``; } else if (isDeezer) { buttons += ``; + } else if (isSpotifyPublic) { + buttons += ``; } else if (isBeatport) { buttons += ``; } else { @@ -26885,12 +28042,13 @@ function getModalActionButtons(urlHash, phase, state = null) { // Only show download button if we have matches or a converted playlist ID if (hasSpotifyMatches || hasConvertedPlaylistId) { if (isListenBrainz) { - // ListenBrainz uses same download function as others (to be implemented) buttons += ``; } else if (isTidal) { buttons += ``; } else if (isDeezer) { buttons += ``; + } else if (isSpotifyPublic) { + buttons += ``; } else if (isBeatport) { buttons += ``; } else { @@ -26950,6 +28108,18 @@ function getModalActionButtons(urlHash, phase, state = null) { (0%)
`; + } else if (isSpotifyPublic) { + return ` + +
+ 0 + / + 0 + / + 0 + (0%) +
+ `; } else if (isBeatport) { return ` @@ -26985,6 +28155,8 @@ function getModalActionButtons(urlHash, phase, state = null) { syncCompleteButtons += ``; } else if (isTidal) { syncCompleteButtons += ``; + } else if (isSpotifyPublic) { + syncCompleteButtons += ``; } else if (isBeatport) { syncCompleteButtons += ``; } else { @@ -26998,6 +28170,8 @@ function getModalActionButtons(urlHash, phase, state = null) { syncCompleteButtons += ``; } else if (isTidal) { syncCompleteButtons += ``; + } else if (isSpotifyPublic) { + syncCompleteButtons += ``; } else if (isBeatport) { syncCompleteButtons += ``; } else { @@ -27023,8 +28197,8 @@ function getModalActionButtons(urlHash, phase, state = null) { } } -function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false) { - const source = isMirrored ? 'mirrored' : (isDeezer ? 'Deezer' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')))); +function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false) { + const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isDeezer ? 'Deezer' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'))))); switch (phase) { case 'fresh': return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`; @@ -27057,10 +28231,11 @@ function getInitialProgressText(phase, isTidal = false, isBeatport = false, isLi function generateTableRowsFromState(state, urlHash) { const isTidal = state.is_tidal_playlist; const isDeezer = state.is_deezer_playlist; + const isSpotifyPublic = state.is_spotify_public_playlist; const isBeatport = state.is_beatport_playlist; const isListenBrainz = state.is_listenbrainz_playlist; const isMirrored = state.is_mirrored_playlist; - const platform = isMirrored ? 'mirrored' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube')))); + const platform = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'spotify_public' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube'))))); // Support both camelCase and snake_case const discoveryResults = state.discoveryResults || state.discovery_results; @@ -27205,10 +28380,11 @@ function updateYouTubeDiscoveryModal(urlHash, status) { if (actionsCell) { const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; const platform = state?.is_mirrored_playlist ? 'mirrored' : + (state?.is_spotify_public_playlist ? 'spotify_public' : (state?.is_deezer_playlist ? 'deezer' : (state?.is_listenbrainz_playlist ? 'listenbrainz' : (state?.is_tidal_playlist ? 'tidal' : - (state?.is_beatport_playlist ? 'beatport' : 'youtube')))); + (state?.is_beatport_playlist ? 'beatport' : 'youtube'))))); actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform); } }); @@ -27269,13 +28445,44 @@ function closeYouTubeDiscoveryModal(urlHash) { if (state) { const isTidal = state.is_tidal_playlist; const isDeezer = state.is_deezer_playlist; + const isSpotifyPublic = state.is_spotify_public_playlist; const isBeatport = state.is_beatport_playlist; // Reset to 'discovered' phase if modal is closed after completion (like Tidal does) if (state.phase === 'sync_complete' || state.phase === 'download_complete') { - console.log(`🧹 [Modal Close] Resetting ${isDeezer ? 'Deezer' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'))} state after completion`); + console.log(`🧹 [Modal Close] Resetting ${isSpotifyPublic ? 'Spotify Public' : (isDeezer ? 'Deezer' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')))} state after completion`); - if (isDeezer) { + if (isSpotifyPublic) { + // Spotify Public: Extract url_hash and reset state + const spUrlHash = state.spotify_public_playlist_id || null; + if (spUrlHash && spotifyPublicPlaylistStates[spUrlHash]) { + const preservedData = { + playlist: spotifyPublicPlaylistStates[spUrlHash].playlist, + discovery_results: spotifyPublicPlaylistStates[spUrlHash].discovery_results, + spotify_matches: spotifyPublicPlaylistStates[spUrlHash].spotify_matches, + discovery_progress: spotifyPublicPlaylistStates[spUrlHash].discovery_progress, + convertedSpotifyPlaylistId: spotifyPublicPlaylistStates[spUrlHash].convertedSpotifyPlaylistId + }; + + delete spotifyPublicPlaylistStates[spUrlHash].download_process_id; + delete spotifyPublicPlaylistStates[spUrlHash].phase; + + Object.assign(spotifyPublicPlaylistStates[spUrlHash], preservedData); + spotifyPublicPlaylistStates[spUrlHash].phase = 'discovered'; + + updateSpotifyPublicCardPhase(spUrlHash, 'discovered'); + + try { + fetch(`/api/spotify-public/update_phase/${spUrlHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('Error updating backend Spotify Public phase:', error); + } + } + } else if (isDeezer) { // Deezer: Extract playlist ID and reset Deezer state const deezerPlaylistId = state.deezer_playlist_id || null; if (deezerPlaylistId && deezerPlaylistStates[deezerPlaylistId]) { diff --git a/webui/static/style.css b/webui/static/style.css index e9579d5d..8671d03e 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -6690,6 +6690,12 @@ body { box-shadow: 0 4px 15px rgba(255, 0, 0, 0.3); } +.sync-tab-button[data-tab="spotify-public"].active { + background: #1DB954; + color: #fff; + box-shadow: 0 4px 15px rgba(29, 185, 84, 0.3); +} + .sync-tab-button:hover:not(.active) { background: rgba(255, 255, 255, 0.1); color: #ffffff; @@ -10118,7 +10124,8 @@ body { } #youtube-url-input, -#deezer-url-input { +#deezer-url-input, +#spotify-public-url-input { flex: 1; background: transparent; border: none; @@ -10131,13 +10138,15 @@ body { } #youtube-url-input::placeholder, -#deezer-url-input::placeholder { +#deezer-url-input::placeholder, +#spotify-public-url-input::placeholder { color: rgba(255, 255, 255, 0.3); font-weight: 400; } #youtube-parse-btn, -#deezer-parse-btn { +#deezer-parse-btn, +#spotify-public-parse-btn { flex-shrink: 0; padding: 10px 22px; border: none; @@ -10186,7 +10195,8 @@ body { } #youtube-parse-btn:disabled, -#deezer-parse-btn:disabled { +#deezer-parse-btn:disabled, +#spotify-public-parse-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; @@ -10204,6 +10214,28 @@ body { box-shadow: 0 0 16px rgba(162, 56, 255, 0.08); } +#spotify-public-parse-btn { + background: linear-gradient(135deg, #1DB954, #1ed760); + color: #fff; + box-shadow: 0 2px 8px rgba(29, 185, 84, 0.2); +} + +#spotify-public-parse-btn:hover { + background: linear-gradient(135deg, #1ed760, #2de86e); + box-shadow: 0 4px 16px rgba(29, 185, 84, 0.3); + transform: translateY(-1px); +} + +#spotify-public-parse-btn:active { + transform: translateY(0); + box-shadow: 0 1px 4px rgba(29, 185, 84, 0.2); +} + +#spotify-public-tab-content .youtube-input-section:focus-within { + border-color: rgba(29, 185, 84, 0.25); + box-shadow: 0 0 16px rgba(29, 185, 84, 0.08); +} + /* Right Sidebar */ .sync-sidebar { gap: 20px; @@ -10657,6 +10689,25 @@ body { box-shadow: 0 0 15px rgba(162, 56, 255, 0.4); } +/* =============================== + SPOTIFY PUBLIC CARD STYLES (extends YouTube card styles) + ===============================*/ + +.spotify-public-card .playlist-card-icon { + background: rgba(29, 185, 84, 0.2); + border-color: #1DB954; + color: #1DB954; +} + +.spotify-public-card .playlist-card-action-btn { + background: linear-gradient(135deg, #1DB954, #1ed760); +} + +.spotify-public-card .playlist-card-action-btn:hover { + background: linear-gradient(135deg, #1ed760, #3be477); + box-shadow: 0 0 15px rgba(29, 185, 84, 0.4); +} + /* =============================== YOUTUBE DISCOVERY MODAL STYLES =============================== */