listenbrainz update

This commit is contained in:
Broque Thomas 2025-11-18 09:57:47 -08:00
parent bc54f2c1a3
commit 333d984527
5 changed files with 514 additions and 200 deletions

View file

@ -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:

View file

@ -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:

View file

@ -2134,36 +2134,21 @@
</div>
</div>
<!-- ListenBrainz Recommendations (Created For You) -->
<!-- ListenBrainz Playlists (Tabbed) -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">🧠 ListenBrainz Recommendations</h2>
<p class="discover-section-subtitle">Playlists curated for you by ListenBrainz</p>
<h2 class="discover-section-title">🧠 ListenBrainz Playlists</h2>
<p class="discover-section-subtitle">Playlists from ListenBrainz</p>
</div>
<div class="discover-carousel" id="listenbrainz-created-for-carousel">
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading ListenBrainz recommendations...</p></div>
</div>
</div>
<!-- Your ListenBrainz Playlists -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">📚 Your ListenBrainz Playlists</h2>
<p class="discover-section-subtitle">Your personal playlists from ListenBrainz</p>
<!-- ListenBrainz Tabs -->
<div class="listenbrainz-tabs" id="listenbrainz-tabs">
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading playlists...</p></div>
</div>
<div class="discover-carousel" id="listenbrainz-user-playlists-carousel">
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading your playlists...</p></div>
</div>
</div>
<!-- Collaborative ListenBrainz Playlists -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">🤝 Collaborative Playlists</h2>
<p class="discover-section-subtitle">Playlists you collaborate on</p>
</div>
<div class="discover-carousel" id="listenbrainz-collaborative-carousel">
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading collaborative playlists...</p></div>
<!-- ListenBrainz Tab Content -->
<div class="listenbrainz-tab-content" id="listenbrainz-tab-content">
<!-- Content will be populated dynamically -->
</div>
</div>

View file

@ -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 = '<div class="discover-empty"><p>Please configure ListenBrainz token in Settings to view recommendations</p></div>';
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 = '<div class="discover-empty"><p>No ListenBrainz recommendations available yet</p></div>';
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 = '<div class="decade-tabs-inner">'; // Reuse decade tabs styling
tabs.forEach(tab => {
if (tab.hasData) {
const isActive = tab.id === activeListenBrainzTab;
tabsHtml += `
<button class="decade-tab${isActive ? ' active' : ''}"
onclick="switchListenBrainzTab('${tab.id}')"
data-tab="${tab.id}">
${tab.label}
</button>
`;
}
});
tabsHtml += '</div>';
if (tabs.every(t => !t.hasData)) {
console.log('⚠️ No tabs have data');
tabsContainer.innerHTML = '<div class="discover-empty"><p>No ListenBrainz playlists available. Configure your token in Settings.</p></div>';
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 += `
<div class="discover-card" onclick="openListenBrainzPlaylist('${identifier}', '${escapeHtml(title)}')">
<div class="discover-card-image listenbrainz-playlist-card">
<div class="listenbrainz-icon">🧠</div>
</div>
<div class="discover-card-info">
<h4 class="discover-card-title">${title}</h4>
<p class="discover-card-subtitle">by ${creator}</p>
<p class="discover-card-meta">${trackCount} tracks</p>
</div>
</div>
`;
});
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 = '<div class="discover-empty"><p>Failed to load recommendations</p></div>';
console.error('Error initializing ListenBrainz tabs:', error);
const tabsContainer = document.getElementById('listenbrainz-tabs');
if (tabsContainer) {
tabsContainer.innerHTML = '<div class="discover-empty"><p>Failed to load playlists</p></div>';
}
}
}
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 = '<div class="discover-empty"><p>Please configure ListenBrainz token in Settings to view your playlists</p></div>';
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 = '<div class="discover-empty"><p>No playlists in this category</p></div>';
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 = '<div class="discover-empty"><p>No playlists found. <a href="https://listenbrainz.org/playlists/" target="_blank" style="color: #eb743b; text-decoration: underline;">Create your first playlist on ListenBrainz!</a></p></div>';
html += `
<div class="discover-section-subsection">
<div class="discover-section-header">
<div>
<h3 class="discover-section-subtitle-large">${title}</h3>
<p class="discover-section-meta" id="${playlistId}-meta">by ${creator} Loading tracks...</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary"
onclick="openDownloadModalForListenBrainzPlaylist('${identifier}', '${escapeHtml(title)}')"
title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button primary"
id="${playlistId}-sync-btn"
onclick="startListenBrainzPlaylistSync('${identifier}', '${escapeHtml(title)}', '${playlistId}')"
title="Sync to media server">
<span class="button-icon"></span>
<span class="button-text">Sync</span>
</button>
</div>
</div>
<!-- Sync Status Display -->
<div class="discover-sync-status" id="${playlistId}-sync-status" style="display: none;">
<div class="sync-status-content">
<div class="sync-status-label">
<span class="sync-icon"></span>
<span>Syncing to media server...</span>
</div>
<div class="sync-status-stats">
<span class="sync-stat"> <span id="${playlistId}-sync-total">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat"> <span id="${playlistId}-sync-matched">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat"> <span id="${playlistId}-sync-failed">0</span></span>
<span class="sync-stat">(<span id="${playlistId}-sync-percentage">0</span>%)</span>
</div>
</div>
</div>
<div class="discover-playlist-container compact" id="${playlistId}-playlist">
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading tracks...</p></div>
</div>
</div>
`;
});
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 += `
<div class="discover-card" onclick="openListenBrainzPlaylist('${identifier}', '${escapeHtml(title)}')">
<div class="discover-card-image listenbrainz-playlist-card">
<div class="listenbrainz-icon">${isPublic ? '📚' : '🔒'}</div>
</div>
<div class="discover-card-info">
<h4 class="discover-card-title">${title}</h4>
<p class="discover-card-subtitle">by ${creator}</p>
<p class="discover-card-meta">${trackCount} tracks ${isPublic ? 'Public' : 'Private'}</p>
</div>
</div>
`;
});
if (!data.success || !data.tracks || data.tracks.length === 0) {
playlistContainer.innerHTML = '<div class="discover-empty"><p>No tracks available</p></div>';
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 = '<div class="discover-empty"><p>Failed to load your playlists</p></div>';
console.error('Error loading ListenBrainz playlist tracks:', error);
const playlistContainer = document.getElementById(`${playlistId}-playlist`);
if (playlistContainer) {
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load tracks</p></div>';
}
}
}
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 = '<div class="discover-playlist-tracks-compact">';
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 += `
<div class="discover-playlist-track-compact" data-track-index="${index}">
<div class="track-compact-number">${index + 1}</div>
<div class="track-compact-image">
<img src="${coverUrl}" alt="${albumName}" onerror="this.src='${placeholderImage}'">
</div>
<div class="track-compact-info">
<div class="track-compact-name">${escapeHtml(track.track_name || 'Unknown Track')}</div>
<div class="track-compact-artist">${escapeHtml(track.artist_name || 'Unknown Artist')}</div>
</div>
<div class="track-compact-album">${albumName}</div>
<div class="track-compact-duration">${duration}</div>
</div>
`;
});
html += '</div>';
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 = '<div class="discover-empty"><p>Please configure ListenBrainz token in Settings to view collaborative playlists</p></div>';
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 = '<div class="discover-empty"><p>You\'re not collaborating on any playlists yet. Join or create collaborative playlists on <a href="https://listenbrainz.org/playlists/" target="_blank" style="color: #eb743b; text-decoration: underline;">ListenBrainz</a>!</p></div>';
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 += `
<div class="discover-card" onclick="openListenBrainzPlaylist('${identifier}', '${escapeHtml(title)}')">
<div class="discover-card-image listenbrainz-playlist-card">
<div class="listenbrainz-icon">🤝</div>
</div>
<div class="discover-card-info">
<h4 class="discover-card-title">${title}</h4>
<p class="discover-card-subtitle">by ${creator}</p>
<p class="discover-card-meta">${trackCount} tracks Collaborative</p>
</div>
</div>
`;
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 = '<div class="discover-empty"><p>Failed to load collaborative playlists</p></div>';
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');
}
}

View file

@ -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;