diff --git a/core/listenbrainz_client.py b/core/listenbrainz_client.py index e60a11e7..389496f7 100644 --- a/core/listenbrainz_client.py +++ b/core/listenbrainz_client.py @@ -2,6 +2,7 @@ import requests from typing import Dict, List, Optional, Any from utils.logging_config import get_logger from config.settings import config_manager +import time logger = get_logger("listenbrainz_client") @@ -13,18 +14,49 @@ class ListenBrainzClient: self.token = config_manager.get("listenbrainz.token", "") self.username = None + # Create a session for connection pooling + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': 'SoulSync/1.0' + }) + if self.token: # Validate token and get username self._validate_and_get_username() + def _make_request_with_retry(self, method: str, url: str, max_retries: int = 3, **kwargs): + """Make HTTP request with retry logic""" + for attempt in range(max_retries): + try: + if method.lower() == 'get': + response = self.session.get(url, **kwargs) + elif method.lower() == 'post': + response = self.session.post(url, **kwargs) + else: + response = self.session.request(method, url, **kwargs) + + return response + except (requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + ConnectionResetError) as e: + if attempt < max_retries - 1: + wait_time = (attempt + 1) * 2 # Exponential backoff + logger.warning(f"Connection error (attempt {attempt + 1}/{max_retries}), retrying in {wait_time}s: {e}") + time.sleep(wait_time) + else: + logger.error(f"Failed after {max_retries} attempts: {e}") + raise + + return None + def _validate_and_get_username(self): """Validate token and retrieve username""" try: url = f"{self.base_url}/validate-token" headers = {'Authorization': f'Token {self.token}'} - response = requests.get(url, headers=headers, timeout=5) + response = self._make_request_with_retry('get', url, headers=headers, timeout=10) - if response.status_code == 200: + if response and response.status_code == 200: data = response.json() if data.get('valid'): self.username = data.get('user_name') @@ -57,18 +89,19 @@ class ListenBrainzClient: 'offset': offset } - response = requests.get(url, params=params, timeout=10) + response = self._make_request_with_retry('get', url, params=params, timeout=15) - if response.status_code == 200: + if response and response.status_code == 200: data = response.json() playlists = data.get('playlists', []) logger.info(f"📋 Fetched {len(playlists)} playlists created for {self.username}") return playlists - elif response.status_code == 404: + elif response and response.status_code == 404: logger.warning(f"User {self.username} not found") return [] else: - logger.error(f"Failed to fetch created-for playlists: {response.status_code}") + status = response.status_code if response else 'No response' + logger.error(f"Failed to fetch created-for playlists: {status}") return [] except Exception as e: @@ -92,18 +125,19 @@ class ListenBrainzClient: 'offset': offset } - response = requests.get(url, headers=headers, params=params, timeout=10) + response = self._make_request_with_retry('get', url, headers=headers, params=params, timeout=15) - if response.status_code == 200: + if response and response.status_code == 200: data = response.json() playlists = data.get('playlists', []) logger.info(f"📋 Fetched {len(playlists)} user playlists for {self.username}") return playlists - elif response.status_code == 404: + elif response and response.status_code == 404: logger.warning(f"User {self.username} not found") return [] else: - logger.error(f"Failed to fetch user playlists: {response.status_code}") + status = response.status_code if response else 'No response' + logger.error(f"Failed to fetch user playlists: {status}") return [] except Exception as e: @@ -127,18 +161,19 @@ class ListenBrainzClient: 'offset': offset } - response = requests.get(url, headers=headers, params=params, timeout=10) + response = self._make_request_with_retry('get', url, headers=headers, params=params, timeout=15) - if response.status_code == 200: + if response and response.status_code == 200: data = response.json() playlists = data.get('playlists', []) logger.info(f"📋 Fetched {len(playlists)} collaborative playlists for {self.username}") return playlists - elif response.status_code == 404: + elif response and response.status_code == 404: logger.warning(f"User {self.username} not found") return [] else: - logger.error(f"Failed to fetch collaborative playlists: {response.status_code}") + status = response.status_code if response else 'No response' + logger.error(f"Failed to fetch collaborative playlists: {status}") return [] except Exception as e: @@ -165,22 +200,23 @@ class ListenBrainzClient: if self.token: headers['Authorization'] = f'Token {self.token}' - response = requests.get(url, headers=headers, params=params, timeout=10) + response = self._make_request_with_retry('get', url, headers=headers, params=params, timeout=20) - if response.status_code == 200: + if response and response.status_code == 200: data = response.json() playlist = data.get('playlist', {}) track_count = len(playlist.get('track', [])) logger.info(f"📋 Fetched playlist '{playlist.get('title')}' with {track_count} tracks") return playlist - elif response.status_code == 404: + elif response and response.status_code == 404: logger.warning(f"Playlist {playlist_mbid} not found") return None - elif response.status_code == 401: + elif response and response.status_code == 401: logger.warning(f"Unauthorized to access playlist {playlist_mbid}") return None else: - logger.error(f"Failed to fetch playlist: {response.status_code}") + status = response.status_code if response else 'No response' + logger.error(f"Failed to fetch playlist: {status}") return None except Exception as e: diff --git a/web_server.py b/web_server.py index 1cfbb1ce..7b0f4c6f 100644 --- a/web_server.py +++ b/web_server.py @@ -15300,12 +15300,18 @@ def get_listenbrainz_playlist_tracks(playlist_mbid): extension = track.get('extension', {}) mb_data = extension.get('https://musicbrainz.org/doc/jspf#track', {}) + # Extract album cover from extension if available + album_cover_url = None + if mb_data and 'album_cover_url' in mb_data: + album_cover_url = mb_data['album_cover_url'] + track_data = { - 'title': track.get('title', 'Unknown Track'), - 'creator': track.get('creator', 'Unknown Artist'), - 'album': track.get('album', 'Unknown Album'), + 'track_name': track.get('title', 'Unknown Track'), + 'artist_name': track.get('creator', 'Unknown Artist'), + 'album_name': track.get('album', 'Unknown Album'), 'duration_ms': track.get('duration', 0), - 'recording_mbid': recording_mbid, + 'mbid': recording_mbid, + 'album_cover_url': album_cover_url, 'additional_metadata': mb_data } @@ -15313,13 +15319,8 @@ def get_listenbrainz_playlist_tracks(playlist_mbid): return jsonify({ "success": True, - "playlist": { - "identifier": playlist.get('identifier'), - "title": playlist.get('title'), - "creator": playlist.get('creator'), - "tracks": tracks, - "track_count": len(tracks) - } + "tracks": tracks, + "track_count": len(tracks) }) except Exception as e: diff --git a/webui/index.html b/webui/index.html index c36c2061..32bd46cb 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2134,36 +2134,21 @@ - +
-

🧠 ListenBrainz Recommendations

-

Playlists curated for you by ListenBrainz

+

🧠 ListenBrainz Playlists

+

Playlists from ListenBrainz

- -
- -
-
-

📚 Your ListenBrainz Playlists

-

Your personal playlists from ListenBrainz

+ +
+

Loading playlists...

- -
- -
-
-

🤝 Collaborative Playlists

-

Playlists you collaborate on

-
- diff --git a/webui/static/script.js b/webui/static/script.js index eec13c27..5a4516ca 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -24744,9 +24744,7 @@ async function loadDiscoverPage() { loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites loadDiscoveryShuffle(), // NEW: Discovery Shuffle loadFamiliarFavorites(), // NEW: Familiar Favorites - loadListenBrainzCreatedFor(), // ListenBrainz recommendations - loadListenBrainzUserPlaylists(), // ListenBrainz user playlists - loadListenBrainzCollaborative(), // ListenBrainz collaborative playlists + initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed) loadDecadeBrowserTabs(), // Time Machine (tabbed by decade) loadGenreBrowserTabs() // Browse by Genre (tabbed by genre) ]); @@ -26267,193 +26265,474 @@ async function openDownloadModalForGenre(genreName) { // LISTENBRAINZ PLAYLISTS // =============================== -async function loadListenBrainzCreatedFor() { - try { - const carousel = document.getElementById('listenbrainz-created-for-carousel'); - if (!carousel) return; +let listenbrainzPlaylistsCache = {}; // Store playlists by type +let listenbrainzTracksCache = {}; // Store tracks for each playlist +let activeListenBrainzTab = 'recommendations'; // Track active tab - const response = await fetch('/api/discover/listenbrainz/created-for'); - if (!response.ok) { - if (response.status === 401) { - carousel.innerHTML = '

Please configure ListenBrainz token in Settings to view recommendations

'; - return; +async function initializeListenBrainzTabs() { + try { + console.log('🧠 Initializing ListenBrainz tabs...'); + + // Fetch all playlists types + const [createdForRes, userPlaylistsRes, collaborativeRes] = await Promise.all([ + fetch('/api/discover/listenbrainz/created-for'), + fetch('/api/discover/listenbrainz/user-playlists'), + fetch('/api/discover/listenbrainz/collaborative') + ]); + + console.log('📡 API Responses:', { + createdFor: createdForRes.status, + userPlaylists: userPlaylistsRes.status, + collaborative: collaborativeRes.status + }); + + const tabs = [ + { id: 'recommendations', label: '🎁 Recommendations', hasData: false }, + { id: 'user', label: '📚 Your Playlists', hasData: false }, + { id: 'collaborative', label: '🤝 Collaborative', hasData: false } + ]; + + // Check which tabs have data + if (createdForRes.ok) { + const data = await createdForRes.json(); + console.log('📋 Created For data:', data); + if (data.success && data.playlists && data.playlists.length > 0) { + listenbrainzPlaylistsCache['recommendations'] = data.playlists; + tabs[0].hasData = true; + console.log(`✅ Found ${data.playlists.length} recommendation playlists`); } - throw new Error('Failed to fetch ListenBrainz recommendations'); } - const data = await response.json(); - if (!data.success || !data.playlists || data.playlists.length === 0) { - carousel.innerHTML = '

No ListenBrainz recommendations available yet

'; + if (userPlaylistsRes.ok) { + const data = await userPlaylistsRes.json(); + console.log('📚 User Playlists data:', data); + if (data.success && data.playlists && data.playlists.length > 0) { + listenbrainzPlaylistsCache['user'] = data.playlists; + tabs[1].hasData = true; + console.log(`✅ Found ${data.playlists.length} user playlists`); + } + } + + if (collaborativeRes.ok) { + const data = await collaborativeRes.json(); + console.log('🤝 Collaborative data:', data); + if (data.success && data.playlists && data.playlists.length > 0) { + listenbrainzPlaylistsCache['collaborative'] = data.playlists; + tabs[2].hasData = true; + console.log(`✅ Found ${data.playlists.length} collaborative playlists`); + } + } + + // Build tabs HTML + const tabsContainer = document.getElementById('listenbrainz-tabs'); + console.log('🔧 Building tabs. Available tabs:', tabs.filter(t => t.hasData).map(t => t.label)); + + let tabsHtml = '
'; // Reuse decade tabs styling + + tabs.forEach(tab => { + if (tab.hasData) { + const isActive = tab.id === activeListenBrainzTab; + tabsHtml += ` + + `; + } + }); + tabsHtml += '
'; + + if (tabs.every(t => !t.hasData)) { + console.log('⚠️ No tabs have data'); + tabsContainer.innerHTML = '

No ListenBrainz playlists available. Configure your token in Settings.

'; return; } - // Build playlist cards - let html = ''; - data.playlists.forEach(playlist => { - // JSPF structure: playlist.playlist contains the actual data - const playlistData = playlist.playlist || playlist; - const identifier = playlistData.identifier?.split('/').pop() || ''; - const title = playlistData.title || 'Untitled Playlist'; - const creator = playlistData.creator || 'ListenBrainz'; + tabsContainer.innerHTML = tabsHtml; - // Track count - default to 50 if not available or 0 - let trackCount = 50; // Default - if (playlistData.annotation?.track_count && playlistData.annotation.track_count > 0) { - trackCount = playlistData.annotation.track_count; - } else if (playlistData.track && Array.isArray(playlistData.track) && playlistData.track.length > 0) { - trackCount = playlistData.track.length; - } - - html += ` -
-
-
🧠
-
-
-

${title}

-

by ${creator}

-

${trackCount} tracks

-
-
- `; - }); - - carousel.innerHTML = html; + // Load first available tab + const firstTab = tabs.find(t => t.hasData); + if (firstTab) { + console.log(`🎯 Loading first tab: ${firstTab.label} (${firstTab.id})`); + activeListenBrainzTab = firstTab.id; + loadListenBrainzTabContent(firstTab.id); + } else { + console.log('❌ No first tab found'); + } } catch (error) { - console.error('Error loading ListenBrainz created-for playlists:', error); - const carousel = document.getElementById('listenbrainz-created-for-carousel'); - if (carousel) { - carousel.innerHTML = '

Failed to load recommendations

'; + console.error('Error initializing ListenBrainz tabs:', error); + const tabsContainer = document.getElementById('listenbrainz-tabs'); + if (tabsContainer) { + tabsContainer.innerHTML = '

Failed to load playlists

'; } } } -async function loadListenBrainzUserPlaylists() { - try { - const carousel = document.getElementById('listenbrainz-user-playlists-carousel'); - if (!carousel) return; +function switchListenBrainzTab(tabId) { + // Update active tab + activeListenBrainzTab = tabId; - const response = await fetch('/api/discover/listenbrainz/user-playlists'); - if (!response.ok) { - if (response.status === 401) { - carousel.innerHTML = '

Please configure ListenBrainz token in Settings to view your playlists

'; - return; - } - throw new Error('Failed to fetch ListenBrainz user playlists'); + // Update tab buttons + const tabs = document.querySelectorAll('#listenbrainz-tabs .decade-tab'); + tabs.forEach(tab => { + if (tab.dataset.tab === tabId) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + }); + + // Load content + loadListenBrainzTabContent(tabId); +} + +async function loadListenBrainzTabContent(tabId) { + const container = document.getElementById('listenbrainz-tab-content'); + if (!container) return; + + const playlists = listenbrainzPlaylistsCache[tabId] || []; + if (playlists.length === 0) { + container.innerHTML = '

No playlists in this category

'; + return; + } + + // Build HTML for all playlists in this tab + let html = ''; + playlists.forEach((playlist, index) => { + const playlistData = playlist.playlist || playlist; + const identifier = playlistData.identifier?.split('/').pop() || ''; + console.log(`📋 Playlist ${index}:`, { + title: playlistData.title, + fullIdentifier: playlistData.identifier, + extractedIdentifier: identifier + }); + const title = playlistData.title || 'Untitled Playlist'; + const creator = playlistData.creator || 'ListenBrainz'; + + let trackCount = 50; + if (playlistData.annotation?.track_count && playlistData.annotation.track_count > 0) { + trackCount = playlistData.annotation.track_count; + } else if (playlistData.track && Array.isArray(playlistData.track) && playlistData.track.length > 0) { + trackCount = playlistData.track.length; } - const data = await response.json(); + const playlistId = `lb-${tabId}-${index}`; + const virtualPlaylistId = `discover_lb_${tabId}_${identifier}`; - if (!data.success || !data.playlists || data.playlists.length === 0) { - carousel.innerHTML = ''; + html += ` +
+
+
+

${title}

+ +
+
+ + +
+
+ + +
+

Loading tracks...

+
+
+ `; + }); + + container.innerHTML = html; + + // Load tracks for all playlists in this tab + playlists.forEach((playlist, index) => { + const playlistData = playlist.playlist || playlist; + const identifier = playlistData.identifier?.split('/').pop() || ''; + const playlistId = `lb-${tabId}-${index}`; + loadListenBrainzPlaylistTracks(identifier, playlistId); + }); +} + +async function loadListenBrainzPlaylistTracks(identifier, playlistId) { + try { + const playlistContainer = document.getElementById(`${playlistId}-playlist`); + if (!playlistContainer) return; + + // Check cache first + if (listenbrainzTracksCache[identifier]) { + displayListenBrainzTracks(listenbrainzTracksCache[identifier], playlistId); return; } - // Build playlist cards - let html = ''; - data.playlists.forEach(playlist => { - // JSPF structure: playlist.playlist contains the actual data - const playlistData = playlist.playlist || playlist; - const identifier = playlistData.identifier?.split('/').pop() || ''; - const title = playlistData.title || 'Untitled Playlist'; - const creator = playlistData.creator || 'You'; + console.log(`🔄 Fetching tracks for playlist: ${identifier}`); + const response = await fetch(`/api/discover/listenbrainz/playlist/${identifier}`); + console.log(`📡 Response status: ${response.status}`); - // Track count - default to 50 if not available or 0 - let trackCount = 50; // Default - if (playlistData.annotation?.track_count && playlistData.annotation.track_count > 0) { - trackCount = playlistData.annotation.track_count; - } else if (playlistData.track && Array.isArray(playlistData.track) && playlistData.track.length > 0) { - trackCount = playlistData.track.length; - } + if (!response.ok) { + const errorText = await response.text(); + console.error(`❌ Failed to fetch playlist: ${response.status} - ${errorText}`); + throw new Error('Failed to fetch playlist tracks'); + } - const isPublic = playlistData.annotation?.public !== false; + const data = await response.json(); + console.log(`📋 Received data:`, data); + console.log(`📊 Tracks count: ${data.tracks?.length || 0}`); - html += ` -
-
-
${isPublic ? '📚' : '🔒'}
-
-
-

${title}

-

by ${creator}

-

${trackCount} tracks • ${isPublic ? 'Public' : 'Private'}

-
-
- `; - }); + if (!data.success || !data.tracks || data.tracks.length === 0) { + playlistContainer.innerHTML = '

No tracks available

'; + return; + } - carousel.innerHTML = html; + // Cache the tracks + listenbrainzTracksCache[identifier] = data.tracks; + + // Display tracks + displayListenBrainzTracks(data.tracks, playlistId); } catch (error) { - console.error('Error loading ListenBrainz user playlists:', error); - const carousel = document.getElementById('listenbrainz-user-playlists-carousel'); - if (carousel) { - carousel.innerHTML = '

Failed to load your playlists

'; + console.error('Error loading ListenBrainz playlist tracks:', error); + const playlistContainer = document.getElementById(`${playlistId}-playlist`); + if (playlistContainer) { + playlistContainer.innerHTML = '

Failed to load tracks

'; } } } -async function loadListenBrainzCollaborative() { +function displayListenBrainzTracks(tracks, playlistId) { + const playlistContainer = document.getElementById(`${playlistId}-playlist`); + if (!playlistContainer) return; + + console.log(`🎨 Displaying ${tracks.length} tracks for ${playlistId}`); + if (tracks.length > 0) { + console.log('Sample track data:', tracks[0]); + } + + // Update track count in the metadata section + const metaElement = document.getElementById(`${playlistId}-meta`); + if (metaElement) { + // Extract creator from existing text (before the bullet) + const currentText = metaElement.textContent; + const creatorMatch = currentText.match(/by (.+?) •/); + const creator = creatorMatch ? creatorMatch[1] : 'ListenBrainz'; + metaElement.textContent = `by ${creator} • ${tracks.length} track${tracks.length !== 1 ? 's' : ''}`; + } + + // Simple SVG placeholder for missing album art + const placeholderImage = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiMzMzMiLz48cGF0aCBkPSJNMjAgMTBDMTguMzQzMSAxMCAxNyAxMS4zNDMxIDE3IDEzQzE3IDE0LjY1NjkgMTguMzQzMSAxNiAyMCAxNkMyMS42NTY5IDE2IDIzIDE0LjY1NjkgMjMgMTNDMjMgMTEuMzQzMSAyMS42NTY5IDEwIDIwIDEwWk0yNSAyMEgyNUMyNSAxOC44OTU0IDI0LjEwNDYgMTggMjMgMThIMTdDMTUuODk1NCAxOCAxNSAxOC44OTU0IDE1IDIwVjI4QzE1IDI5LjEwNDYgMTUuODk1NCAzMCAxNyAzMEgyM0MyNC4xMDQ2IDMwIDI1IDI5LjEwNDYgMjUgMjhWMjBaIiBmaWxsPSIjNjY2Ii8+PC9zdmc+'; + + let html = '
'; + tracks.forEach((track, index) => { + const coverUrl = track.album_cover_url || placeholderImage; + const durationMin = Math.floor(track.duration_ms / 60000); + const durationSec = Math.floor((track.duration_ms % 60000) / 1000); + const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; + + const albumName = escapeHtml(track.album_name || 'Unknown Album'); + + html += ` +
+
${index + 1}
+
+ ${albumName} +
+
+
${escapeHtml(track.track_name || 'Unknown Track')}
+
${escapeHtml(track.artist_name || 'Unknown Artist')}
+
+
${albumName}
+
${duration}
+
+ `; + }); + html += '
'; + + playlistContainer.innerHTML = html; +} + +async function startListenBrainzPlaylistSync(identifier, title, playlistId) { try { - const carousel = document.getElementById('listenbrainz-collaborative-carousel'); - if (!carousel) return; + console.log(`🔄 Starting sync for ListenBrainz playlist:`, { identifier, title, playlistId }); - const response = await fetch('/api/discover/listenbrainz/collaborative'); - if (!response.ok) { - if (response.status === 401) { - carousel.innerHTML = '

Please configure ListenBrainz token in Settings to view collaborative playlists

'; - return; - } - throw new Error('Failed to fetch ListenBrainz collaborative playlists'); - } + const tracks = listenbrainzTracksCache[identifier]; + console.log(`📊 Cached tracks for ${identifier}:`, tracks?.length || 0); - const data = await response.json(); - - if (!data.success || !data.playlists || data.playlists.length === 0) { - carousel.innerHTML = '

You\'re not collaborating on any playlists yet. Join or create collaborative playlists on ListenBrainz!

'; + if (!tracks || tracks.length === 0) { + console.error('❌ No tracks found in cache'); + showToast('No tracks to sync', 'error'); return; } - // Build playlist cards - let html = ''; - data.playlists.forEach(playlist => { - // JSPF structure: playlist.playlist contains the actual data - const playlistData = playlist.playlist || playlist; - const identifier = playlistData.identifier?.split('/').pop() || ''; - const title = playlistData.title || 'Untitled Playlist'; - const creator = playlistData.creator || 'Unknown'; + console.log(`✅ Found ${tracks.length} tracks to sync`); - // Track count - default to 50 if not available or 0 - let trackCount = 50; // Default - if (playlistData.annotation?.track_count && playlistData.annotation.track_count > 0) { - trackCount = playlistData.annotation.track_count; - } else if (playlistData.track && Array.isArray(playlistData.track) && playlistData.track.length > 0) { - trackCount = playlistData.track.length; - } + // Show sync status + const statusDisplay = document.getElementById(`${playlistId}-sync-status`); + const syncButton = document.getElementById(`${playlistId}-sync-btn`); - html += ` -
-
-
🤝
-
-
-

${title}

-

by ${creator}

-

${trackCount} tracks • Collaborative

-
-
- `; + console.log('UI Elements:', { + statusDisplay: !!statusDisplay, + syncButton: !!syncButton }); - carousel.innerHTML = html; + if (statusDisplay) statusDisplay.style.display = 'block'; + if (syncButton) syncButton.disabled = true; + + // Prepare tracks for sync + const tracksForSync = tracks.map(track => ({ + name: track.track_name, + artist: track.artist_name, + album: track.album_name, + mbid: track.mbid || null, + duration_ms: track.duration_ms || 0 + })); + + const virtualPlaylistId = `discover_lb_${identifier}`; + console.log(`🆔 Virtual playlist ID: ${virtualPlaylistId}`); + + // Start sync + console.log('📤 Sending sync request...'); + const response = await fetch('/api/sync/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + virtual_playlist_id: virtualPlaylistId, + playlist_name: title, + tracks: tracksForSync + }) + }); + + console.log(`📡 Response status: ${response.status}`); + const result = await response.json(); + console.log('📋 Response data:', result); + + if (!result.success) { + throw new Error(result.error || 'Failed to start sync'); + } + + console.log(`✅ Sync started successfully for ${title}`); + showToast(`Syncing "${title}" to media server`, 'success'); + + // Start polling for status + startListenBrainzSyncPolling(playlistId, virtualPlaylistId); } catch (error) { - console.error('Error loading ListenBrainz collaborative playlists:', error); - const carousel = document.getElementById('listenbrainz-collaborative-carousel'); - if (carousel) { - carousel.innerHTML = '

Failed to load collaborative playlists

'; + console.error('❌ Error starting ListenBrainz playlist sync:', error); + showToast('Failed to start sync', 'error'); + + const statusDisplay = document.getElementById(`${playlistId}-sync-status`); + const syncButton = document.getElementById(`${playlistId}-sync-btn`); + if (statusDisplay) statusDisplay.style.display = 'none'; + if (syncButton) syncButton.disabled = false; + } +} + +function startListenBrainzSyncPolling(playlistId, virtualPlaylistId) { + const pollInterval = setInterval(async () => { + try { + const response = await fetch(`/api/sync/status/${virtualPlaylistId}`); + if (!response.ok) { + clearInterval(pollInterval); + return; + } + + const status = await response.json(); + + // Update UI + const totalEl = document.getElementById(`${playlistId}-sync-total`); + const matchedEl = document.getElementById(`${playlistId}-sync-matched`); + const failedEl = document.getElementById(`${playlistId}-sync-failed`); + const percentageEl = document.getElementById(`${playlistId}-sync-percentage`); + + if (totalEl) totalEl.textContent = status.total_tracks || 0; + if (matchedEl) matchedEl.textContent = status.matched_tracks || 0; + if (failedEl) failedEl.textContent = status.failed_tracks || 0; + + const percentage = status.total_tracks > 0 + ? Math.round(((status.matched_tracks || 0) / status.total_tracks) * 100) + : 0; + if (percentageEl) percentageEl.textContent = percentage; + + // Check if complete + if (status.is_complete) { + clearInterval(pollInterval); + + const statusDisplay = document.getElementById(`${playlistId}-sync-status`); + const syncButton = document.getElementById(`${playlistId}-sync-btn`); + + if (statusDisplay) { + setTimeout(() => { + statusDisplay.style.display = 'none'; + }, 3000); + } + + if (syncButton) syncButton.disabled = false; + + showToast(`Sync complete: ${status.matched_tracks}/${status.total_tracks} tracks matched`, 'success'); + } + + } catch (error) { + console.error('Error polling sync status:', error); + clearInterval(pollInterval); } + }, 2000); +} + +async function openDownloadModalForListenBrainzPlaylist(identifier, title) { + try { + const tracks = listenbrainzTracksCache[identifier]; + if (!tracks || tracks.length === 0) { + showToast('No tracks to download', 'error'); + return; + } + + console.log(`📥 Opening download modal for ListenBrainz playlist: ${title}`); + + // Convert ListenBrainz tracks to Spotify-compatible format + const spotifyTracks = tracks.map(track => ({ + id: null, // No Spotify ID for ListenBrainz tracks + name: track.track_name, + artists: [track.artist_name], + album: { + name: track.album_name, + images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 0, + mbid: track.mbid // Include MusicBrainz ID for matching + })); + + const virtualPlaylistId = `discover_lb_${identifier}`; + + // Open the download modal + await openDownloadMissingModalForYouTube(virtualPlaylistId, title, spotifyTracks); + + } catch (error) { + console.error('Error opening download modal for ListenBrainz playlist:', error); + showToast('Failed to open download modal', 'error'); } } diff --git a/webui/static/style.css b/webui/static/style.css index 96eb64ca..12f24c80 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -17463,6 +17463,19 @@ body { LISTENBRAINZ PLAYLIST CARDS =============================== */ +.listenbrainz-tabs { + margin-top: 20px; + margin-bottom: 20px; +} + +.listenbrainz-tab-content .discover-section-subsection { + margin-bottom: 40px; +} + +.listenbrainz-tab-content .discover-section-subsection:not(:first-child) { + margin-top: 60px; +} + .listenbrainz-playlist-card { background: linear-gradient(135deg, #eb743b 0%, #d26230 100%); display: flex;