From bc54f2c1a31b9d16a4eb90e725a8994399fb4b67 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 17 Nov 2025 22:38:38 -0800 Subject: [PATCH] listenbrainz playlist support --- core/listenbrainz_client.py | 223 ++++++++++++++++++++++++++++++++ web_server.py | 150 ++++++++++++++++++++++ webui/index.html | 33 +++++ webui/static/script.js | 246 ++++++++++++++++++++++++++++++++++++ webui/static/style.css | 40 ++++++ 5 files changed, 692 insertions(+) create mode 100644 core/listenbrainz_client.py diff --git a/core/listenbrainz_client.py b/core/listenbrainz_client.py new file mode 100644 index 00000000..e60a11e7 --- /dev/null +++ b/core/listenbrainz_client.py @@ -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 [] diff --git a/web_server.py b/web_server.py index caccb852..1cfbb1ce 100644 --- a/web_server.py +++ b/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/', 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""" diff --git a/webui/index.html b/webui/index.html index 13dcdc47..c36c2061 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2134,6 +2134,39 @@ + +
+
+

🧠 ListenBrainz Recommendations

+

Playlists curated for you by ListenBrainz

+
+ +
+ + +
+
+

📚 Your ListenBrainz Playlists

+

Your personal playlists from ListenBrainz

+
+ +
+ + +
+
+

🤝 Collaborative Playlists

+

Playlists you collaborate on

+
+ +
+
diff --git a/webui/static/script.js b/webui/static/script.js index 59212a59..eec13c27 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = '

Please configure ListenBrainz token in Settings to view recommendations

'; + 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 = '

No ListenBrainz recommendations available yet

'; + 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 += ` +
+
+
🧠
+
+
+

${title}

+

by ${creator}

+

${trackCount} tracks

+
+
+ `; + }); + + 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 = '

Failed to load recommendations

'; + } + } +} + +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 = '

Please configure ListenBrainz token in Settings to view your playlists

'; + 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 = ''; + 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 += ` +
+
+
${isPublic ? '📚' : '🔒'}
+
+
+

${title}

+

by ${creator}

+

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

+
+
+ `; + }); + + 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 = '

Failed to load your playlists

'; + } + } +} + +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 = '

Please configure ListenBrainz token in Settings to view collaborative playlists

'; + 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 = '

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

'; + 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 += ` +
+
+
🤝
+
+
+

${title}

+

by ${creator}

+

${trackCount} tracks • Collaborative

+
+
+ `; + }); + + carousel.innerHTML = html; + + } 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

'; + } + } +} + +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 // =============================== diff --git a/webui/static/style.css b/webui/static/style.css index d5dde329..96eb64ca 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; + } +} +