diff --git a/core/deezer_client.py b/core/deezer_client.py index d6dc0327..0874e0a0 100644 --- a/core/deezer_client.py +++ b/core/deezer_client.py @@ -1,7 +1,8 @@ +import re import requests import time import threading -from typing import Dict, Optional, Any +from typing import Dict, List, Optional, Any from functools import wraps from utils.logging_config import get_logger @@ -223,3 +224,124 @@ class DeezerClient: except Exception as e: logger.error(f"Error getting track {track_id}: {e}") return None + + @rate_limited + def get_playlist(self, playlist_id) -> Optional[Dict[str, Any]]: + """ + Get a playlist with all its tracks by ID. + + Fetches playlist metadata and tracks, paginating if the playlist + contains more tracks than a single response returns (400 per page). + + Args: + playlist_id: Deezer playlist ID (string or int) + + Returns: + Dict with id, name, description, track_count, image_url, owner, + and tracks list, or None on error + """ + try: + playlist_id = str(playlist_id) + + response = self.session.get( + f"{self.BASE_URL}/playlist/{playlist_id}", + timeout=15 + ) + response.raise_for_status() + + data = response.json() + if 'error' in data: + logger.error(f"Deezer API error getting playlist {playlist_id}: {data['error']}") + return None + + total_tracks = data.get('nb_tracks', 0) + raw_tracks = data.get('tracks', {}).get('data', []) + + # Paginate if we didn't get all tracks + while len(raw_tracks) < total_tracks: + index = len(raw_tracks) + logger.debug(f"Paginating playlist {playlist_id} tracks at index {index}") + page_response = self.session.get( + f"{self.BASE_URL}/playlist/{playlist_id}/tracks", + params={'index': index, 'limit': 400}, + timeout=15 + ) + page_response.raise_for_status() + + page_data = page_response.json() + if 'error' in page_data: + logger.warning(f"Error paginating playlist tracks at index {index}: {page_data['error']}") + break + + page_tracks = page_data.get('data', []) + if not page_tracks: + break + + raw_tracks.extend(page_tracks) + + # Normalize tracks + tracks: List[Dict[str, Any]] = [] + for i, t in enumerate(raw_tracks, start=1): + artist_name = t.get('artist', {}).get('name', 'Unknown Artist') + # Some tracks list multiple artists separated by commas or slashes + tracks.append({ + 'id': str(t.get('id', '')), + 'name': t.get('title', ''), + 'artists': [artist_name], + 'album': t.get('album', {}).get('title', ''), + 'duration_ms': t.get('duration', 0) * 1000, + 'track_number': i, + }) + + result = { + 'id': str(data.get('id', '')), + 'name': data.get('title', ''), + 'description': data.get('description', ''), + 'track_count': total_tracks, + 'image_url': data.get('picture_medium', ''), + 'owner': data.get('creator', {}).get('name', ''), + 'tracks': tracks, + } + + logger.info(f"Fetched playlist '{result['name']}' with {len(tracks)} tracks") + return result + + except Exception as e: + logger.error(f"Error getting playlist {playlist_id}: {e}") + return None + + @staticmethod + def parse_playlist_url(url: str) -> Optional[str]: + """ + Extract a Deezer playlist ID from a URL or raw numeric string. + + Supported formats: + https://www.deezer.com/playlist/1234567890 + https://www.deezer.com/en/playlist/1234567890 + https://deezer.com/playlist/1234567890 + 1234567890 + + Args: + url: Deezer playlist URL or numeric ID + + Returns: + Playlist ID as a string, or None if the input is invalid + """ + if not url or not isinstance(url, str): + return None + + url = url.strip() + + # Raw numeric ID + if url.isdigit(): + return url + + # URL pattern: optional www, optional locale segment, /playlist/{id} + match = re.match( + r'https?://(?:www\.)?deezer\.com/(?:[a-z]{2}/)?playlist/(\d+)', + url + ) + if match: + return match.group(1) + + return None diff --git a/web_server.py b/web_server.py index b78ccbff..53de213b 100644 --- a/web_server.py +++ b/web_server.py @@ -13,6 +13,7 @@ import glob import uuid import re import sqlite3 +import types from pathlib import Path from urllib.parse import urljoin @@ -19033,6 +19034,13 @@ def _on_download_completed(batch_id, task_id, success=True): tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete' print(f"๐Ÿ“‹ 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"๐Ÿ“‹ Updated Deezer playlist {deezer_playlist_id} to download_complete phase") + print(f"๐ŸŽ‰ [Batch Manager] Batch {batch_id} complete - stopping monitor") download_monitor.stop_monitoring(batch_id) @@ -22871,6 +22879,40 @@ def update_tidal_discovery_match(): print(f"โœ… Manual match updated: tidal - {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('tidal_track', {}) + original_name = original_track.get('name', spotify_track['name']) + original_artist = '' + original_artists = original_track.get('artists', []) + if original_artists: + original_artist = original_artists[0] if isinstance(original_artists[0], str) else original_artists[0].get('name', '') + + 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: @@ -23971,6 +24013,791 @@ def cancel_tidal_sync(playlist_id): return jsonify({"error": str(e)}), 500 +# =================================================================== +# DEEZER PLAYLIST DISCOVERY API ENDPOINTS +# =================================================================== + +# Global state for Deezer playlist discovery management +deezer_discovery_states = {} # Key: playlist_id, Value: discovery state +deezer_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="deezer_discovery") + +# Lazy-initialized global DeezerClient instance +_deezer_client_instance = None +_deezer_client_lock = threading.Lock() + +def _get_deezer_client(): + """Get or create the global DeezerClient instance (thread-safe).""" + global _deezer_client_instance + if _deezer_client_instance is None: + with _deezer_client_lock: + if _deezer_client_instance is None: + from core.deezer_client import DeezerClient + _deezer_client_instance = DeezerClient() + return _deezer_client_instance + +@app.route('/api/deezer/playlist/', methods=['GET']) +def get_deezer_playlist(playlist_id): + """Fetch a Deezer playlist by ID or URL""" + try: + from core.deezer_client import DeezerClient + + # Parse URL if needed + parsed_id = DeezerClient.parse_playlist_url(playlist_id) + if not parsed_id: + return jsonify({"error": "Invalid Deezer playlist ID or URL"}), 400 + + client = _get_deezer_client() + playlist = client.get_playlist(parsed_id) + + if not playlist: + return jsonify({"error": "Deezer playlist not found"}), 404 + + return jsonify(playlist) + + except Exception as e: + print(f"โŒ Error fetching Deezer playlist: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/deezer/discovery/start/', methods=['POST']) +def start_deezer_discovery(playlist_id): + """Start Spotify discovery process for a Deezer playlist""" + try: + from core.deezer_client import DeezerClient + + # Parse URL if needed + parsed_id = DeezerClient.parse_playlist_url(playlist_id) + if parsed_id: + playlist_id = parsed_id + + # Initialize discovery state if it doesn't exist, or update existing state + if playlist_id in deezer_discovery_states: + existing_state = deezer_discovery_states[playlist_id] + if existing_state['phase'] == 'discovering': + return jsonify({"error": "Discovery already in progress"}), 400 + + # Fetch fresh playlist data if not already stored + if not existing_state.get('playlist'): + client = _get_deezer_client() + playlist_data = client.get_playlist(playlist_id) + if not playlist_data: + return jsonify({"error": "Deezer playlist not found"}), 404 + existing_state['playlist'] = playlist_data + + # Update existing state for discovery + existing_state['phase'] = 'discovering' + existing_state['status'] = 'discovering' + existing_state['last_accessed'] = time.time() + state = existing_state + else: + # Fetch playlist data from Deezer + client = _get_deezer_client() + playlist_data = client.get_playlist(playlist_id) + + if not playlist_data: + return jsonify({"error": "Deezer playlist not found"}), 404 + + if not playlist_data.get('tracks'): + return jsonify({"error": "Playlist has no tracks"}), 400 + + # Create new state for first-time discovery + state = { + 'playlist': playlist_data, + 'phase': 'discovering', # fresh -> discovering -> discovered -> syncing -> sync_complete -> downloading -> download_complete + 'status': 'discovering', + 'discovery_progress': 0, + 'spotify_matches': 0, + 'spotify_total': len(playlist_data['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': {} + } + deezer_discovery_states[playlist_id] = state + + # Add activity for discovery start + playlist_name = state['playlist']['name'] + track_count = len(state['playlist']['tracks']) + add_activity_item("๐Ÿ”", "Deezer Discovery Started", f"'{playlist_name}' - {track_count} tracks", "Now") + + # Start discovery worker + future = deezer_discovery_executor.submit(_run_deezer_discovery_worker, playlist_id) + state['discovery_future'] = future + + print(f"๐Ÿ” Started Spotify discovery for Deezer playlist: {playlist_name}") + return jsonify({"success": True, "message": "Discovery started"}) + + except Exception as e: + print(f"โŒ Error starting Deezer discovery: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/deezer/discovery/status/', methods=['GET']) +def get_deezer_discovery_status(playlist_id): + """Get real-time discovery status for a Deezer playlist""" + try: + if playlist_id not in deezer_discovery_states: + return jsonify({"error": "Deezer discovery not found"}), 404 + + state = deezer_discovery_states[playlist_id] + 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 Deezer discovery status: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/deezer/discovery/update_match', methods=['POST']) +def update_deezer_discovery_match(): + """Update a Deezer discovery result with manually selected Spotify track""" + try: + data = request.get_json() + identifier = data.get('identifier') # playlist_id + 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 = deezer_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: deezer - {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('deezer_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 Deezer discovery match: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/deezer/playlists/states', methods=['GET']) +def get_deezer_playlist_states(): + """Get all stored Deezer playlist discovery states for frontend hydration""" + try: + states = [] + current_time = time.time() + + for playlist_id, state in deezer_discovery_states.items(): + state['last_accessed'] = current_time + + state_info = { + 'playlist_id': playlist_id, + '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 Deezer playlist states for hydration") + return jsonify({"states": states}) + + except Exception as e: + print(f"โŒ Error getting Deezer playlist states: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/deezer/state/', methods=['GET']) +def get_deezer_playlist_state(playlist_id): + """Get specific Deezer playlist state (detailed version)""" + try: + if playlist_id not in deezer_discovery_states: + return jsonify({"error": "Deezer playlist not found"}), 404 + + state = deezer_discovery_states[playlist_id] + state['last_accessed'] = time.time() + + # Deezer playlist is a dict, no __dict__ needed + response = { + 'playlist_id': playlist_id, + '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 Deezer playlist state: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/deezer/reset/', methods=['POST']) +def reset_deezer_playlist(playlist_id): + """Reset Deezer playlist to fresh phase (clear discovery/sync data)""" + try: + if playlist_id not in deezer_discovery_states: + return jsonify({"error": "Deezer playlist not found"}), 404 + + state = deezer_discovery_states[playlist_id] + + # 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 Deezer playlist to fresh: {playlist_id}") + return jsonify({"success": True, "message": "Playlist reset to fresh phase"}) + + except Exception as e: + print(f"โŒ Error resetting Deezer playlist: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/deezer/delete/', methods=['POST']) +def delete_deezer_playlist(playlist_id): + """Delete Deezer playlist state completely""" + try: + if playlist_id not in deezer_discovery_states: + return jsonify({"error": "Deezer playlist not found"}), 404 + + state = deezer_discovery_states[playlist_id] + + # Stop any active discovery + if 'discovery_future' in state and state['discovery_future']: + state['discovery_future'].cancel() + + # Remove from state dictionary + del deezer_discovery_states[playlist_id] + + print(f"๐Ÿ—‘๏ธ Deleted Deezer playlist state: {playlist_id}") + return jsonify({"success": True, "message": "Playlist deleted"}) + + except Exception as e: + print(f"โŒ Error deleting Deezer playlist: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/deezer/update_phase/', methods=['POST']) +def update_deezer_playlist_phase(playlist_id): + """Update Deezer playlist phase (used when modal closes to reset from download_complete to discovered)""" + try: + if playlist_id not in deezer_discovery_states: + return jsonify({"error": "Deezer 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 = deezer_discovery_states[playlist_id] + old_phase = state.get('phase', 'unknown') + state['phase'] = new_phase + state['last_accessed'] = time.time() + + 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}) + + except Exception as e: + print(f"โŒ Error updating Deezer playlist phase: {e}") + return jsonify({"error": str(e)}), 500 + + +def _run_deezer_discovery_worker(playlist_id): + """Background worker for Deezer discovery process (Spotify preferred, iTunes fallback)""" + _ew_state = {} + try: + _ew_state = _pause_enrichment_workers('Deezer discovery') + state = deezer_discovery_states[playlist_id] + 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 Deezer 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, deezer_track in enumerate(tracks): + if state.get('cancelled', False): + break + + try: + track_name = deezer_track['name'] + track_artists = deezer_track['artists'] + track_id = deezer_track['id'] + track_album = deezer_track.get('album', '') + track_duration_ms = deezer_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 = { + 'deezer_track': { + 'id': track_id, + 'name': track_name, + 'artists': track_artists or [], + 'album': track_album, + '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, + 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 = { + 'deezer_track': { + 'id': track_id, + 'name': track_name, + 'artists': track_artists or [], + 'album': track_album, + '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 {} + + 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, + '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' + 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 = { + 'deezer_track': { + 'name': deezer_track.get('name', 'Unknown'), + 'artists': deezer_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"Deezer Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now") + + print(f"โœ… Deezer discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found") + + except Exception as e: + print(f"โŒ Error in Deezer discovery worker: {e}") + if playlist_id in deezer_discovery_states: + deezer_discovery_states[playlist_id]['phase'] = 'error' + deezer_discovery_states[playlist_id]['status'] = f'error: {str(e)}' + finally: + _resume_enrichment_workers(_ew_state, 'Deezer discovery') + + +def convert_deezer_results_to_spotify_tracks(discovery_results): + """Convert Deezer 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)} Deezer matches to Spotify tracks for sync") + return spotify_tracks + + +# =================================================================== +# DEEZER SYNC API ENDPOINTS +# =================================================================== + +@app.route('/api/deezer/sync/start/', methods=['POST']) +def start_deezer_sync(playlist_id): + """Start sync process for a Deezer playlist using discovered Spotify tracks""" + try: + if playlist_id not in deezer_discovery_states: + return jsonify({"error": "Deezer playlist not found"}), 404 + + state = deezer_discovery_states[playlist_id] + state['last_accessed'] = time.time() + + if state['phase'] not in ['discovered', 'sync_complete']: + return jsonify({"error": "Deezer playlist not ready for sync"}), 400 + + # Convert discovery results to Spotify tracks format + spotify_tracks = convert_deezer_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"deezer_{playlist_id}" + playlist_name = state['playlist']['name'] + + # Add activity for sync start + add_activity_item("๐Ÿ”„", "Deezer Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now") + + # Update Deezer 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 Deezer 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 Deezer sync: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/deezer/sync/status/', methods=['GET']) +def get_deezer_sync_status(playlist_id): + """Get sync status for a Deezer playlist""" + try: + if playlist_id not in deezer_discovery_states: + return jsonify({"error": "Deezer playlist not found"}), 404 + + state = deezer_discovery_states[playlist_id] + 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 Deezer 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"Deezer 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"Deezer playlist '{playlist_name}' sync failed", "Now") + + return jsonify(response) + + except Exception as e: + print(f"โŒ Error getting Deezer sync status: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/deezer/sync/cancel/', methods=['POST']) +def cancel_deezer_sync(playlist_id): + """Cancel sync for a Deezer playlist""" + try: + if playlist_id not in deezer_discovery_states: + return jsonify({"error": "Deezer playlist not found"}), 404 + + state = deezer_discovery_states[playlist_id] + 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 Deezer state + state['phase'] = 'discovered' + state['sync_playlist_id'] = None + state['sync_progress'] = {} + + return jsonify({"success": True, "message": "Deezer sync cancelled"}) + + except Exception as e: + print(f"โŒ Error cancelling Deezer sync: {e}") + return jsonify({"error": str(e)}), 500 + + # =================================================================== # YOUTUBE PLAYLIST API ENDPOINTS # =================================================================== @@ -24167,6 +24994,42 @@ def update_youtube_discovery_match(): print(f"โœ… Manual match updated: youtube - {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: + # Get original track name from the YouTube/source track data + original_track = result.get('youtube_track', result.get('tidal_track', result.get('deezer_track', {}))) + original_name = original_track.get('name', spotify_track['name']) + original_artists = original_track.get('artists', []) + if original_artists: + original_artist = original_artists[0] if isinstance(original_artists[0], str) else original_artists[0].get('name', '') + else: + original_artist = '' + + 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}") + # Persist manual fix to DB for mirrored playlists if identifier.startswith('mirrored_'): try: @@ -24175,19 +25038,6 @@ def update_youtube_discovery_match(): db_track_id = tracks[track_index].get('db_track_id') if db_track_id: db = get_database() - artists_list = spotify_track['artists'] - if isinstance(artists_list, list): - artists_list = [{'name': a} if isinstance(a, str) else a 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', - } extra_data = { 'discovered': True, 'provider': 'spotify', @@ -35811,6 +36661,7 @@ def _emit_discovery_progress_loop(): """Push discovery progress to subscribed rooms every 1 second.""" platform_states = { 'tidal': lambda: tidal_discovery_states, + 'deezer': lambda: deezer_discovery_states, 'youtube': lambda: youtube_playlist_states, 'beatport': lambda: beatport_chart_states, 'listenbrainz': lambda: listenbrainz_playlist_states, diff --git a/webui/index.html b/webui/index.html index c3301c33..a51a6010 100644 --- a/webui/index.html +++ b/webui/index.html @@ -936,6 +936,9 @@ + @@ -972,6 +975,18 @@ + +
+
+ + +
+
+
Paste a Deezer playlist URL above to get started.
+
+
+
diff --git a/webui/static/script.js b/webui/static/script.js index fd18414a..8a96f963 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -56,6 +56,8 @@ let activeYouTubePollers = {}; // Key: url_hash, Value: intervalId let tidalPlaylists = []; let tidalPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases let tidalPlaylistsLoaded = false; +let deezerPlaylists = []; +let deezerPlaylistStates = {}; // --- Beatport Chart State Management (Similar to YouTube/Tidal) --- let beatportChartStates = {}; // Key: chart_hash, Value: chart state with phases @@ -14434,6 +14436,8 @@ function openDiscoveryFixModal(platform, identifier, trackIndex) { state = youtubePlaylistStates[identifier]; // Beatport uses YouTube state infrastructure } else if (platform === 'listenbrainz') { state = listenbrainzPlaylistStates[identifier]; // ListenBrainz has its own state + } else if (platform === 'deezer') { + state = youtubePlaylistStates[identifier]; // Deezer uses YouTube state infrastructure } else if (platform === 'mirrored') { state = youtubePlaylistStates[identifier]; // Mirrored playlists use YouTube state infrastructure } @@ -14664,6 +14668,10 @@ async function selectDiscoveryFixTrack(track) { // For Tidal, backend expects the actual playlist_id, not url_hash const state = youtubePlaylistStates[identifier]; backendIdentifier = state?.tidal_playlist_id || identifier; + } else if (platform === 'deezer') { + // For Deezer, backend expects the actual playlist_id, not url_hash + const state = youtubePlaylistStates[identifier]; + backendIdentifier = state?.deezer_playlist_id || identifier; } else if (platform === 'beatport') { // For Beatport, backend expects url_hash (same as identifier) backendIdentifier = identifier; @@ -14716,6 +14724,8 @@ async function selectDiscoveryFixTrack(track) { state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier]; } else if (platform === 'tidal') { state = youtubePlaylistStates[identifier]; + } else if (platform === 'deezer') { + state = youtubePlaylistStates[identifier]; } else if (platform === 'beatport') { state = youtubePlaylistStates[identifier]; } else if (platform === 'listenbrainz') { @@ -14768,6 +14778,26 @@ async function selectDiscoveryFixTrack(track) { } console.log(`โœ… Updated progress: ${state.spotifyMatches}/${spotify_total} (${progress}%)`); + + // Also update the Deezer playlist card if this is a Deezer fix + if (platform === 'deezer' && state.deezer_playlist_id) { + const deezerState = deezerPlaylistStates[state.deezer_playlist_id]; + if (deezerState) { + deezerState.spotifyMatches = state.spotifyMatches; + updateDeezerCardProgress(state.deezer_playlist_id, { + spotify_matches: state.spotifyMatches, + spotify_total: spotify_total + }); + } + } + + // Also update the Tidal playlist card if this is a Tidal fix + if (platform === 'tidal' && state.tidal_playlist_id) { + const tidalState = tidalPlaylistStates?.[state.tidal_playlist_id]; + if (tidalState) { + tidalState.spotifyMatches = state.spotifyMatches; + } + } } // Update UI - refresh the table row @@ -21033,227 +21063,1203 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, } -// =============================== -// SYNC PAGE FUNCTIONALITY (REDESIGNED) -// =============================== - -function initializeSyncPage() { - // Logic for tab switching - const tabButtons = document.querySelectorAll('.sync-tab-button'); - const syncSidebar = document.querySelector('.sync-sidebar'); - const syncContentArea = document.querySelector('.sync-content-area'); - - tabButtons.forEach(button => { - button.addEventListener('click', () => { - const tabId = button.dataset.tab; - - // Update button active state - tabButtons.forEach(btn => btn.classList.remove('active')); - button.classList.add('active'); - - // Update content active state - document.querySelectorAll('.sync-tab-content').forEach(content => { - content.classList.remove('active'); - }); - document.getElementById(`${tabId}-tab-content`).classList.add('active'); - - // Show/hide sidebar based on active tab (skip on mobile where sidebar is always hidden) - if (syncSidebar && syncContentArea) { - const isMobile = window.innerWidth <= 1300; - if (tabId === 'spotify' && !isMobile) { - syncSidebar.style.display = ''; - syncContentArea.style.gridTemplateColumns = '2.5fr 0.75fr'; - } else { - syncSidebar.style.display = 'none'; - syncContentArea.style.gridTemplateColumns = '1fr'; - } - } +// =================================================================== +// DEEZER PLAYLIST MANAGEMENT (URL-input like YouTube, reuses YouTube modal) +// =================================================================== - // Auto-load mirrored playlists on first tab activation - if (tabId === 'mirrored' && !mirroredPlaylistsLoaded) { - loadMirroredPlaylists(); - } - }); - }); +async function loadDeezerPlaylist() { + const urlInput = document.getElementById('deezer-url-input'); + if (!urlInput) return; - // Logic for the Spotify refresh button - const refreshBtn = document.getElementById('spotify-refresh-btn'); - if (refreshBtn) { - // Remove any old listeners to be safe, then add the new one - refreshBtn.removeEventListener('click', loadSpotifyPlaylists); - refreshBtn.addEventListener('click', loadSpotifyPlaylists); + const rawUrl = urlInput.value.trim(); + if (!rawUrl) { + showToast('Please paste a Deezer playlist URL', 'error'); + return; } - // Logic for the Tidal refresh button - const tidalRefreshBtn = document.getElementById('tidal-refresh-btn'); - if (tidalRefreshBtn) { - tidalRefreshBtn.removeEventListener('click', loadTidalPlaylists); - tidalRefreshBtn.addEventListener('click', loadTidalPlaylists); + // Extract playlist ID from URL + // Supports: deezer.com/playlist/{id}, deezer.com/{locale}/playlist/{id}, or raw numeric ID + let playlistId = null; + const urlMatch = rawUrl.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/i); + if (urlMatch) { + playlistId = urlMatch[1]; + } else if (/^\d+$/.test(rawUrl)) { + playlistId = rawUrl; } - // Logic for the Mirrored refresh button - const mirroredRefreshBtn = document.getElementById('mirrored-refresh-btn'); - if (mirroredRefreshBtn) { - mirroredRefreshBtn.addEventListener('click', loadMirroredPlaylists); + if (!playlistId) { + showToast('Invalid Deezer playlist URL. Expected format: deezer.com/playlist/{id}', 'error'); + return; } - // Initialize import file tab - _initImportFileTab(); - - // Logic for the Beatport clear button - const beatportClearBtn = document.getElementById('beatport-clear-btn'); - if (beatportClearBtn) { - beatportClearBtn.addEventListener('click', clearBeatportPlaylists); - // Set initial clear button state - updateBeatportClearButtonState(); + // Check if already loaded + if (deezerPlaylists.find(p => String(p.id) === String(playlistId))) { + showToast('This playlist is already loaded', 'info'); + urlInput.value = ''; + return; } - // Logic for Beatport nested tabs - const beatportTabButtons = document.querySelectorAll('.beatport-tab-button'); - beatportTabButtons.forEach(button => { - button.addEventListener('click', () => { - const tabId = button.dataset.beatportTab; - - // Update button active state - beatportTabButtons.forEach(btn => btn.classList.remove('active')); - button.classList.add('active'); - - // Update content active state - document.querySelectorAll('.beatport-tab-content').forEach(content => { - content.classList.remove('active'); - }); - document.getElementById(`beatport-${tabId}-content`).classList.add('active'); - - // Initialize rebuild slider if rebuild tab is selected - if (tabId === 'rebuild') { - initializeBeatportRebuildSlider(); - loadBeatportTop10Lists(); - loadBeatportTop10Releases(); - initializeBeatportReleasesSlider(); - initializeBeatportHypePicksSlider(); - initializeBeatportChartsSlider(); - initializeBeatportDJSlider(); - } - }); - }); - - // Logic for Homepage Genre Explorer card - const genreExplorerCard = document.querySelector('[data-action="show-genres"]'); - if (genreExplorerCard) { - genreExplorerCard.addEventListener('click', () => { - console.log('๐ŸŽต Genre Explorer card clicked'); - showBeatportSubView('genres'); - loadBeatportGenres(); - }); + const parseBtn = document.getElementById('deezer-parse-btn'); + if (parseBtn) { + parseBtn.disabled = true; + parseBtn.textContent = 'Loading...'; } - // Setup homepage chart handlers (following genre page pattern to prevent duplicates) - setupHomepageChartTypeHandlers(); + try { + const response = await fetch(`/api/deezer/playlist/${playlistId}`); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch Deezer playlist'); + } - // Load homepage chart collections automatically (disabled since Browse Charts tab is hidden) - // loadDJChartsInline(); - // loadFeaturedChartsInline(); + const playlist = await response.json(); + deezerPlaylists.push(playlist); - // Logic for Beatport breadcrumb back buttons - const beatportBackButtons = document.querySelectorAll('.breadcrumb-back'); - beatportBackButtons.forEach(button => { - button.addEventListener('click', () => { - // Handle different back button types - if (button.id === 'genre-detail-back') { - showBeatportGenresView(); - } else if (button.id === 'genre-charts-list-back') { - showBeatportGenreDetailViewFromBack(); - } else { - showBeatportMainView(); - } - }); - }); + // Auto-mirror Deezer playlist + if (playlist.tracks && playlist.tracks.length > 0) { + mirrorPlaylist('deezer', playlist.id, playlist.name, playlist.tracks.map(t => ({ + 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 }); + } - // Logic for Beatport chart items - const beatportChartItems = document.querySelectorAll('.beatport-chart-item'); - beatportChartItems.forEach(item => { - item.addEventListener('click', () => { - const chartType = item.dataset.chartType; - const chartId = item.dataset.chartId; - const chartName = item.dataset.chartName; - const chartEndpoint = item.dataset.chartEndpoint; - handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint); - }); - }); + renderDeezerPlaylists(); + await loadDeezerPlaylistStatesFromBackend(); - // Logic for Beatport genre items - const beatportGenreItems = document.querySelectorAll('.beatport-genre-item'); - beatportGenreItems.forEach(item => { - item.addEventListener('click', () => { - const genreSlug = item.dataset.genreSlug; - const genreId = item.dataset.genreId; - handleBeatportGenreClick(genreSlug, genreId); - }); - }); + urlInput.value = ''; + showToast(`Deezer playlist loaded: ${playlist.name} (${playlist.track_count || playlist.tracks.length} tracks)`, 'success'); + console.log(`๐ŸŽต Loaded Deezer playlist: ${playlist.name}`); - // Logic for Rebuild page Top 10 containers - Beatport Top 10 - const beatportTop10Container = document.getElementById('beatport-top10-list'); - if (beatportTop10Container) { - beatportTop10Container.addEventListener('click', () => { - console.log('๐ŸŽต Beatport Top 10 container clicked on rebuild page'); - handleRebuildBeatportTop10Click(); - }); + } catch (error) { + showToast(`Error loading Deezer playlist: ${error.message}`, 'error'); + } finally { + if (parseBtn) { + parseBtn.disabled = false; + parseBtn.textContent = 'Load Playlist'; + } } +} - // Logic for Rebuild page Top 10 containers - Hype Top 10 - const beatportHype10Container = document.getElementById('beatport-hype10-list'); - if (beatportHype10Container) { - beatportHype10Container.addEventListener('click', () => { - console.log('๐Ÿ”ฅ Hype Top 10 container clicked on rebuild page'); - handleRebuildHypeTop10Click(); - }); +function renderDeezerPlaylists() { + const container = document.getElementById('deezer-playlist-container'); + if (deezerPlaylists.length === 0) { + container.innerHTML = `
Paste a Deezer playlist URL above to get started.
`; + return; } - // Logic for Rebuild page Hero Slider - individual slide click handlers will be set up in populateBeatportSlider - // Container-level click handler removed to allow individual slide clicks like top 10 releases + container.innerHTML = deezerPlaylists.map(p => { + if (!deezerPlaylistStates[p.id]) { + deezerPlaylistStates[p.id] = { + phase: 'fresh', + playlist: p + }; + } + return createDeezerCard(p); + }).join(''); - // Logic for the Start Sync button - const startSyncBtn = document.getElementById('start-sync-btn'); - if (startSyncBtn) { - startSyncBtn.addEventListener('click', startSequentialSync); - } + // Add click handlers to cards + deezerPlaylists.forEach(p => { + const card = document.getElementById(`deezer-card-${p.id}`); + if (card) { + card.addEventListener('click', () => handleDeezerCardClick(p.id)); + } + }); +} - // Logic for the YouTube parse button - const youtubeParseBtn = document.getElementById('youtube-parse-btn'); - if (youtubeParseBtn) { - youtubeParseBtn.addEventListener('click', parseYouTubePlaylist); - } +function createDeezerCard(playlist) { + const state = deezerPlaylistStates[playlist.id]; + const phase = state.phase; - // Logic for YouTube URL input (Enter key support) - const youtubeUrlInput = document.getElementById('youtube-url-input'); - if (youtubeUrlInput) { - youtubeUrlInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - parseYouTubePlaylist(); - } - }); - } + let buttonText = getActionButtonText(phase); + let phaseText = getPhaseText(phase); + let phaseColor = getPhaseColor(phase); - // Logic for Beatport Top 100 button - const beatportTop100Btn = document.getElementById('beatport-top100-btn'); - if (beatportTop100Btn) { - beatportTop100Btn.addEventListener('click', handleBeatportTop100Click); + return ` +
+
๐ŸŽต
+
+
${escapeHtml(playlist.name)}
+
+ ${playlist.track_count || playlist.tracks.length} tracks + ${phaseText} +
+
+
+ +
+ +
+ `; +} + +async function handleDeezerCardClick(playlistId) { + const state = deezerPlaylistStates[playlistId]; + if (!state) { + console.error(`No state found for Deezer playlist: ${playlistId}`); + showToast('Playlist state not found - try refreshing the page', 'error'); + return; } - // Logic for Hype Top 100 button - const hypeTop100Btn = document.getElementById('hype-top100-btn'); - if (hypeTop100Btn) { - hypeTop100Btn.addEventListener('click', handleHypeTop100Click); + if (!state.playlist) { + console.error(`No playlist data found for Deezer playlist: ${playlistId}`); + showToast('Playlist data missing - try refreshing the page', 'error'); + return; } - // Initialize live log viewer - initializeLiveLogViewer(); -} + if (!state.phase) { + state.phase = 'fresh'; + } + console.log(`๐ŸŽต [Card Click] Deezer card clicked: ${playlistId}, Phase: ${state.phase}`); -// --- Event Handlers --- + if (state.phase === 'fresh') { + console.log(`๐ŸŽต Using pre-loaded Deezer playlist data for: ${state.playlist.name}`); + openDeezerDiscoveryModal(playlistId, state.playlist); -// --- Find and REPLACE the existing handleDbUpdateButtonClick function --- + } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { + console.log(`๐ŸŽต [Card Click] Opening Deezer discovery modal for ${state.phase} phase`); + + if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) { + try { + const stateResponse = await fetch(`/api/deezer/state/${playlistId}`); + 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; + deezerPlaylistStates[playlistId] = { ...deezerPlaylistStates[playlistId], ...state }; + console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`); + } + } + } catch (error) { + console.error(`Failed to fetch discovery results from backend: ${error}`); + } + } + + openDeezerDiscoveryModal(playlistId, 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 rehydrateDeezerDownloadModal(playlistId, state); + } + } else { + await rehydrateDeezerDownloadModal(playlistId, state); + } + } else { + if (state.discovery_results && state.discovery_results.length > 0) { + openDeezerDiscoveryModal(playlistId, state.playlist); + } else { + showToast('Unable to open download modal - missing playlist data', 'error'); + } + } + } +} + +async function rehydrateDeezerDownloadModal(playlistId, 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 || `deezer_${playlistId}`; + 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 Deezer download modal: ${error}`); + } +} + +async function openDeezerDiscoveryModal(playlistId, playlistData) { + console.log(`๐ŸŽต Opening Deezer discovery modal (reusing YouTube modal): ${playlistData.name}`); + + const fakeUrlHash = `deezer_${playlistId}`; + + const deezerCardState = deezerPlaylistStates[playlistId]; + const isAlreadyDiscovered = deezerCardState && (deezerCardState.phase === 'discovered' || deezerCardState.phase === 'syncing' || deezerCardState.phase === 'sync_complete'); + const isCurrentlyDiscovering = deezerCardState && deezerCardState.phase === 'discovering'; + + let transformedResults = []; + let actualMatches = 0; + if (isAlreadyDiscovered && deezerCardState.discovery_results) { + transformedResults = deezerCardState.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.deezer_track ? result.deezer_track.name : 'Unknown', + yt_artist: result.deezer_track ? (result.deezer_track.artists ? result.deezer_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(`๐ŸŽต Deezer modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`); + } + + const modalPhase = deezerCardState ? deezerCardState.phase : 'fresh'; + youtubePlaylistStates[fakeUrlHash] = { + phase: modalPhase, + playlist: { + name: playlistData.name, + tracks: playlistData.tracks + }, + is_deezer_playlist: true, + deezer_playlist_id: playlistId, + 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 Deezer discovery for: ${playlistData.name}`); + + const response = await fetch(`/api/deezer/discovery/start/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + console.error('Error starting Deezer discovery:', result.error); + showToast(`Error starting discovery: ${result.error}`, 'error'); + return; + } + + console.log('Deezer discovery started, beginning polling...'); + + deezerPlaylistStates[playlistId].phase = 'discovering'; + updateDeezerCardPhase(playlistId, 'discovering'); + youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; + + startDeezerDiscoveryPolling(fakeUrlHash, playlistId); + + } catch (error) { + console.error('Error starting Deezer discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + } + } else if (isCurrentlyDiscovering) { + console.log(`๐Ÿ”„ Resuming Deezer discovery polling for: ${playlistData.name}`); + startDeezerDiscoveryPolling(fakeUrlHash, playlistId); + } else if (deezerCardState && deezerCardState.phase === 'syncing') { + console.log(`๐Ÿ”„ Resuming Deezer sync polling for: ${playlistData.name}`); + startDeezerSyncPolling(fakeUrlHash); + } else { + console.log('Using existing results - no need to re-discover'); + } + + openYouTubeDiscoveryModal(fakeUrlHash); +} + +function startDeezerDiscoveryPolling(fakeUrlHash, playlistId) { + console.log(`๐Ÿ”„ Starting Deezer discovery polling for: ${playlistId}`); + + if (activeYouTubePollers[fakeUrlHash]) { + clearInterval(activeYouTubePollers[fakeUrlHash]); + } + + // WebSocket subscription + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [playlistId] }); + _discoveryProgressCallbacks[playlistId] = (data) => { + if (data.error) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId]; + 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.deezer_track ? r.deezer_track.name : 'Unknown', + yt_artist: r.deezer_track ? (r.deezer_track.artists ? r.deezer_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 (deezerPlaylistStates[playlistId]) { + deezerPlaylistStates[playlistId].phase = data.phase; + deezerPlaylistStates[playlistId].discovery_results = data.results; + deezerPlaylistStates[playlistId].spotify_matches = data.spotify_matches; + deezerPlaylistStates[playlistId].discovery_progress = data.progress; + updateDeezerCardPhase(playlistId, data.phase); + } + updateDeezerCardProgress(playlistId, data); + if (data.complete) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId]; + } + }; + } + + const pollInterval = setInterval(async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/deezer/discovery/status/${playlistId}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling Deezer 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.deezer_track ? result.deezer_track.name : 'Unknown', + yt_artist: result.deezer_track ? (result.deezer_track.artists ? result.deezer_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 (deezerPlaylistStates[playlistId]) { + deezerPlaylistStates[playlistId].phase = status.phase; + deezerPlaylistStates[playlistId].discovery_results = status.results; + deezerPlaylistStates[playlistId].spotify_matches = status.spotify_matches; + deezerPlaylistStates[playlistId].discovery_progress = status.progress; + updateDeezerCardPhase(playlistId, status.phase); + } + + updateDeezerCardProgress(playlistId, status); + + console.log(`๐Ÿ”„ Deezer discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`); + } + + if (status.complete) { + console.log(`Deezer discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + + } catch (error) { + console.error('Error polling Deezer discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + }, 1000); + + activeYouTubePollers[fakeUrlHash] = pollInterval; +} + +async function loadDeezerPlaylistStatesFromBackend() { + try { + console.log('๐ŸŽต Loading Deezer playlist states from backend...'); + + const response = await fetch('/api/deezer/playlists/states'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch Deezer playlist states'); + } + + const data = await response.json(); + const states = data.states || []; + + console.log(`๐ŸŽต Found ${states.length} stored Deezer playlist states in backend`); + + if (states.length === 0) return; + + for (const stateInfo of states) { + await applyDeezerPlaylistState(stateInfo); + } + + // Rehydrate download modals for Deezer 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 Deezer playlist: ${stateInfo.playlist_id}`); + try { + const playlistData = deezerPlaylists.find(p => String(p.id) === String(stateInfo.playlist_id)); + if (!playlistData) continue; + + const spotifyTracks = deezerPlaylistStates[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 Deezer download modal for ${stateInfo.playlist_id}:`, error); + } + } + } + } + + console.log('Deezer playlist states loaded and applied'); + + } catch (error) { + console.error('Error loading Deezer playlist states:', error); + } +} + +async function applyDeezerPlaylistState(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 Deezer playlist: ${playlist_id}, Phase: ${phase}`); + + const playlistData = deezerPlaylists.find(p => String(p.id) === String(playlist_id)); + if (!playlistData) { + console.warn(`Playlist data not found for state ${playlist_id} - skipping`); + return; + } + + if (!deezerPlaylistStates[playlist_id]) { + deezerPlaylistStates[playlist_id] = { + playlist: playlistData, + phase: 'fresh' + }; + } + + deezerPlaylistStates[playlist_id].phase = phase; + deezerPlaylistStates[playlist_id].discovery_progress = discovery_progress; + deezerPlaylistStates[playlist_id].spotify_matches = spotify_matches; + deezerPlaylistStates[playlist_id].discovery_results = discovery_results; + deezerPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id; + deezerPlaylistStates[playlist_id].download_process_id = download_process_id; + deezerPlaylistStates[playlist_id].playlist = playlistData; + + if (phase !== 'fresh' && phase !== 'discovering') { + try { + const stateResponse = await fetch(`/api/deezer/state/${playlist_id}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results && deezerPlaylistStates[playlist_id]) { + deezerPlaylistStates[playlist_id].discovery_results = fullState.discovery_results; + deezerPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress; + deezerPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches; + deezerPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; + deezerPlaylistStates[playlist_id].download_process_id = fullState.download_process_id; + } + } + } catch (error) { + console.warn(`Error fetching full discovery results for Deezer playlist ${playlistData.name}:`, error.message); + } + } + + updateDeezerCardPhase(playlist_id, phase); + + if (phase === 'discovered' && deezerPlaylistStates[playlist_id]) { + const progressInfo = { + spotify_total: playlistData.track_count || playlistData.tracks?.length || 0, + spotify_matches: deezerPlaylistStates[playlist_id].spotify_matches || 0 + }; + updateDeezerCardProgress(playlist_id, progressInfo); + } + + if (phase === 'discovering') { + const fakeUrlHash = `deezer_${playlist_id}`; + startDeezerDiscoveryPolling(fakeUrlHash, playlist_id); + } else if (phase === 'syncing') { + const fakeUrlHash = `deezer_${playlist_id}`; + startDeezerSyncPolling(fakeUrlHash); + } + + } catch (error) { + console.error(`Error applying Deezer playlist state for ${playlist_id}:`, error); + } +} + +function updateDeezerCardPhase(playlistId, phase) { + const state = deezerPlaylistStates[playlistId]; + if (!state) return; + + state.phase = phase; + + const card = document.getElementById(`deezer-card-${playlistId}`); + if (card) { + const newCardHtml = createDeezerCard(state.playlist); + card.outerHTML = newCardHtml; + + const newCard = document.getElementById(`deezer-card-${playlistId}`); + if (newCard) { + newCard.addEventListener('click', () => handleDeezerCardClick(playlistId)); + } + + if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { + setTimeout(() => { + updateDeezerCardSyncProgress(playlistId, state.lastSyncProgress); + }, 0); + } + } +} + +function updateDeezerCardProgress(playlistId, progress) { + const state = deezerPlaylistStates[playlistId]; + if (!state) return; + + const card = document.getElementById(`deezer-card-${playlistId}`); + 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} +
+ `; + } +} + +// =============================== +// DEEZER SYNC FUNCTIONALITY +// =============================== + +async function startDeezerPlaylistSync(urlHash) { + try { + console.log('๐ŸŽต Starting Deezer playlist sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_deezer_playlist) { + console.error('Invalid Deezer playlist state for sync'); + return; + } + + const playlistId = state.deezer_playlist_id; + const response = await fetch(`/api/deezer/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; + + updateDeezerCardPhase(playlistId, 'syncing'); + updateDeezerModalButtons(urlHash, 'syncing'); + + startDeezerSyncPolling(urlHash, syncPlaylistId); + + showToast('Deezer playlist sync started!', 'success'); + + } catch (error) { + console.error('Error starting Deezer sync:', error); + showToast(`Error starting sync: ${error.message}`, 'error'); + } +} + +function startDeezerSyncPolling(urlHash, syncPlaylistId) { + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + const state = youtubePlaylistStates[urlHash]; + const playlistId = state.deezer_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 || {}; + updateDeezerCardSyncProgress(playlistId, progress); + updateDeezerModalSyncProgress(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 (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateDeezerCardPhase(playlistId, 'sync_complete'); + updateDeezerModalButtons(urlHash, 'sync_complete'); + showToast('Deezer 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 (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateDeezerCardPhase(playlistId, 'discovered'); + updateDeezerModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + const pollFunction = async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/deezer/sync/status/${playlistId}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling Deezer sync status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + updateDeezerCardSyncProgress(playlistId, status.progress); + updateDeezerModalSyncProgress(urlHash, status.progress); + + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateDeezerCardPhase(playlistId, 'sync_complete'); + updateDeezerModalButtons(urlHash, 'sync_complete'); + showToast('Deezer playlist sync complete!', 'success'); + } else if (status.sync_status === 'error') { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateDeezerCardPhase(playlistId, 'discovered'); + updateDeezerModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); + } + } catch (error) { + console.error('Error polling Deezer sync:', error); + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + } + }; + + if (!socketConnected) pollFunction(); + + const pollInterval = setInterval(pollFunction, 1000); + activeYouTubePollers[urlHash] = pollInterval; +} + +async function cancelDeezerSync(urlHash) { + try { + console.log('Cancelling Deezer sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_deezer_playlist) { + console.error('Invalid Deezer playlist state'); + return; + } + + const playlistId = state.deezer_playlist_id; + const response = await fetch(`/api/deezer/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]; + } + + updateDeezerCardPhase(playlistId, 'discovered'); + updateDeezerModalButtons(urlHash, 'discovered'); + + showToast('Deezer sync cancelled', 'info'); + + } catch (error) { + console.error('Error cancelling Deezer sync:', error); + showToast(`Error cancelling sync: ${error.message}`, 'error'); + } +} + +function updateDeezerCardSyncProgress(playlistId, progress) { + const state = deezerPlaylistStates[playlistId]; + if (!state || !state.playlist || !progress) return; + + state.lastSyncProgress = progress; + + const card = document.getElementById(`deezer-card-${playlistId}`); + 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 updateDeezerModalSyncProgress(urlHash, progress) { + const statusDisplay = document.getElementById(`deezer-sync-status-${urlHash}`); + if (!statusDisplay || !progress) return; + + const totalEl = document.getElementById(`deezer-total-${urlHash}`); + const matchedEl = document.getElementById(`deezer-matched-${urlHash}`); + const failedEl = document.getElementById(`deezer-failed-${urlHash}`); + const percentageEl = document.getElementById(`deezer-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 updateDeezerModalButtons(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 startDeezerDownloadMissing(urlHash) { + try { + console.log('๐Ÿ” Starting download missing tracks for Deezer playlist:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_deezer_playlist) { + console.error('Invalid Deezer 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 virtualPlaylistId = `deezer_${state.deezer_playlist_id}`; + const playlistName = state.playlist.name; + + state.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 download missing tracks:', error); + showToast(`Error starting downloads: ${error.message}`, 'error'); + } +} + + +// =============================== +// SYNC PAGE FUNCTIONALITY (REDESIGNED) +// =============================== + +function initializeSyncPage() { + // Logic for tab switching + const tabButtons = document.querySelectorAll('.sync-tab-button'); + const syncSidebar = document.querySelector('.sync-sidebar'); + const syncContentArea = document.querySelector('.sync-content-area'); + + tabButtons.forEach(button => { + button.addEventListener('click', () => { + const tabId = button.dataset.tab; + + // Update button active state + tabButtons.forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + + // Update content active state + document.querySelectorAll('.sync-tab-content').forEach(content => { + content.classList.remove('active'); + }); + document.getElementById(`${tabId}-tab-content`).classList.add('active'); + + // Show/hide sidebar based on active tab (skip on mobile where sidebar is always hidden) + if (syncSidebar && syncContentArea) { + const isMobile = window.innerWidth <= 1300; + if (tabId === 'spotify' && !isMobile) { + syncSidebar.style.display = ''; + syncContentArea.style.gridTemplateColumns = '2.5fr 0.75fr'; + } else { + syncSidebar.style.display = 'none'; + syncContentArea.style.gridTemplateColumns = '1fr'; + } + } + + // Auto-load mirrored playlists on first tab activation + if (tabId === 'mirrored' && !mirroredPlaylistsLoaded) { + loadMirroredPlaylists(); + } + }); + }); + + // Logic for the Spotify refresh button + const refreshBtn = document.getElementById('spotify-refresh-btn'); + if (refreshBtn) { + // Remove any old listeners to be safe, then add the new one + refreshBtn.removeEventListener('click', loadSpotifyPlaylists); + refreshBtn.addEventListener('click', loadSpotifyPlaylists); + } + + // Logic for the Tidal refresh button + const tidalRefreshBtn = document.getElementById('tidal-refresh-btn'); + if (tidalRefreshBtn) { + tidalRefreshBtn.removeEventListener('click', loadTidalPlaylists); + tidalRefreshBtn.addEventListener('click', loadTidalPlaylists); + } + + // Logic for the Deezer parse button + const deezerParseBtn = document.getElementById('deezer-parse-btn'); + if (deezerParseBtn) { + deezerParseBtn.addEventListener('click', loadDeezerPlaylist); + } + // Also allow Enter key in the Deezer input + const deezerUrlInput = document.getElementById('deezer-url-input'); + if (deezerUrlInput) { + deezerUrlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') loadDeezerPlaylist(); + }); + } + + // Logic for the Mirrored refresh button + const mirroredRefreshBtn = document.getElementById('mirrored-refresh-btn'); + if (mirroredRefreshBtn) { + mirroredRefreshBtn.addEventListener('click', loadMirroredPlaylists); + } + + // Initialize import file tab + _initImportFileTab(); + + // Logic for the Beatport clear button + const beatportClearBtn = document.getElementById('beatport-clear-btn'); + if (beatportClearBtn) { + beatportClearBtn.addEventListener('click', clearBeatportPlaylists); + // Set initial clear button state + updateBeatportClearButtonState(); + } + + // Logic for Beatport nested tabs + const beatportTabButtons = document.querySelectorAll('.beatport-tab-button'); + beatportTabButtons.forEach(button => { + button.addEventListener('click', () => { + const tabId = button.dataset.beatportTab; + + // Update button active state + beatportTabButtons.forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + + // Update content active state + document.querySelectorAll('.beatport-tab-content').forEach(content => { + content.classList.remove('active'); + }); + document.getElementById(`beatport-${tabId}-content`).classList.add('active'); + + // Initialize rebuild slider if rebuild tab is selected + if (tabId === 'rebuild') { + initializeBeatportRebuildSlider(); + loadBeatportTop10Lists(); + loadBeatportTop10Releases(); + initializeBeatportReleasesSlider(); + initializeBeatportHypePicksSlider(); + initializeBeatportChartsSlider(); + initializeBeatportDJSlider(); + } + }); + }); + + // Logic for Homepage Genre Explorer card + const genreExplorerCard = document.querySelector('[data-action="show-genres"]'); + if (genreExplorerCard) { + genreExplorerCard.addEventListener('click', () => { + console.log('๐ŸŽต Genre Explorer card clicked'); + showBeatportSubView('genres'); + loadBeatportGenres(); + }); + } + + // Setup homepage chart handlers (following genre page pattern to prevent duplicates) + setupHomepageChartTypeHandlers(); + + // Load homepage chart collections automatically (disabled since Browse Charts tab is hidden) + // loadDJChartsInline(); + // loadFeaturedChartsInline(); + + // Logic for Beatport breadcrumb back buttons + const beatportBackButtons = document.querySelectorAll('.breadcrumb-back'); + beatportBackButtons.forEach(button => { + button.addEventListener('click', () => { + // Handle different back button types + if (button.id === 'genre-detail-back') { + showBeatportGenresView(); + } else if (button.id === 'genre-charts-list-back') { + showBeatportGenreDetailViewFromBack(); + } else { + showBeatportMainView(); + } + }); + }); + + // Logic for Beatport chart items + const beatportChartItems = document.querySelectorAll('.beatport-chart-item'); + beatportChartItems.forEach(item => { + item.addEventListener('click', () => { + const chartType = item.dataset.chartType; + const chartId = item.dataset.chartId; + const chartName = item.dataset.chartName; + const chartEndpoint = item.dataset.chartEndpoint; + handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint); + }); + }); + + // Logic for Beatport genre items + const beatportGenreItems = document.querySelectorAll('.beatport-genre-item'); + beatportGenreItems.forEach(item => { + item.addEventListener('click', () => { + const genreSlug = item.dataset.genreSlug; + const genreId = item.dataset.genreId; + handleBeatportGenreClick(genreSlug, genreId); + }); + }); + + // Logic for Rebuild page Top 10 containers - Beatport Top 10 + const beatportTop10Container = document.getElementById('beatport-top10-list'); + if (beatportTop10Container) { + beatportTop10Container.addEventListener('click', () => { + console.log('๐ŸŽต Beatport Top 10 container clicked on rebuild page'); + handleRebuildBeatportTop10Click(); + }); + } + + // Logic for Rebuild page Top 10 containers - Hype Top 10 + const beatportHype10Container = document.getElementById('beatport-hype10-list'); + if (beatportHype10Container) { + beatportHype10Container.addEventListener('click', () => { + console.log('๐Ÿ”ฅ Hype Top 10 container clicked on rebuild page'); + handleRebuildHypeTop10Click(); + }); + } + + // Logic for Rebuild page Hero Slider - individual slide click handlers will be set up in populateBeatportSlider + // Container-level click handler removed to allow individual slide clicks like top 10 releases + + // Logic for the Start Sync button + const startSyncBtn = document.getElementById('start-sync-btn'); + if (startSyncBtn) { + startSyncBtn.addEventListener('click', startSequentialSync); + } + + // Logic for the YouTube parse button + const youtubeParseBtn = document.getElementById('youtube-parse-btn'); + if (youtubeParseBtn) { + youtubeParseBtn.addEventListener('click', parseYouTubePlaylist); + } + + // Logic for YouTube URL input (Enter key support) + const youtubeUrlInput = document.getElementById('youtube-url-input'); + if (youtubeUrlInput) { + youtubeUrlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + parseYouTubePlaylist(); + } + }); + } + + // Logic for Beatport Top 100 button + const beatportTop100Btn = document.getElementById('beatport-top100-btn'); + if (beatportTop100Btn) { + beatportTop100Btn.addEventListener('click', handleBeatportTop100Click); + } + + // Logic for Hype Top 100 button + const hypeTop100Btn = document.getElementById('hype-top100-btn'); + if (hypeTop100Btn) { + hypeTop100Btn.addEventListener('click', handleHypeTop100Click); + } + + // Initialize live log viewer + initializeLiveLogViewer(); +} + + +// --- Event Handlers --- + +// --- Find and REPLACE the existing handleDbUpdateButtonClick function --- async function handleDbUpdateButtonClick() { const button = document.getElementById('db-update-button'); @@ -24684,6 +25690,8 @@ function openYouTubeDiscoveryModal(urlHash) { console.log('๐Ÿ”„ Resuming sync polling...'); if (state.is_tidal_playlist) { startTidalSyncPolling(urlHash); + } else if (state.is_deezer_playlist) { + startDeezerSyncPolling(urlHash); } else if (state.is_beatport_playlist) { startBeatportSyncPolling(urlHash); } else if (state.is_listenbrainz_playlist) { @@ -24693,17 +25701,20 @@ function openYouTubeDiscoveryModal(urlHash) { } } } else { - // Create new modal (support YouTube, Tidal, Beatport, ListenBrainz, and Mirrored) + // Create new modal (support YouTube, Tidal, Deezer, Beatport, ListenBrainz, and Mirrored) const isTidal = state.is_tidal_playlist; + const isDeezer = state.is_deezer_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' : + 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') : + isDeezer ? 'Deezer' : isTidal ? 'Tidal' : isBeatport ? 'Beatport' : isListenBrainz ? 'LB' : @@ -24715,7 +25726,7 @@ function openYouTubeDiscoveryModal(urlHash) { @@ -24848,6 +25859,8 @@ function openYouTubeDiscoveryModal(urlHash) { console.log('๐Ÿ”„ Modal opened in syncing phase - starting immediate polling...'); if (state.is_tidal_playlist) { startTidalSyncPolling(urlHash); + } else if (state.is_deezer_playlist) { + startDeezerSyncPolling(urlHash); } else if (state.is_beatport_playlist) { startBeatportSyncPolling(urlHash); } else { @@ -24866,6 +25879,7 @@ function getModalActionButtons(urlHash, phase, state = null) { } const isTidal = state && state.is_tidal_playlist; + const isDeezer = state && state.is_deezer_playlist; const isBeatport = state && state.is_beatport_playlist; const isListenBrainz = state && state.is_listenbrainz_playlist; @@ -24883,6 +25897,8 @@ function getModalActionButtons(urlHash, phase, state = null) { return ``; } else if (isTidal) { return ``; + } else if (isDeezer) { + return ``; } else if (isBeatport) { return ``; } else { @@ -24909,6 +25925,8 @@ function getModalActionButtons(urlHash, phase, state = null) { buttons += ``; } else if (isTidal) { buttons += ``; + } else if (isDeezer) { + buttons += ``; } else if (isBeatport) { buttons += ``; } else { @@ -24923,6 +25941,8 @@ function getModalActionButtons(urlHash, phase, state = null) { buttons += ``; } else if (isTidal) { buttons += ``; + } else if (isDeezer) { + buttons += ``; } else if (isBeatport) { buttons += ``; } else { @@ -24970,6 +25990,18 @@ function getModalActionButtons(urlHash, phase, state = null) { (0%)
`; + } else if (isDeezer) { + return ` + +
+ โ™ช 0 + / + โœ“ 0 + / + โœ— 0 + (0%) +
+ `; } else if (isBeatport) { return ` @@ -25043,8 +26075,8 @@ function getModalActionButtons(urlHash, phase, state = null) { } } -function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false) { - const source = isMirrored ? 'mirrored' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'))); +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')))); switch (phase) { case 'fresh': return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`; @@ -25076,10 +26108,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 isBeatport = state.is_beatport_playlist; const isListenBrainz = state.is_listenbrainz_playlist; const isMirrored = state.is_mirrored_playlist; - const platform = isMirrored ? 'mirrored' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube'))); + const platform = isMirrored ? 'mirrored' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube')))); // Support both camelCase and snake_case const discoveryResults = state.discoveryResults || state.discovery_results; @@ -25224,9 +26257,10 @@ function updateYouTubeDiscoveryModal(urlHash, status) { if (actionsCell) { const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; const platform = state?.is_mirrored_playlist ? 'mirrored' : + (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); } }); @@ -25286,13 +26320,44 @@ function closeYouTubeDiscoveryModal(urlHash) { const state = youtubePlaylistStates[urlHash]; if (state) { const isTidal = state.is_tidal_playlist; + const isDeezer = state.is_deezer_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 ${isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')} state after completion`); + console.log(`๐Ÿงน [Modal Close] Resetting ${isDeezer ? 'Deezer' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'))} state after completion`); + + if (isDeezer) { + // Deezer: Extract playlist ID and reset Deezer state + const deezerPlaylistId = state.deezer_playlist_id || null; + if (deezerPlaylistId && deezerPlaylistStates[deezerPlaylistId]) { + 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 + }; - if (isTidal) { + delete deezerPlaylistStates[deezerPlaylistId].download_process_id; + delete deezerPlaylistStates[deezerPlaylistId].phase; + + Object.assign(deezerPlaylistStates[deezerPlaylistId], preservedData); + deezerPlaylistStates[deezerPlaylistId].phase = 'discovered'; + + updateDeezerCardPhase(deezerPlaylistId, 'discovered'); + + try { + fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('Error updating backend Deezer phase:', error); + } + } + } else if (isTidal) { // Tidal: Extract playlist ID and reset Tidal state const tidalPlaylistId = state.tidal_playlist_id || null; if (tidalPlaylistId && tidalPlaylistStates[tidalPlaylistId]) { @@ -25633,6 +26698,7 @@ async function startYouTubeDownloadMissing(urlHash) { const isListenBrainz = state.is_listenbrainz_playlist; const isBeatport = state.is_beatport_playlist; const isTidal = state.is_tidal_playlist; + const isDeezer = state.is_deezer_playlist; // Convert discovery results to a format compatible with the download modal const spotifyTracks = discoveryResults @@ -25668,7 +26734,7 @@ async function startYouTubeDownloadMissing(urlHash) { } // Create a virtual playlist for the download system - const virtualPlaylistId = isListenBrainz ? `listenbrainz_${urlHash}` : (isBeatport ? `beatport_${urlHash}` : (isTidal ? `tidal_${urlHash}` : `youtube_${urlHash}`)); + const virtualPlaylistId = isListenBrainz ? `listenbrainz_${urlHash}` : (isDeezer ? `deezer_${urlHash}` : (isBeatport ? `beatport_${urlHash}` : (isTidal ? `tidal_${urlHash}` : `youtube_${urlHash}`))); const playlistName = state.playlist.name; // Store reference for card navigation diff --git a/webui/static/style.css b/webui/static/style.css index 4186cc1a..d7ae0c46 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -6438,6 +6438,12 @@ body { box-shadow: 0 4px 15px rgba(255, 102, 0, 0.3); } +.sync-tab-button[data-tab="deezer"].active { + background: #a238ff; + color: #fff; + box-shadow: 0 0 12px rgba(162, 56, 255, 0.4); +} + .sync-tab-button[data-tab="youtube"].active { background: #ff0000; color: #fff; @@ -6465,6 +6471,16 @@ body { background-image: url('data:image/svg+xml;charset=utf-8,'); } +.deezer-icon { + display: inline-block; + width: 16px; + height: 16px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Crect x='0' y='18' width='4' height='4' rx='0.5'/%3E%3Crect x='6' y='14' width='4' height='8' rx='0.5'/%3E%3Crect x='12' y='10' width='4' height='12' rx='0.5'/%3E%3Crect x='18' y='6' width='4' height='16' rx='0.5'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + .youtube-icon { background-image: url('data:image/svg+xml;charset=utf-8,'); } @@ -6967,6 +6983,11 @@ body { border-color: rgba(255, 0, 0, 0.25); color: #ff4444; } +.mirrored-playlist-card .source-icon.deezer { + background: linear-gradient(135deg, rgba(162, 56, 255, 0.2) 0%, rgba(162, 56, 255, 0.08) 100%); + border-color: rgba(162, 56, 255, 0.25); + color: #a238ff; +} .mirrored-playlist-card .source-icon.beatport { background: linear-gradient(135deg, rgba(1, 255, 149, 0.2) 0%, rgba(1, 255, 149, 0.08) 100%); border-color: rgba(1, 255, 149, 0.25); @@ -6992,6 +7013,7 @@ body { .mirrored-playlist-card .source-badge.spotify { background: #1db954; } .mirrored-playlist-card .source-badge.tidal { background: #ff6600; } .mirrored-playlist-card .source-badge.youtube { background: #ff0000; } +.mirrored-playlist-card .source-badge.deezer { background: #a238ff; } .mirrored-playlist-card .source-badge.beatport { background: #01ff95; color: #000; } .mirrored-playlist-card .source-badge.file { background: #60a5fa; } @@ -9838,28 +9860,108 @@ body { padding: 40px; } +/* Playlist URL input section (YouTube, Deezer) */ .youtube-input-section { display: flex; - gap: 10px; + align-items: center; + gap: 0; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 4px; + transition: border-color 0.25s ease, box-shadow 0.25s ease; } -#youtube-url-input { - flex-grow: 1; - background: #3a3a3a; - border: 1px solid #555555; - border-radius: 6px; - padding: 10px; - color: #ffffff; +.youtube-input-section:focus-within { + border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 0 20px rgba(0, 0, 0, 0.2); } -#youtube-parse-btn { - width: 150px; - background: #ff0000; - color: #fff; +#youtube-url-input, +#deezer-url-input { + flex: 1; + background: transparent; border: none; - border-radius: 6px; - font-weight: bold; + padding: 12px 16px; + color: rgba(255, 255, 255, 0.9); + font-size: 13.5px; + font-family: 'SF Pro Text', -apple-system, sans-serif; + outline: none; + min-width: 0; +} + +#youtube-url-input::placeholder, +#deezer-url-input::placeholder { + color: rgba(255, 255, 255, 0.3); + font-weight: 400; +} + +#youtube-parse-btn, +#deezer-parse-btn { + flex-shrink: 0; + padding: 10px 22px; + border: none; + border-radius: 9px; + font-size: 12.5px; + font-weight: 600; + font-family: 'SF Pro Text', -apple-system, sans-serif; cursor: pointer; + transition: all 0.2s ease; + letter-spacing: 0.2px; + white-space: nowrap; +} + +#youtube-parse-btn { + background: linear-gradient(135deg, #ff2020, #e00000); + color: #fff; + box-shadow: 0 2px 8px rgba(255, 0, 0, 0.2); +} + +#youtube-parse-btn:hover { + background: linear-gradient(135deg, #ff3333, #ff1111); + box-shadow: 0 4px 16px rgba(255, 0, 0, 0.3); + transform: translateY(-1px); +} + +#youtube-parse-btn:active { + transform: translateY(0); + box-shadow: 0 1px 4px rgba(255, 0, 0, 0.2); +} + +#deezer-parse-btn { + background: linear-gradient(135deg, #a238ff, #b44dff); + color: #fff; + box-shadow: 0 2px 8px rgba(162, 56, 255, 0.2); +} + +#deezer-parse-btn:hover { + background: linear-gradient(135deg, #b044ff, #c058ff); + box-shadow: 0 4px 16px rgba(162, 56, 255, 0.3); + transform: translateY(-1px); +} + +#deezer-parse-btn:active { + transform: translateY(0); + box-shadow: 0 1px 4px rgba(162, 56, 255, 0.2); +} + +#youtube-parse-btn:disabled, +#deezer-parse-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Brand-colored focus glow per tab */ +#youtube-tab-content .youtube-input-section:focus-within { + border-color: rgba(255, 0, 0, 0.25); + box-shadow: 0 0 16px rgba(255, 0, 0, 0.08); +} + +#deezer-tab-content .youtube-input-section:focus-within { + border-color: rgba(162, 56, 255, 0.25); + box-shadow: 0 0 16px rgba(162, 56, 255, 0.08); } /* Right Sidebar */ @@ -10296,6 +10398,25 @@ body { box-shadow: 0 4px 15px rgba(255, 102, 0, 0.3); } +/* =============================== + DEEZER PLAYLIST CARD STYLES (extends YouTube card styles) + ===============================*/ + +.deezer-playlist-card .playlist-card-icon { + background: rgba(162, 56, 255, 0.2); + border-color: #a238ff; + color: #a238ff; +} + +.deezer-playlist-card .playlist-card-action-btn { + background: linear-gradient(135deg, #a238ff, #b44dff); +} + +.deezer-playlist-card .playlist-card-action-btn:hover { + background: linear-gradient(135deg, #b44dff, #c562ff); + box-shadow: 0 0 15px rgba(162, 56, 255, 0.4); +} + /* =============================== YOUTUBE DISCOVERY MODAL STYLES =============================== */