diff --git a/web_server.py b/web_server.py index bbd1c0ca..77cf8151 100644 --- a/web_server.py +++ b/web_server.py @@ -5538,6 +5538,320 @@ def get_playlist_tracks(playlist_id): return jsonify({"error": str(e)}), 500 +# =================================================================== +# TIDAL PLAYLIST API ENDPOINTS +# =================================================================== + +@app.route('/api/tidal/playlists', methods=['GET']) +def get_tidal_playlists(): + """Fetches all user playlists from Tidal with full track data (like sync.py).""" + if not tidal_client or not tidal_client.is_authenticated(): + return jsonify({"error": "Tidal not authenticated."}), 401 + try: + # Use same method as sync.py - this already includes all track data + playlists = tidal_client.get_user_playlists_metadata_only() + + playlist_data = [] + for p in playlists: + # Get track count from actual tracks if available + track_count = len(p.tracks) if hasattr(p, 'tracks') and p.tracks else 0 + + playlist_dict = { + "id": p.id, + "name": p.name, + "owner": getattr(p, 'owner', 'Unknown'), + "track_count": track_count, + "image_url": getattr(p, 'image_url', None), + "description": getattr(p, 'description', ''), + "tracks": [] # Add tracks data like sync.py + } + + # Include full track data if available (like sync.py has) + if hasattr(p, 'tracks') and p.tracks: + playlist_dict['tracks'] = [{ + 'id': t.id, + 'name': t.name, + 'artists': t.artists or [], + 'album': getattr(t, 'album', 'Unknown Album'), + 'duration_ms': getattr(t, 'duration_ms', 0), + 'track_number': getattr(t, 'track_number', 0) + } for t in p.tracks] + + playlist_data.append(playlist_dict) + + print(f"🎵 Loaded {len(playlist_data)} Tidal playlists with track data") + return jsonify(playlist_data) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@app.route('/api/tidal/playlist/', methods=['GET']) +def get_tidal_playlist_tracks(playlist_id): + """Fetches full track details for a specific Tidal playlist (matches sync.py pattern).""" + if not tidal_client or not tidal_client.is_authenticated(): + return jsonify({"error": "Tidal not authenticated."}), 401 + try: + print(f"🎵 Getting full Tidal playlist with tracks for: {playlist_id}") + + # First check if this playlist exists in metadata list + try: + metadata_playlists = tidal_client.get_user_playlists_metadata_only() + target_playlist = None + for p in metadata_playlists: + if p.id == playlist_id: + target_playlist = p + break + + if not target_playlist: + print(f"❌ Playlist {playlist_id} not found in user's Tidal playlists") + return jsonify({"error": "Playlist not found in your Tidal library"}), 404 + + print(f"🎵 Found playlist in metadata: {target_playlist.name}") + except Exception as e: + print(f"❌ Error checking playlist metadata: {e}") + + # Use same method as sync.py: tidal_client.get_playlist(playlist_id) + full_playlist = tidal_client.get_playlist(playlist_id) + if not full_playlist: + return jsonify({"error": "Unable to access this Tidal playlist. This may be due to privacy settings or Tidal API restrictions. Please try a different playlist."}), 403 + + if not full_playlist.tracks: + return jsonify({"error": "This playlist appears to have no tracks or they cannot be accessed"}), 403 + + print(f"🎵 Loaded {len(full_playlist.tracks)} tracks from Tidal playlist: {full_playlist.name}") + + # Convert playlist to dict (matches sync.py structure) + playlist_dict = { + 'id': full_playlist.id, + 'name': full_playlist.name, + 'description': getattr(full_playlist, 'description', ''), + 'owner': getattr(full_playlist, 'owner', 'Unknown'), + 'track_count': len(full_playlist.tracks), + 'image_url': getattr(full_playlist, 'image_url', None), + 'tracks': [] + } + + # Convert tracks to dict format (for discovery modal) + playlist_dict['tracks'] = [{ + 'id': t.id, + 'name': t.name, + 'artists': t.artists or [], + 'album': getattr(t, 'album', 'Unknown Album'), + 'duration_ms': getattr(t, 'duration_ms', 0), + 'track_number': getattr(t, 'track_number', 0) + } for t in full_playlist.tracks] + + return jsonify(playlist_dict) + except Exception as e: + print(f"❌ Error getting Tidal playlist tracks: {e}") + return jsonify({"error": str(e)}), 500 + + +# =================================================================== +# TIDAL DISCOVERY API ENDPOINTS +# =================================================================== + +# Global state for Tidal playlist discovery management +tidal_discovery_states = {} # Key: playlist_id, Value: discovery state +tidal_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="tidal_discovery") + +@app.route('/api/tidal/discovery/start/', methods=['POST']) +def start_tidal_discovery(playlist_id): + """Start Spotify discovery process for a Tidal playlist""" + try: + # Get playlist data from the initial load + if not tidal_client or not tidal_client.is_authenticated(): + return jsonify({"error": "Tidal not authenticated."}), 401 + + # Get playlist from tidal client + playlists = tidal_client.get_user_playlists_metadata_only() + target_playlist = None + for p in playlists: + if p.id == playlist_id: + target_playlist = p + break + + if not target_playlist: + return jsonify({"error": "Tidal playlist not found"}), 404 + + if not target_playlist.tracks: + return jsonify({"error": "Playlist has no tracks"}), 400 + + # Initialize or update discovery state + if playlist_id in tidal_discovery_states and tidal_discovery_states[playlist_id]['phase'] == 'discovering': + return jsonify({"error": "Discovery already in progress"}), 400 + + state = { + 'playlist': target_playlist, + 'phase': 'discovering', + 'status': 'discovering', + 'discovery_progress': 0, + 'spotify_matches': 0, + 'spotify_total': len(target_playlist.tracks), + 'discovery_results': [], + 'last_accessed': time.time() + } + + tidal_discovery_states[playlist_id] = state + + # Start discovery worker + future = tidal_discovery_executor.submit(_run_tidal_discovery_worker, playlist_id) + state['discovery_future'] = future + + print(f"🔍 Started Spotify discovery for Tidal playlist: {target_playlist.name}") + return jsonify({"success": True, "message": "Discovery started"}) + + except Exception as e: + print(f"❌ Error starting Tidal discovery: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/tidal/discovery/status/', methods=['GET']) +def get_tidal_discovery_status(playlist_id): + """Get real-time discovery status for a Tidal playlist""" + try: + if playlist_id not in tidal_discovery_states: + return jsonify({"error": "Tidal discovery not found"}), 404 + + state = tidal_discovery_states[playlist_id] + state['last_accessed'] = time.time() # Update access 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 Tidal discovery status: {e}") + return jsonify({"error": str(e)}), 500 + + +def _run_tidal_discovery_worker(playlist_id): + """Background worker for Tidal Spotify discovery process (like sync.py)""" + try: + state = tidal_discovery_states[playlist_id] + playlist = state['playlist'] + + print(f"🎵 Starting Tidal Spotify discovery for: {playlist.name}") + + # Import matching engine for validation (like sync.py) + from core.matching_engine import MusicMatchingEngine + matching_engine = MusicMatchingEngine() + + successful_discoveries = 0 + + for i, tidal_track in enumerate(playlist.tracks): + if state.get('cancelled', False): + break + + try: + print(f"🔍 [{i+1}/{len(playlist.tracks)}] Searching: {tidal_track.name} by {', '.join(tidal_track.artists)}") + + # Use the same search logic as sync.py TidalSpotifyDiscoveryWorker + spotify_track = _search_spotify_for_tidal_track(tidal_track) + + # Create result entry + result = { + 'tidal_track': { + 'id': tidal_track.id, + 'name': tidal_track.name, + 'artists': tidal_track.artists or [], + 'album': getattr(tidal_track, 'album', 'Unknown Album'), + 'duration_ms': getattr(tidal_track, 'duration_ms', 0), + }, + 'spotify_data': None, + 'status': 'not_found' + } + + if spotify_track: + result['spotify_data'] = { + 'id': spotify_track.id, + 'name': spotify_track.name, + 'artists': spotify_track.artists, # Already a list of strings + 'album': spotify_track.album, # Already a string + 'duration_ms': spotify_track.duration_ms, + 'external_urls': spotify_track.external_urls + } + result['status'] = 'found' + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100) + + # Add delay between requests (like sync.py) + time.sleep(0.1) + + except Exception as e: + print(f"❌ Error processing track {i+1}: {e}") + # Add error result + result = { + 'tidal_track': { + 'name': tidal_track.name, + 'artists': tidal_track.artists or [], + }, + 'spotify_data': None, + 'status': 'error', + 'error': str(e) + } + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100) + + # Mark as complete + state['phase'] = 'discovered' + state['status'] = 'discovered' + state['discovery_progress'] = 100 + + print(f"✅ Tidal discovery complete: {successful_discoveries}/{len(playlist.tracks)} tracks found") + + except Exception as e: + print(f"❌ Error in Tidal discovery worker: {e}") + state['phase'] = 'error' + state['status'] = f'error: {str(e)}' + + +def _search_spotify_for_tidal_track(tidal_track): + """Search Spotify for a Tidal track (simplified version of sync.py logic)""" + if not spotify_client or not spotify_client.is_authenticated(): + return None + + try: + # Construct search query like sync.py does + track_name = tidal_track.name + artists = tidal_track.artists or [] + + if not artists: + return None + + # Try different search combinations (like sync.py TidalSpotifyDiscoveryWorker) + search_queries = [ + f'track:"{track_name}" artist:"{artists[0]}"', + f'"{track_name}" "{artists[0]}"', + f'{track_name} {artists[0]}' + ] + + for query in search_queries: + try: + results = spotify_client.search_tracks(query, limit=5) + if results and len(results) > 0: + # Return first match (could add matching logic like sync.py) + return results[0] + except Exception as e: + print(f"❌ Search error for query '{query}': {e}") + continue + + return None + + except Exception as e: + print(f"❌ Error searching Spotify for Tidal track: {e}") + return None + + # =================================================================== # YOUTUBE PLAYLIST API ENDPOINTS # =================================================================== diff --git a/webui/static/script.js b/webui/static/script.js index cb6d5e09..59fb2d58 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -42,6 +42,11 @@ let sequentialSyncManager = null; let youtubePlaylistStates = {}; // Key: url_hash, Value: playlist state let activeYouTubePollers = {}; // Key: url_hash, Value: intervalId +// --- Tidal Playlist State Management (Similar to YouTube but loads from API like Spotify) --- +let tidalPlaylists = []; +let tidalPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases +let tidalPlaylistsLoaded = false; + // --- Wishlist Modal Persistence State Management --- const WishlistModalState = { // Track if wishlist modal was visible before page refresh @@ -6321,6 +6326,331 @@ function updateDbProgressUI(state) { } } +// =================================================================== +// TIDAL PLAYLIST MANAGEMENT (YouTube-style cards with Tidal colors) +// =================================================================== + +async function loadTidalPlaylists() { + const container = document.getElementById('tidal-playlist-container'); + const refreshBtn = document.getElementById('tidal-refresh-btn'); + + container.innerHTML = `
🔄 Loading Tidal playlists...
`; + refreshBtn.disabled = true; + refreshBtn.textContent = '🔄 Loading...'; + + try { + const response = await fetch('/api/tidal/playlists'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch Tidal playlists'); + } + + tidalPlaylists = await response.json(); + renderTidalPlaylists(); + tidalPlaylistsLoaded = true; + + console.log(`🎵 Loaded ${tidalPlaylists.length} Tidal playlists`); + + } catch (error) { + container.innerHTML = `
❌ Error: ${error.message}
`; + showToast(`Error loading Tidal playlists: ${error.message}`, 'error'); + } finally { + refreshBtn.disabled = false; + refreshBtn.textContent = '🔄 Refresh'; + } +} + +function renderTidalPlaylists() { + const container = document.getElementById('tidal-playlist-container'); + if (tidalPlaylists.length === 0) { + container.innerHTML = `
No Tidal playlists found.
`; + return; + } + + container.innerHTML = tidalPlaylists.map(p => { + // Initialize state if not exists (fresh state like sync.py) + if (!tidalPlaylistStates[p.id]) { + tidalPlaylistStates[p.id] = { + phase: 'fresh', + playlist: p + }; + } + + return createTidalCard(p); + }).join(''); + + // Add click handlers to cards + tidalPlaylists.forEach(p => { + const card = document.getElementById(`tidal-card-${p.id}`); + if (card) { + card.addEventListener('click', () => handleTidalCardClick(p.id)); + } + }); +} + +function createTidalCard(playlist) { + const state = tidalPlaylistStates[playlist.id]; + const phase = state.phase; + + // Get phase-specific button text (like YouTube cards) + let buttonText = 'Start Discovery'; + let phaseText = 'Ready to discover'; + let phaseColor = '#999'; + + if (phase === 'discovering') { + buttonText = 'View Progress'; + phaseText = 'Discovering...'; + phaseColor = '#ff6600'; + } else if (phase === 'discovered') { + buttonText = 'View Details'; + phaseText = 'Discovery Complete'; + phaseColor = '#1db954'; + } + + return ` +
+
🎵
+
+
${escapeHtml(playlist.name)}
+
+ ${playlist.track_count} tracks + ${phaseText} +
+
+ +
+ `; +} + +async function handleTidalCardClick(playlistId) { + const state = tidalPlaylistStates[playlistId]; + if (!state) return; + + console.log(`🎵 Tidal card clicked: ${playlistId}, Phase: ${state.phase}`); + + if (state.phase === 'fresh') { + // No need to fetch data - we already have all tracks from initial load (like sync.py) + console.log(`🎵 Using pre-loaded Tidal playlist data for: ${state.playlist.name}`); + console.log(`🎵 Ready with ${state.playlist.tracks.length} Tidal tracks for discovery`); + + // Update phase to discovering + state.phase = 'discovering'; + + // Update card to show discovering state + updateTidalCardPhase(playlistId, 'discovering'); + + // Open YouTube discovery modal but with Tidal data (exact sync.py pattern) + openTidalDiscoveryModal(playlistId, state.playlist); + + } else if (state.phase === 'discovering' || state.phase === 'discovered') { + // Reopen existing modal (like sync.py) + openTidalDiscoveryModal(playlistId, state.playlist); + } +} + +function updateTidalCardPhase(playlistId, phase) { + const state = tidalPlaylistStates[playlistId]; + if (!state) return; + + state.phase = phase; + + // Re-render the card with new phase + const card = document.getElementById(`tidal-card-${playlistId}`); + if (card) { + const newCardHtml = createTidalCard(state.playlist); + card.outerHTML = newCardHtml; + + // Re-attach click handler + const newCard = document.getElementById(`tidal-card-${playlistId}`); + if (newCard) { + newCard.addEventListener('click', () => handleTidalCardClick(playlistId)); + } + } + + console.log(`🎵 Updated Tidal card phase: ${playlistId} -> ${phase}`); +} + +async function openTidalDiscoveryModal(playlistId, playlistData) { + console.log(`🎵 Opening Tidal discovery modal (reusing YouTube modal): ${playlistData.name}`); + + // Create a fake YouTube-style urlHash for the modal system + const fakeUrlHash = `tidal_${playlistId}`; + + // Get current Tidal card state to check if discovery is already done + const tidalCardState = tidalPlaylistStates[playlistId]; + const isAlreadyDiscovered = tidalCardState && tidalCardState.phase === 'discovered'; + + // Prepare discovery results in the correct format for modal + let transformedResults = []; + if (isAlreadyDiscovered && tidalCardState.discovery_results) { + transformedResults = tidalCardState.discovery_results.map((result, index) => ({ + index: index, + yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown', + yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: result.status === 'found' ? '✅ Found' : '❌ Not Found', + status_class: result.status === 'found' ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : '-', + spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-', + spotify_album: result.spotify_data ? result.spotify_data.album : '-' + })); + } + + // Create YouTube-compatible state structure + youtubePlaylistStates[fakeUrlHash] = { + phase: isAlreadyDiscovered ? 'discovered' : 'discovering', + playlist: { + name: playlistData.name, + tracks: playlistData.tracks + }, + is_tidal_playlist: true, // Flag to identify this as Tidal + tidal_playlist_id: playlistId, + discovery_progress: isAlreadyDiscovered ? (tidalCardState.discovery_progress || 100) : 0, + spotify_matches: isAlreadyDiscovered ? (tidalCardState.spotify_matches || 0) : 0, + spotify_total: playlistData.tracks.length, + discovery_results: transformedResults, + discoveryResults: transformedResults // Both formats for compatibility + }; + + // Only start discovery if not already discovered + if (!isAlreadyDiscovered) { + // Start Tidal discovery process automatically (like sync.py) + try { + console.log(`🔍 Starting Tidal discovery for: ${playlistData.name}`); + + const response = await fetch(`/api/tidal/discovery/start/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + console.error('❌ Error starting Tidal discovery:', result.error); + showToast(`Error starting discovery: ${result.error}`, 'error'); + return; + } + + console.log('✅ Tidal discovery started, beginning polling...'); + + // Start polling for progress + startTidalDiscoveryPolling(fakeUrlHash, playlistId); + + } catch (error) { + console.error('❌ Error starting Tidal discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + } + } else { + console.log('✅ Using existing discovery results - no need to re-discover'); + } + + // Reuse YouTube discovery modal (exact sync.py pattern) + openYouTubeDiscoveryModal(fakeUrlHash); +} + +function startTidalDiscoveryPolling(fakeUrlHash, playlistId) { + console.log(`🔄 Starting Tidal discovery polling for: ${playlistId}`); + + // Stop any existing polling + if (activeYouTubePollers[fakeUrlHash]) { + clearInterval(activeYouTubePollers[fakeUrlHash]); + } + + const pollInterval = setInterval(async () => { + try { + const response = await fetch(`/api/tidal/discovery/status/${playlistId}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling Tidal discovery status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + return; + } + + // Update fake YouTube state with Tidal discovery results + const state = youtubePlaylistStates[fakeUrlHash]; + if (state) { + state.discovery_progress = status.progress; + state.spotify_matches = status.spotify_matches; + state.discovery_results = status.results; + state.phase = status.phase; + + // Transform Tidal results to YouTube modal format + const transformedStatus = { + progress: status.progress, + spotify_matches: status.spotify_matches, + spotify_total: status.spotify_total, + results: status.results.map((result, index) => ({ + index: index, + status: result.status === 'found' ? '✅ Found' : '❌ Not Found', + status_class: result.status === 'found' ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : '-', + spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-', + spotify_album: result.spotify_data ? result.spotify_data.album : '-' + // Note: No duration column for Tidal (matches GUI version) + })) + }; + + // Update modal with transformed data (reuse YouTube modal update logic) + updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus); + + // Update Tidal card phase and save discovery results + if (tidalPlaylistStates[playlistId]) { + tidalPlaylistStates[playlistId].phase = status.phase; + tidalPlaylistStates[playlistId].discovery_results = status.results; + tidalPlaylistStates[playlistId].spotify_matches = status.spotify_matches; + tidalPlaylistStates[playlistId].discovery_progress = status.progress; + updateTidalCardPhase(playlistId, status.phase); + } + + console.log(`🔄 Tidal discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`); + } + + // Stop polling when complete + if (status.complete) { + console.log(`✅ Tidal discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + + } catch (error) { + console.error('❌ Error polling Tidal discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + }, 1000); // Poll every second like YouTube + + // Store poller reference (reuse YouTube poller storage) + activeYouTubePollers[fakeUrlHash] = pollInterval; +} + +// Tidal-specific sync and download functions (placeholder implementations) +function startTidalPlaylistSync(urlHash) { + console.log(`🎵 Starting Tidal playlist sync for: ${urlHash}`); + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_tidal_playlist) { + console.error('❌ Invalid Tidal playlist state for sync'); + return; + } + + // TODO: Implement Tidal playlist sync logic + // For now, show a message that this feature is coming soon + showToast('🔄 Tidal playlist sync functionality coming soon!', 'info'); +} + +function startTidalDownloadMissing(urlHash) { + console.log(`🎵 Starting Tidal download missing tracks for: ${urlHash}`); + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_tidal_playlist) { + console.error('❌ Invalid Tidal playlist state for download'); + return; + } + + // TODO: Implement Tidal download missing tracks logic + // For now, show a message that this feature is coming soon + showToast('🔍 Tidal download missing tracks functionality coming soon!', 'info'); +} + + // =============================== // SYNC PAGE FUNCTIONALITY (REDESIGNED) // =============================== @@ -6352,6 +6682,13 @@ function initializeSyncPage() { 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 Start Sync button const startSyncBtn = document.getElementById('start-sync-btn'); if (startSyncBtn) { @@ -7013,14 +7350,18 @@ function openYouTubeDiscoveryModal(urlHash) { startYouTubeDiscoveryPolling(urlHash); } } else { - // Create new modal + // Create new modal (support both YouTube and Tidal like sync.py) + const isTidal = state.is_tidal_playlist; + const modalTitle = isTidal ? '🎵 Tidal Playlist Discovery' : '🎵 YouTube Playlist Discovery'; + const sourceLabel = isTidal ? 'Tidal' : 'YT'; + const modalHtml = `