listenbrainz playlist support

This commit is contained in:
Broque Thomas 2025-11-17 22:38:38 -08:00
parent a50e5a59d9
commit bc54f2c1a3
5 changed files with 692 additions and 0 deletions

223
core/listenbrainz_client.py Normal file
View 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 []

View file

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

View file

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

View file

@ -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
// ===============================

View file

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