listenbrainz playlist support
This commit is contained in:
parent
a50e5a59d9
commit
bc54f2c1a3
5 changed files with 692 additions and 0 deletions
223
core/listenbrainz_client.py
Normal file
223
core/listenbrainz_client.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import requests
|
||||
from typing import Dict, List, Optional, Any
|
||||
from utils.logging_config import get_logger
|
||||
from config.settings import config_manager
|
||||
|
||||
logger = get_logger("listenbrainz_client")
|
||||
|
||||
class ListenBrainzClient:
|
||||
"""Client for interacting with ListenBrainz API"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = "https://api.listenbrainz.org/1"
|
||||
self.token = config_manager.get("listenbrainz.token", "")
|
||||
self.username = None
|
||||
|
||||
if self.token:
|
||||
# Validate token and get username
|
||||
self._validate_and_get_username()
|
||||
|
||||
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)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('valid'):
|
||||
self.username = data.get('user_name')
|
||||
logger.info(f"✅ ListenBrainz authenticated as: {self.username}")
|
||||
return True
|
||||
|
||||
logger.warning("❌ Invalid ListenBrainz token")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating ListenBrainz token: {e}")
|
||||
return False
|
||||
|
||||
def is_authenticated(self):
|
||||
"""Check if client is authenticated"""
|
||||
return bool(self.token and self.username)
|
||||
|
||||
def get_playlists_created_for_user(self, count: int = 25, offset: int = 0) -> List[Dict]:
|
||||
"""
|
||||
Fetch playlists created FOR the user (recommendations, personalized playlists)
|
||||
These are all public and don't require authentication
|
||||
"""
|
||||
if not self.username:
|
||||
logger.warning("No username available for ListenBrainz")
|
||||
return []
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/user/{self.username}/playlists/createdfor"
|
||||
params = {
|
||||
'count': count,
|
||||
'offset': offset
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
|
||||
if 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:
|
||||
logger.warning(f"User {self.username} not found")
|
||||
return []
|
||||
else:
|
||||
logger.error(f"Failed to fetch created-for playlists: {response.status_code}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching created-for playlists: {e}")
|
||||
return []
|
||||
|
||||
def get_user_playlists(self, count: int = 25, offset: int = 0) -> List[Dict]:
|
||||
"""
|
||||
Fetch user's own playlists (both public and private)
|
||||
Requires authentication
|
||||
"""
|
||||
if not self.is_authenticated():
|
||||
logger.warning("Not authenticated for ListenBrainz")
|
||||
return []
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/user/{self.username}/playlists"
|
||||
headers = {'Authorization': f'Token {self.token}'}
|
||||
params = {
|
||||
'count': count,
|
||||
'offset': offset
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
|
||||
if 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:
|
||||
logger.warning(f"User {self.username} not found")
|
||||
return []
|
||||
else:
|
||||
logger.error(f"Failed to fetch user playlists: {response.status_code}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching user playlists: {e}")
|
||||
return []
|
||||
|
||||
def get_collaborative_playlists(self, count: int = 25, offset: int = 0) -> List[Dict]:
|
||||
"""
|
||||
Fetch playlists where user is a collaborator
|
||||
Requires authentication for private playlists
|
||||
"""
|
||||
if not self.is_authenticated():
|
||||
logger.warning("Not authenticated for ListenBrainz")
|
||||
return []
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/user/{self.username}/playlists/collaborator"
|
||||
headers = {'Authorization': f'Token {self.token}'}
|
||||
params = {
|
||||
'count': count,
|
||||
'offset': offset
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
|
||||
if 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:
|
||||
logger.warning(f"User {self.username} not found")
|
||||
return []
|
||||
else:
|
||||
logger.error(f"Failed to fetch collaborative playlists: {response.status_code}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching collaborative playlists: {e}")
|
||||
return []
|
||||
|
||||
def get_playlist_details(self, playlist_mbid: str, fetch_metadata: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch full playlist details including tracks
|
||||
|
||||
Args:
|
||||
playlist_mbid: The MusicBrainz ID of the playlist
|
||||
fetch_metadata: Whether to fetch recording metadata (default True)
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/playlist/{playlist_mbid}"
|
||||
params = {}
|
||||
|
||||
if not fetch_metadata:
|
||||
params['fetch_metadata'] = 'false'
|
||||
|
||||
# Add auth header if we have a token (for private playlists)
|
||||
headers = {}
|
||||
if self.token:
|
||||
headers['Authorization'] = f'Token {self.token}'
|
||||
|
||||
response = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
|
||||
if 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:
|
||||
logger.warning(f"Playlist {playlist_mbid} not found")
|
||||
return None
|
||||
elif 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}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching playlist details: {e}")
|
||||
return None
|
||||
|
||||
def search_playlists(self, query: str) -> List[Dict]:
|
||||
"""
|
||||
Search for playlists by name or description
|
||||
|
||||
Args:
|
||||
query: Search query (minimum 3 characters)
|
||||
"""
|
||||
if len(query) < 3:
|
||||
logger.warning("Search query must be at least 3 characters")
|
||||
return []
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/playlist/search"
|
||||
params = {'query': query}
|
||||
|
||||
# Add auth header if we have a token
|
||||
headers = {}
|
||||
if self.token:
|
||||
headers['Authorization'] = f'Token {self.token}'
|
||||
|
||||
response = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
playlists = data.get('playlists', [])
|
||||
logger.info(f"🔍 Found {len(playlists)} playlists matching '{query}'")
|
||||
return playlists
|
||||
else:
|
||||
logger.error(f"Failed to search playlists: {response.status_code}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching playlists: {e}")
|
||||
return []
|
||||
150
web_server.py
150
web_server.py
|
|
@ -15178,6 +15178,156 @@ def get_discover_genre_playlist(genre_name):
|
|||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
# ===============================
|
||||
# LISTENBRAINZ DISCOVER ENDPOINTS
|
||||
# ===============================
|
||||
|
||||
@app.route('/api/discover/listenbrainz/created-for', methods=['GET'])
|
||||
def get_listenbrainz_created_for():
|
||||
"""Get playlists created for the user by ListenBrainz"""
|
||||
try:
|
||||
from core.listenbrainz_client import ListenBrainzClient
|
||||
|
||||
client = ListenBrainzClient()
|
||||
|
||||
if not client.is_authenticated():
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Not authenticated with ListenBrainz"
|
||||
}), 401
|
||||
|
||||
playlists = client.get_playlists_created_for_user(count=25)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"playlists": playlists,
|
||||
"count": len(playlists)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting ListenBrainz created-for playlists: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/listenbrainz/user-playlists', methods=['GET'])
|
||||
def get_listenbrainz_user_playlists():
|
||||
"""Get user's own ListenBrainz playlists"""
|
||||
try:
|
||||
from core.listenbrainz_client import ListenBrainzClient
|
||||
|
||||
client = ListenBrainzClient()
|
||||
|
||||
if not client.is_authenticated():
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Not authenticated with ListenBrainz"
|
||||
}), 401
|
||||
|
||||
playlists = client.get_user_playlists(count=25)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"playlists": playlists,
|
||||
"count": len(playlists)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting ListenBrainz user playlists: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/listenbrainz/collaborative', methods=['GET'])
|
||||
def get_listenbrainz_collaborative():
|
||||
"""Get collaborative ListenBrainz playlists"""
|
||||
try:
|
||||
from core.listenbrainz_client import ListenBrainzClient
|
||||
|
||||
client = ListenBrainzClient()
|
||||
|
||||
if not client.is_authenticated():
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Not authenticated with ListenBrainz"
|
||||
}), 401
|
||||
|
||||
playlists = client.get_collaborative_playlists(count=25)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"playlists": playlists,
|
||||
"count": len(playlists)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting ListenBrainz collaborative playlists: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/listenbrainz/playlist/<playlist_mbid>', methods=['GET'])
|
||||
def get_listenbrainz_playlist_tracks(playlist_mbid):
|
||||
"""Get tracks from a specific ListenBrainz playlist"""
|
||||
try:
|
||||
from core.listenbrainz_client import ListenBrainzClient
|
||||
|
||||
client = ListenBrainzClient()
|
||||
|
||||
playlist = client.get_playlist_details(playlist_mbid, fetch_metadata=True)
|
||||
|
||||
if not playlist:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Playlist not found or not accessible"
|
||||
}), 404
|
||||
|
||||
# Extract tracks from JSPF format
|
||||
jspf_tracks = playlist.get('track', [])
|
||||
|
||||
# Convert to our standard format
|
||||
tracks = []
|
||||
for track in jspf_tracks:
|
||||
# Get recording MBID from identifier
|
||||
recording_mbid = None
|
||||
identifiers = track.get('identifier', [])
|
||||
for identifier in identifiers:
|
||||
if 'musicbrainz.org/recording/' in identifier:
|
||||
recording_mbid = identifier.split('/')[-1]
|
||||
break
|
||||
|
||||
# Get extension data (has MusicBrainz metadata)
|
||||
extension = track.get('extension', {})
|
||||
mb_data = extension.get('https://musicbrainz.org/doc/jspf#track', {})
|
||||
|
||||
track_data = {
|
||||
'title': track.get('title', 'Unknown Track'),
|
||||
'creator': track.get('creator', 'Unknown Artist'),
|
||||
'album': track.get('album', 'Unknown Album'),
|
||||
'duration_ms': track.get('duration', 0),
|
||||
'recording_mbid': recording_mbid,
|
||||
'additional_metadata': mb_data
|
||||
}
|
||||
|
||||
tracks.append(track_data)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"playlist": {
|
||||
"identifier": playlist.get('identifier'),
|
||||
"title": playlist.get('title'),
|
||||
"creator": playlist.get('creator'),
|
||||
"tracks": tracks,
|
||||
"track_count": len(tracks)
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting ListenBrainz playlist tracks: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/metadata/start', methods=['POST'])
|
||||
def start_metadata_update():
|
||||
"""Start the metadata update process - EXACT copy of dashboard.py logic"""
|
||||
|
|
|
|||
|
|
@ -2134,6 +2134,39 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ListenBrainz Recommendations (Created For You) -->
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Machine (Tabbed by Decade) -->
|
||||
<div class="discover-section">
|
||||
<div class="discover-section-header">
|
||||
|
|
|
|||
|
|
@ -24744,6 +24744,9 @@ 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
|
||||
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
|
||||
loadGenreBrowserTabs() // Browse by Genre (tabbed by genre)
|
||||
]);
|
||||
|
|
@ -26260,6 +26263,249 @@ async function openDownloadModalForGenre(genreName) {
|
|||
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// LISTENBRAINZ PLAYLISTS
|
||||
// ===============================
|
||||
|
||||
async function loadListenBrainzCreatedFor() {
|
||||
try {
|
||||
const carousel = document.getElementById('listenbrainz-created-for-carousel');
|
||||
if (!carousel) return;
|
||||
|
||||
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;
|
||||
}
|
||||
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>';
|
||||
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';
|
||||
|
||||
// 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;
|
||||
|
||||
} 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>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadListenBrainzUserPlaylists() {
|
||||
try {
|
||||
const carousel = document.getElementById('listenbrainz-user-playlists-carousel');
|
||||
if (!carousel) return;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
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>';
|
||||
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';
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const isPublic = playlistData.annotation?.public !== false;
|
||||
|
||||
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>
|
||||
`;
|
||||
});
|
||||
|
||||
carousel.innerHTML = html;
|
||||
|
||||
} 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>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadListenBrainzCollaborative() {
|
||||
try {
|
||||
const carousel = document.getElementById('listenbrainz-collaborative-carousel');
|
||||
if (!carousel) return;
|
||||
|
||||
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 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>';
|
||||
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';
|
||||
|
||||
// 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 • Collaborative</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
carousel.innerHTML = html;
|
||||
|
||||
} 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>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openListenBrainzPlaylist(playlistMbid, playlistName) {
|
||||
try {
|
||||
showLoadingOverlay(`Loading ${playlistName}...`);
|
||||
|
||||
const response = await fetch(`/api/discover/listenbrainz/playlist/${playlistMbid}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch playlist');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.playlist) {
|
||||
showToast('Failed to load playlist', 'error');
|
||||
hideLoadingOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
const playlist = data.playlist;
|
||||
const tracks = playlist.tracks || [];
|
||||
|
||||
if (tracks.length === 0) {
|
||||
showToast('This playlist is empty', 'info');
|
||||
hideLoadingOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to Spotify-like format for compatibility with download modal
|
||||
const spotifyTracks = tracks.map(track => ({
|
||||
id: track.recording_mbid || '',
|
||||
name: track.title || 'Unknown',
|
||||
artists: [track.creator || 'Unknown'],
|
||||
album: {
|
||||
name: track.album || 'Unknown Album',
|
||||
images: []
|
||||
},
|
||||
duration_ms: track.duration_ms || 0,
|
||||
listenbrainz_metadata: track.additional_metadata
|
||||
}));
|
||||
|
||||
const virtualPlaylistId = `listenbrainz_${playlistMbid}`;
|
||||
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
||||
hideLoadingOverlay();
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error opening ListenBrainz playlist:`, error);
|
||||
showToast(`Failed to load playlist`, 'error');
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// SEASONAL DISCOVERY
|
||||
// ===============================
|
||||
|
|
|
|||
|
|
@ -17459,3 +17459,43 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
LISTENBRAINZ PLAYLIST CARDS
|
||||
=============================== */
|
||||
|
||||
.listenbrainz-playlist-card {
|
||||
background: linear-gradient(135deg, #eb743b 0%, #d26230 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.listenbrainz-playlist-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.listenbrainz-icon {
|
||||
font-size: 48px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue