-
-
-
Loading collaborative playlists...
+
+
+
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 = '
';
}
}
}
-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 += `
+
+
+
+
+
+
+ ⟳
+ Syncing to media server...
+
+
+ ♪ 0
+ /
+ ✓ 0
+ /
+ ✗ 0
+ (0%)
+
+
+
+
+
+ `;
+ });
+
+ 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 = '
';
+ 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 = '
';
}
}
}
-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}
+
+

+
+
+
${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;