listenbrainz discovery fix

This commit is contained in:
Broque Thomas 2025-11-25 15:13:41 -08:00
parent ab58b64a17
commit ffe02d5330
3 changed files with 1317 additions and 79 deletions

View file

@ -2587,23 +2587,33 @@ class MusicDatabase:
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id, spotify_artist_id, artist_name, date_added,
last_scan_timestamp, created_at, updated_at, image_url
# Check which columns exist (for migration compatibility)
cursor.execute("PRAGMA table_info(watchlist_artists)")
existing_columns = {column[1] for column in cursor.fetchall()}
# Build SELECT query based on existing columns
base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added',
'last_scan_timestamp', 'created_at', 'updated_at']
optional_columns = ['image_url', 'include_albums', 'include_eps', 'include_singles']
columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns]
cursor.execute(f"""
SELECT {', '.join(columns_to_select)}
FROM watchlist_artists
ORDER BY date_added DESC
""")
rows = cursor.fetchall()
watchlist_artists = []
for row in rows:
# Try to get image_url, fallback to None if column doesn't exist yet (migration)
try:
image_url = row['image_url']
except (KeyError, IndexError):
image_url = None
# Safely get optional columns with defaults
image_url = row.get('image_url') if 'image_url' in existing_columns else None
include_albums = bool(row.get('include_albums', 1)) if 'include_albums' in existing_columns else True
include_eps = bool(row.get('include_eps', 1)) if 'include_eps' in existing_columns else True
include_singles = bool(row.get('include_singles', 1)) if 'include_singles' in existing_columns else True
watchlist_artists.append(WatchlistArtist(
id=row['id'],
@ -2613,11 +2623,14 @@ class MusicDatabase:
last_scan_timestamp=datetime.fromisoformat(row['last_scan_timestamp']) if row['last_scan_timestamp'] else None,
created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None,
image_url=image_url
image_url=image_url,
include_albums=include_albums,
include_eps=include_eps,
include_singles=include_singles
))
return watchlist_artists
except Exception as e:
logger.error(f"Error getting watchlist artists: {e}")
return []
@ -2643,6 +2656,14 @@ class MusicDatabase:
with self._get_connection() as conn:
cursor = conn.cursor()
# Check if image_url column exists (for migration compatibility)
cursor.execute("PRAGMA table_info(watchlist_artists)")
existing_columns = {column[1] for column in cursor.fetchall()}
if 'image_url' not in existing_columns:
logger.warning("image_url column does not exist in watchlist_artists table. Skipping update. Please restart the app to apply migrations.")
return False
cursor.execute("""
UPDATE watchlist_artists
SET image_url = ?, updated_at = CURRENT_TIMESTAMP

View file

@ -13011,6 +13011,10 @@ youtube_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefi
beatport_chart_states = {} # Key: url_hash, Value: persistent chart state
beatport_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="beatport_discovery")
# Global state for ListenBrainz playlist management (persistent across page reloads)
listenbrainz_playlist_states = {} # Key: playlist_mbid, Value: persistent playlist state
listenbrainz_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="listenbrainz_discovery")
@app.route('/api/youtube/parse', methods=['POST'])
def parse_youtube_playlist_endpoint():
"""Parse a YouTube playlist URL and return structured track data"""
@ -13412,6 +13416,226 @@ def _run_youtube_discovery_worker(url_hash):
state['status'] = 'error'
state['phase'] = 'fresh'
def _run_listenbrainz_discovery_worker(playlist_mbid):
"""Background worker for ListenBrainz Spotify discovery process"""
try:
state = listenbrainz_playlist_states[playlist_mbid]
playlist = state['playlist']
tracks = playlist['tracks']
print(f"🔍 Starting Spotify discovery for {len(tracks)} ListenBrainz tracks...")
if not spotify_client or not spotify_client.is_authenticated():
print("❌ Spotify client not authenticated")
state['status'] = 'error'
state['phase'] = 'fresh'
return
# Process each track for Spotify discovery
for i, track in enumerate(tracks):
try:
# Update progress
state['discovery_progress'] = int((i / len(tracks)) * 100)
# Get cleaned track data from ListenBrainz
cleaned_title = track['track_name']
cleaned_artist = track['artist_name']
album_name = track.get('album_name', '')
duration_ms = track.get('duration_ms', 0)
print(f"🔍 Searching Spotify for: '{cleaned_artist}' - '{cleaned_title}'")
# Try multiple search strategies using matching_engine for better accuracy
spotify_track = None
best_confidence = 0.0
min_confidence = 0.6 # Keep same threshold as YouTube
# Strategy 1: Use matching_engine search queries (with fallback)
try:
# Create a temporary SpotifyTrack-like object for the matching engine
temp_track = type('TempTrack', (), {
'name': cleaned_title,
'artists': [cleaned_artist],
'album': album_name if album_name else None
})()
search_queries = matching_engine.generate_download_queries(temp_track)
print(f"🔍 Generated {len(search_queries)} search queries for ListenBrainz track")
except Exception as e:
print(f"⚠️ Matching engine failed for ListenBrainz, falling back to basic query: {e}")
# Fallback to original simple query
search_queries = [f"artist:{cleaned_artist} track:{cleaned_title}"]
# Store raw Spotify data for best match
best_raw_track = None
for query_idx, search_query in enumerate(search_queries):
try:
print(f"🔍 ListenBrainz query {query_idx + 1}/{len(search_queries)}: {search_query}")
# Get raw Spotify API response to access full album object with images
raw_results = spotify_client.sp.search(q=search_query, type='track', limit=5)
if not raw_results or 'tracks' not in raw_results or not raw_results['tracks']['items']:
continue
spotify_results = spotify_client.search_tracks(search_query, limit=5)
if not spotify_results:
continue
# Score each result using matching engine
for result_idx, spotify_result in enumerate(spotify_results):
raw_track = raw_results['tracks']['items'][result_idx] if result_idx < len(raw_results['tracks']['items']) else None
try:
# Calculate confidence using matching engine's similarity scoring (with fallback)
try:
artist_confidence = 0.0
if spotify_result.artists:
# Get best artist match confidence
for result_artist in spotify_result.artists:
artist_sim = matching_engine.similarity_score(
matching_engine.normalize_string(cleaned_artist),
matching_engine.normalize_string(result_artist)
)
artist_confidence = max(artist_confidence, artist_sim)
# Calculate title confidence
title_confidence = matching_engine.similarity_score(
matching_engine.normalize_string(cleaned_title),
matching_engine.normalize_string(spotify_result.name)
)
# Combined confidence (70% title, 30% artist - same as YouTube)
combined_confidence = (title_confidence * 0.7 + artist_confidence * 0.3)
except Exception as e:
print(f"⚠️ Matching engine scoring failed for ListenBrainz, using basic similarity: {e}")
# Fallback to original character overlap method
def _calculate_similarity_fallback(str1, str2):
if not str1 or not str2:
return 0
str1 = str1.lower().strip()
str2 = str2.lower().strip()
if str1 == str2:
return 1.0
set1 = set(str1.replace(' ', ''))
set2 = set(str2.replace(' ', ''))
if not set1 or not set2:
return 0
intersection = len(set1.intersection(set2))
union = len(set1.union(set2))
return intersection / union if union > 0 else 0
title_score = _calculate_similarity_fallback(cleaned_title, spotify_result.name)
artist_score = _calculate_similarity_fallback(cleaned_artist, spotify_result.artists[0] if spotify_result.artists else "")
combined_confidence = (title_score * 0.7) + (artist_score * 0.3)
print(f"🔍 ListenBrainz candidate: '{spotify_result.artists[0]}' - '{spotify_result.name}' (confidence: {combined_confidence:.3f})")
# Update best match if this is better
if combined_confidence > best_confidence and combined_confidence >= min_confidence:
best_confidence = combined_confidence
spotify_track = spotify_result
best_raw_track = raw_track # Store raw data with full album object
print(f"✅ New best ListenBrainz match: {spotify_result.artists[0]} - {spotify_result.name} (confidence: {combined_confidence:.3f})")
except Exception as e:
print(f"❌ Error processing ListenBrainz search result: {e}")
continue
# If we found a very high confidence match, stop searching
if best_confidence >= 0.9:
print(f"🎯 High confidence ListenBrainz match found ({best_confidence:.3f}), stopping search")
break
except Exception as e:
print(f"❌ Error in ListenBrainz search for query '{search_query}': {e}")
continue
if spotify_track:
print(f"✅ Strategy 1 ListenBrainz match: {spotify_track.artists[0]} - {spotify_track.name} (confidence: {best_confidence:.3f})")
# Strategy 2: Swapped search (if first failed) - keep simple for fallback
if not spotify_track:
print("🔄 ListenBrainz Strategy 2: Trying swapped search (artist/title reversed)")
query = f"artist:{cleaned_title} track:{cleaned_artist}"
spotify_results = spotify_client.search_tracks(query, limit=3)
if spotify_results:
spotify_track = spotify_results[0]
print(f"✅ Strategy 2 ListenBrainz match (swapped): {spotify_track.artists[0]} - {spotify_track.name}")
# Strategy 3: Album-based search (if still failed and we have album name)
if not spotify_track and album_name:
print(f"🔄 ListenBrainz Strategy 3: Trying album-based search: '{cleaned_artist} {album_name} {cleaned_title}'")
query = f"artist:{cleaned_artist} album:{album_name} track:{cleaned_title}"
spotify_results = spotify_client.search_tracks(query, limit=3)
if spotify_results:
spotify_track = spotify_results[0]
print(f"✅ Strategy 3 ListenBrainz match (album): {spotify_track.artists[0]} - {spotify_track.name}")
# Create result entry
result = {
'index': i,
'lb_track': cleaned_title,
'lb_artist': cleaned_artist,
'status': '✅ Found' if spotify_track else '❌ Not Found',
'status_class': 'found' if spotify_track else 'not-found',
'spotify_track': spotify_track.name if spotify_track else '',
'spotify_artist': spotify_track.artists[0] if spotify_track else '',
'spotify_album': spotify_track.album if spotify_track else '',
'duration': f"{duration_ms // 60000}:{(duration_ms % 60000) // 1000:02d}" if duration_ms else '0:00'
}
if spotify_track:
state['spotify_matches'] += 1
# Use full album object from raw Spotify data if available
album_data = best_raw_track.get('album', {}) if best_raw_track else {}
if not album_data:
# Fallback to string album name
album_data = {'name': spotify_track.album, 'album_type': 'album', 'images': []}
result['spotify_data'] = {
'id': spotify_track.id,
'name': spotify_track.name,
'artists': spotify_track.artists,
'album': album_data, # Full album object with images
'duration_ms': spotify_track.duration_ms
}
state['discovery_results'].append(result)
print(f" {'' if spotify_track else ''} Track {i+1}/{len(tracks)}: {result['status']}")
except Exception as e:
print(f"❌ Error processing track {i}: {e}")
# Add failed result
result = {
'index': i,
'lb_track': track['track_name'],
'lb_artist': track['artist_name'],
'status': '❌ Error',
'status_class': 'error',
'spotify_track': '',
'spotify_artist': '',
'spotify_album': '',
'duration': '0:00'
}
state['discovery_results'].append(result)
# Complete discovery
state['phase'] = 'discovered'
state['status'] = 'complete'
state['discovery_progress'] = 100
# Add activity for discovery completion
playlist_name = playlist['name']
add_activity_item("", "ListenBrainz Discovery Complete", f"'{playlist_name}' - {state['spotify_matches']}/{len(tracks)} tracks found", "Now")
print(f"✅ ListenBrainz discovery complete: {state['spotify_matches']}/{len(tracks)} tracks matched")
except Exception as e:
print(f"❌ Error in ListenBrainz discovery worker: {e}")
state['status'] = 'error'
state['phase'] = 'fresh'
def _calculate_similarity(str1, str2):
"""Calculate string similarity using simple character overlap"""
if not str1 or not str2:
@ -16495,6 +16719,429 @@ def refresh_listenbrainz():
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
# ========================================
# LISTENBRAINZ PLAYLIST MANAGEMENT (Discovery System)
# ========================================
@app.route('/api/listenbrainz/playlists', methods=['GET'])
def get_all_listenbrainz_playlists():
"""Get all stored ListenBrainz playlists for frontend hydration"""
try:
playlists = []
current_time = time.time()
for playlist_mbid, state in listenbrainz_playlist_states.items():
# Update access time when requested
state['last_accessed'] = current_time
# Return essential data for card recreation
playlist_info = {
'playlist_mbid': playlist_mbid,
'playlist': state['playlist'],
'phase': state['phase'],
'status': state['status'],
'discovery_progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'),
'download_process_id': state.get('download_process_id'),
'created_at': state['created_at'],
'last_accessed': state['last_accessed']
}
playlists.append(playlist_info)
print(f"📋 Returning {len(playlists)} stored ListenBrainz playlists for hydration")
return jsonify({"playlists": playlists})
except Exception as e:
print(f"❌ Error getting ListenBrainz playlists: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/listenbrainz/state/<playlist_mbid>', methods=['GET'])
def get_listenbrainz_playlist_state(playlist_mbid):
"""Get specific ListenBrainz playlist state (detailed version)"""
try:
if playlist_mbid not in listenbrainz_playlist_states:
return jsonify({"error": "ListenBrainz playlist not found"}), 404
state = listenbrainz_playlist_states[playlist_mbid]
state['last_accessed'] = time.time()
# Return full state information (including results for modal hydration)
response = {
'playlist_mbid': playlist_mbid,
'playlist': state['playlist'],
'phase': state['phase'],
'status': state['status'],
'discovery_progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'discovery_results': state['discovery_results'],
'sync_playlist_id': state['sync_playlist_id'],
'converted_spotify_playlist_id': state['converted_spotify_playlist_id'],
'sync_progress': state['sync_progress'],
'created_at': state['created_at'],
'last_accessed': state['last_accessed']
}
return jsonify(response)
except Exception as e:
print(f"❌ Error getting ListenBrainz playlist state: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/listenbrainz/reset/<playlist_mbid>', methods=['POST'])
def reset_listenbrainz_playlist(playlist_mbid):
"""Reset ListenBrainz playlist to fresh phase (clear discovery/sync data)"""
try:
if playlist_mbid not in listenbrainz_playlist_states:
return jsonify({"error": "ListenBrainz playlist not found"}), 404
state = listenbrainz_playlist_states[playlist_mbid]
# Stop any active discovery
if 'discovery_future' in state and state['discovery_future']:
state['discovery_future'].cancel()
# Reset state to fresh (preserve original playlist data)
state['phase'] = 'fresh'
state['status'] = 'cached'
state['discovery_results'] = []
state['discovery_progress'] = 0
state['spotify_matches'] = 0
state['sync_playlist_id'] = None
state['converted_spotify_playlist_id'] = None
state['sync_progress'] = {}
state['discovery_future'] = None
state['last_accessed'] = time.time()
print(f"🔄 Reset ListenBrainz playlist to fresh: {state['playlist']['title']}")
return jsonify({"success": True, "phase": "fresh"})
except Exception as e:
print(f"❌ Error resetting ListenBrainz playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/listenbrainz/remove/<playlist_mbid>', methods=['POST'])
def remove_listenbrainz_playlist(playlist_mbid):
"""Remove ListenBrainz playlist from state (doesn't affect cache)"""
try:
if playlist_mbid not in listenbrainz_playlist_states:
return jsonify({"error": "ListenBrainz playlist not found"}), 404
state = listenbrainz_playlist_states[playlist_mbid]
# Stop any active discovery
if 'discovery_future' in state and state['discovery_future']:
state['discovery_future'].cancel()
# Remove from state
del listenbrainz_playlist_states[playlist_mbid]
print(f"🗑️ Removed ListenBrainz playlist from state: {playlist_mbid}")
return jsonify({"success": True})
except Exception as e:
print(f"❌ Error removing ListenBrainz playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/listenbrainz/discovery/start/<playlist_mbid>', methods=['POST'])
def start_listenbrainz_discovery(playlist_mbid):
"""Initialize and start Spotify discovery process for a ListenBrainz playlist"""
try:
data = request.get_json()
playlist_data = data.get('playlist')
if not playlist_data:
return jsonify({"error": "Playlist data required"}), 400
# Create or update state
if playlist_mbid not in listenbrainz_playlist_states:
# Initialize new state
listenbrainz_playlist_states[playlist_mbid] = {
'playlist_mbid': playlist_mbid,
'playlist': playlist_data,
'phase': 'discovering',
'status': 'discovering',
'discovery_progress': 0,
'spotify_matches': 0,
'spotify_total': len(playlist_data.get('tracks', [])),
'discovery_results': [],
'created_at': time.time(),
'last_accessed': time.time()
}
print(f"✅ Created new ListenBrainz playlist state: {playlist_data.get('name', 'Unknown')}")
else:
# State already exists, update it
state = listenbrainz_playlist_states[playlist_mbid]
if state['phase'] == 'discovering':
return jsonify({"error": "Discovery already in progress"}), 400
# Reset for new discovery
state['phase'] = 'discovering'
state['status'] = 'discovering'
state['discovery_progress'] = 0
state['spotify_matches'] = 0
state['discovery_results'] = []
state['last_accessed'] = time.time()
state = listenbrainz_playlist_states[playlist_mbid]
# Add activity for discovery start
playlist_name = playlist_data.get('name', 'Unknown Playlist')
track_count = len(playlist_data.get('tracks', []))
add_activity_item("🔍", "ListenBrainz Discovery Started", f"'{playlist_name}' - {track_count} tracks", "Now")
# Start discovery worker
future = listenbrainz_discovery_executor.submit(_run_listenbrainz_discovery_worker, playlist_mbid)
state['discovery_future'] = future
print(f"🔍 Started Spotify discovery for ListenBrainz playlist: {playlist_name}")
return jsonify({"success": True, "message": "Discovery started"})
except Exception as e:
print(f"❌ Error starting ListenBrainz discovery: {e}")
import traceback
traceback.print_exc()
return jsonify({"error": str(e)}), 500
@app.route('/api/listenbrainz/discovery/status/<playlist_mbid>', methods=['GET'])
def get_listenbrainz_discovery_status(playlist_mbid):
"""Get real-time discovery status for a ListenBrainz playlist"""
try:
if playlist_mbid not in listenbrainz_playlist_states:
return jsonify({"error": "ListenBrainz playlist not found"}), 404
state = listenbrainz_playlist_states[playlist_mbid]
state['last_accessed'] = time.time()
response = {
'phase': state['phase'],
'status': state['status'],
'progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'results': state['discovery_results'],
'complete': state['phase'] == 'discovered'
}
return jsonify(response)
except Exception as e:
print(f"❌ Error getting ListenBrainz discovery status: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/listenbrainz/discovery/update_match', methods=['POST'])
def update_listenbrainz_discovery_match():
"""Update a ListenBrainz discovery result with manually selected Spotify track"""
try:
data = request.get_json()
identifier = data.get('identifier') # playlist_mbid
track_index = data.get('track_index')
spotify_track = data.get('spotify_track')
if not identifier or track_index is None or not spotify_track:
return jsonify({'error': 'Missing required fields'}), 400
# Get the state
state = listenbrainz_playlist_states.get(identifier)
if not state:
return jsonify({'error': 'Discovery state not found'}), 404
# Update the discovery result
if track_index < len(state['discovery_results']):
result = state['discovery_results'][track_index]
# Was previously not found, now found
if result['status_class'] == 'not-found' and spotify_track:
state['spotify_matches'] += 1
# Was previously found, now not found
elif result['status_class'] == 'found' and not spotify_track:
state['spotify_matches'] -= 1
# Update result
result['status'] = '✅ Found' if spotify_track else '❌ Not Found'
result['status_class'] = 'found' if spotify_track else 'not-found'
result['spotify_track'] = spotify_track.get('name', '') if spotify_track else ''
result['spotify_artist'] = spotify_track.get('artists', [''])[0] if spotify_track and spotify_track.get('artists') else ''
result['spotify_album'] = spotify_track.get('album', {}).get('name', '') if spotify_track else ''
if spotify_track:
result['spotify_data'] = spotify_track
else:
result['spotify_data'] = None
print(f"✅ Updated ListenBrainz match for track {track_index}: {result['status']}")
return jsonify({'success': True})
else:
return jsonify({'error': 'Invalid track index'}), 400
except Exception as e:
print(f"❌ Error updating ListenBrainz discovery match: {e}")
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
def convert_listenbrainz_results_to_spotify_tracks(discovery_results):
"""Convert ListenBrainz discovery results to Spotify tracks format for sync"""
spotify_tracks = []
for result in discovery_results:
# Support both data formats: spotify_data (manual fixes) and individual fields (automatic discovery)
if result.get('spotify_data'):
spotify_data = result['spotify_data']
# Create track object matching the expected format
track = {
'id': spotify_data['id'],
'name': spotify_data['name'],
'artists': spotify_data['artists'],
'album': spotify_data['album'],
'duration_ms': spotify_data.get('duration_ms', 0)
}
spotify_tracks.append(track)
elif result.get('spotify_track') and result.get('status_class') == 'found':
# Build from individual fields (automatic discovery format)
track = {
'id': result.get('spotify_id', 'unknown'),
'name': result.get('spotify_track', 'Unknown Track'),
'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'],
'album': result.get('spotify_album', 'Unknown Album'),
'duration_ms': 0
}
spotify_tracks.append(track)
print(f"🔄 Converted {len(spotify_tracks)} ListenBrainz matches to Spotify tracks for sync")
return spotify_tracks
@app.route('/api/listenbrainz/sync/start/<playlist_mbid>', methods=['POST'])
def start_listenbrainz_sync(playlist_mbid):
"""Start sync process for a ListenBrainz playlist using discovered Spotify tracks"""
try:
if playlist_mbid not in listenbrainz_playlist_states:
return jsonify({"error": "ListenBrainz playlist not found"}), 404
state = listenbrainz_playlist_states[playlist_mbid]
state['last_accessed'] = time.time() # Update access time
if state['phase'] not in ['discovered', 'sync_complete']:
return jsonify({"error": "ListenBrainz playlist not ready for sync"}), 400
# Convert discovery results to Spotify tracks format
spotify_tracks = convert_listenbrainz_results_to_spotify_tracks(state['discovery_results'])
if not spotify_tracks:
return jsonify({"error": "No Spotify matches found for sync"}), 400
# Create a temporary playlist ID for sync tracking
sync_playlist_id = f"listenbrainz_{playlist_mbid}"
playlist_name = state['playlist']['name']
# Add activity for sync start
add_activity_item("🔄", "ListenBrainz Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now")
# Update ListenBrainz state
state['phase'] = 'syncing'
state['sync_playlist_id'] = sync_playlist_id
state['sync_progress'] = {}
# Start the sync using existing sync infrastructure
sync_data = {
'playlist_id': sync_playlist_id,
'playlist_name': f"[ListenBrainz] {playlist_name}",
'tracks': spotify_tracks
}
with sync_lock:
sync_states[sync_playlist_id] = {"status": "starting", "progress": {}}
# Submit sync task
future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks)
active_sync_workers[sync_playlist_id] = future
print(f"🔄 Started ListenBrainz sync for: {playlist_name} ({len(spotify_tracks)} tracks)")
return jsonify({"success": True, "sync_playlist_id": sync_playlist_id})
except Exception as e:
print(f"❌ Error starting ListenBrainz sync: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/listenbrainz/sync/status/<playlist_mbid>', methods=['GET'])
def get_listenbrainz_sync_status(playlist_mbid):
"""Get sync status for a ListenBrainz playlist"""
try:
if playlist_mbid not in listenbrainz_playlist_states:
return jsonify({"error": "ListenBrainz playlist not found"}), 404
state = listenbrainz_playlist_states[playlist_mbid]
state['last_accessed'] = time.time() # Update access time
sync_playlist_id = state.get('sync_playlist_id')
if not sync_playlist_id:
return jsonify({"error": "No sync in progress"}), 404
# Get sync status from existing sync infrastructure
with sync_lock:
sync_state = sync_states.get(sync_playlist_id, {})
response = {
'phase': state['phase'],
'sync_status': sync_state.get('status', 'unknown'),
'progress': sync_state.get('progress', {}),
'complete': sync_state.get('status') == 'finished',
'error': sync_state.get('error')
}
# Update ListenBrainz state if sync completed
if sync_state.get('status') == 'finished':
state['phase'] = 'sync_complete'
state['sync_progress'] = sync_state.get('progress', {})
# Add activity for sync completion
playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist')
add_activity_item("🔄", "Sync Complete", f"ListenBrainz playlist '{playlist_name}' synced successfully", "Now")
elif sync_state.get('status') == 'error':
state['phase'] = 'discovered' # Revert on error
playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist')
add_activity_item("", "Sync Failed", f"ListenBrainz playlist '{playlist_name}' sync failed", "Now")
return jsonify(response)
except Exception as e:
print(f"❌ Error getting ListenBrainz sync status: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/listenbrainz/sync/cancel/<playlist_mbid>', methods=['POST'])
def cancel_listenbrainz_sync(playlist_mbid):
"""Cancel sync for a ListenBrainz playlist"""
try:
if playlist_mbid not in listenbrainz_playlist_states:
return jsonify({"error": "ListenBrainz playlist not found"}), 404
state = listenbrainz_playlist_states[playlist_mbid]
state['last_accessed'] = time.time() # Update access time
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
# Cancel the sync using existing sync infrastructure
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
# Clean up sync worker
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
# Revert ListenBrainz state
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "ListenBrainz sync cancelled"})
except Exception as e:
print(f"❌ Error cancelling ListenBrainz sync: {e}")
return jsonify({"error": str(e)}), 500
# OLD ENDPOINT - REMOVE ALL THE CODE BELOW FOR THE OLD IMPLEMENTATION
def _old_get_listenbrainz_playlist_tracks_DEPRECATED(playlist_mbid):

View file

@ -52,6 +52,10 @@ let tidalPlaylistsLoaded = false;
// --- Beatport Chart State Management (Similar to YouTube/Tidal) ---
let beatportChartStates = {}; // Key: chart_hash, Value: chart state with phases
// --- ListenBrainz Playlist State Management (Similar to YouTube/Tidal/Beatport) ---
let listenbrainzPlaylistStates = {}; // Key: playlist_mbid, Value: playlist state with phases
let listenbrainzPlaylistsLoaded = false; // Track if playlists have been loaded from backend
// --- Artists Page State Management ---
let artistsPageState = {
currentView: 'search', // 'search', 'results', 'detail'
@ -3064,6 +3068,104 @@ async function loadBeatportChartsFromBackend() {
}
}
async function loadListenBrainzPlaylistsFromBackend() {
// Load all stored ListenBrainz playlist states from backend for persistence (similar to Beatport hydration)
try {
console.log('📋 Loading ListenBrainz playlists from backend...');
const response = await fetch('/api/listenbrainz/playlists');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch ListenBrainz playlists');
}
const data = await response.json();
const playlists = data.playlists || [];
console.log(`🎵 Found ${playlists.length} stored ListenBrainz playlists in backend`);
if (playlists.length === 0) {
console.log('📋 No ListenBrainz playlists to hydrate');
listenbrainzPlaylistsLoaded = true;
return;
}
// Restore state for each playlist
for (const playlistInfo of playlists) {
const playlistMbid = playlistInfo.playlist_mbid;
console.log(`🎵 Hydrating ListenBrainz playlist: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase})`);
// Fetch full state for non-fresh playlists to restore discovery results
if (playlistInfo.phase !== 'fresh') {
try {
console.log(`🔍 Fetching full state for: ${playlistInfo.playlist.name}`);
const stateResponse = await fetch(`/api/listenbrainz/state/${playlistMbid}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
// Transform backend results to frontend format (like Beatport does)
const transformedResults = (fullState.discovery_results || []).map((result, index) => ({
index: result.index !== undefined ? result.index : index,
yt_track: result.lb_track || result.track_name || 'Unknown',
yt_artist: result.lb_artist || result.artist_name || 'Unknown',
status: result.status === 'found' || result.status === '✅ Found' || result.status_class === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'),
status_class: result.status_class || (result.status === 'found' || result.status === '✅ Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data && result.spotify_data.artists ?
(Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists[0] : result.spotify_data.artists) : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'),
spotify_data: result.spotify_data,
duration: result.duration || '0:00'
}));
// Create ListenBrainz state with both naming conventions
listenbrainzPlaylistStates[playlistMbid] = {
phase: fullState.phase,
playlist: fullState.playlist,
is_listenbrainz_playlist: true,
playlist_mbid: playlistMbid,
// Store with both naming conventions
discovery_results: fullState.discovery_results || [],
discoveryResults: transformedResults,
discovery_progress: fullState.discovery_progress || 0,
discoveryProgress: fullState.discovery_progress || 0,
spotify_matches: fullState.spotify_matches || 0,
spotifyMatches: fullState.spotify_matches || 0,
spotify_total: fullState.spotify_total || 0,
spotifyTotal: fullState.spotify_total || 0,
convertedSpotifyPlaylistId: fullState.converted_spotify_playlist_id,
download_process_id: fullState.download_process_id
};
console.log(`✅ Restored ${transformedResults.length} discovery results for: ${playlistInfo.playlist.name}`);
} else {
console.warn(`⚠️ Could not fetch full state for: ${playlistInfo.playlist.name}`);
}
} catch (error) {
console.warn(`⚠️ Error fetching full state for ${playlistInfo.playlist.name}:`, error.message);
}
}
}
// Start polling for any playlists that are still in discovering phase
for (const playlistInfo of playlists) {
if (playlistInfo.phase === 'discovering') {
console.log(`🔄 [Backend Loading] Auto-starting polling for discovering playlist: ${playlistInfo.playlist.name}`);
startListenBrainzDiscoveryPolling(playlistInfo.playlist_mbid);
}
}
listenbrainzPlaylistsLoaded = true;
console.log(`✅ Successfully loaded and rehydrated ${playlists.length} ListenBrainz playlists`);
} catch (error) {
console.error('❌ Error loading ListenBrainz playlists from backend:', error);
listenbrainzPlaylistsLoaded = true; // Mark as loaded even on error to prevent retries
}
}
function createBeatportCardFromBackendState(chartInfo) {
// Create Beatport chart card from backend state data
const chartHash = chartInfo.hash;
@ -7927,7 +8029,7 @@ function openDiscoveryFixModal(platform, identifier, trackIndex) {
console.log(`🔧 Opening fix modal: ${platform} - ${identifier} - track ${trackIndex}`);
// Get the discovery state
// Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results
// Note: Beatport, Tidal, and ListenBrainz have their own states, but reuse YouTube modal infrastructure
let state, result;
if (platform === 'youtube') {
state = youtubePlaylistStates[identifier];
@ -7935,6 +8037,8 @@ function openDiscoveryFixModal(platform, identifier, trackIndex) {
state = youtubePlaylistStates[identifier]; // Tidal uses YouTube state infrastructure
} else if (platform === 'beatport') {
state = youtubePlaylistStates[identifier]; // Beatport uses YouTube state infrastructure
} else if (platform === 'listenbrainz') {
state = listenbrainzPlaylistStates[identifier]; // ListenBrainz has its own state
}
// Support both camelCase and snake_case for discovery results
@ -15552,14 +15656,15 @@ function stopYouTubeDiscoveryPolling(urlHash) {
}
function openYouTubeDiscoveryModal(urlHash) {
const state = youtubePlaylistStates[urlHash];
// Check ListenBrainz state first, then fallback to YouTube state
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
if (!state || !state.playlist) {
console.error('❌ No YouTube playlist data found for hash:', urlHash);
console.error('❌ No playlist data found for identifier:', urlHash);
return;
}
console.log('🎵 Opening YouTube discovery modal for:', state.playlist.name);
console.log('🎵 Opening discovery modal for:', state.playlist.name);
// Check if modal already exists
let modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
@ -15579,19 +15684,24 @@ function openYouTubeDiscoveryModal(urlHash) {
startTidalSyncPolling(urlHash);
} else if (state.is_beatport_playlist) {
startBeatportSyncPolling(urlHash);
} else if (state.is_listenbrainz_playlist) {
startListenBrainzSyncPolling(urlHash);
} else {
startYouTubeSyncPolling(urlHash);
}
}
} else {
// Create new modal (support YouTube, Tidal, and Beatport like sync.py)
// Create new modal (support YouTube, Tidal, Beatport, and ListenBrainz)
const isTidal = state.is_tidal_playlist;
const isBeatport = state.is_beatport_playlist;
const isListenBrainz = state.is_listenbrainz_playlist;
const modalTitle = isTidal ? '🎵 Tidal Playlist Discovery' :
isBeatport ? '🎵 Beatport Chart Discovery' :
isListenBrainz ? '🎵 ListenBrainz Playlist Discovery' :
'🎵 YouTube Playlist Discovery';
const sourceLabel = isTidal ? 'Tidal' :
isBeatport ? 'Beatport' :
isListenBrainz ? 'LB' :
'YT';
const modalHtml = `
@ -15600,17 +15710,17 @@ function openYouTubeDiscoveryModal(urlHash) {
<div class="modal-header">
<h2>${modalTitle}</h2>
<div class="modal-subtitle">${state.playlist.name} (${state.playlist.tracks.length} tracks)</div>
<div class="modal-description">${getModalDescription(state.phase, isTidal, isBeatport)}</div>
<div class="modal-description">${getModalDescription(state.phase, isTidal, isBeatport, isListenBrainz)}</div>
<button class="modal-close-btn" onclick="closeYouTubeDiscoveryModal('${urlHash}')"></button>
</div>
<div class="modal-body">
<div class="progress-section">
<div class="progress-label">🔍 Spotify Discovery Progress</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="youtube-discovery-progress-${urlHash}" style="width: 0%;"></div>
</div>
<div class="progress-text" id="youtube-discovery-progress-text-${urlHash}">${getInitialProgressText(state.phase, isTidal, isBeatport)}</div>
<div class="progress-text" id="youtube-discovery-progress-text-${urlHash}">${getInitialProgressText(state.phase, isTidal, isBeatport, isListenBrainz)}</div>
</div>
<div class="discovery-table-container">
@ -15741,18 +15851,37 @@ function openYouTubeDiscoveryModal(urlHash) {
function getModalActionButtons(urlHash, phase, state = null) {
// Get state if not provided
if (!state) {
state = youtubePlaylistStates[urlHash];
state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
}
const isTidal = state && state.is_tidal_playlist;
const isBeatport = state && state.is_beatport_playlist;
const isListenBrainz = state && state.is_listenbrainz_playlist;
// Validate data availability for buttons
const hasDiscoveryResults = state && state.discoveryResults && state.discoveryResults.length > 0;
const hasSpotifyMatches = state && state.spotifyMatches > 0;
// Validate data availability for buttons (support both naming conventions)
const hasDiscoveryResults = state && ((state.discoveryResults && state.discoveryResults.length > 0) || (state.discovery_results && state.discovery_results.length > 0));
const hasSpotifyMatches = state && ((state.spotifyMatches > 0) || (state.spotify_matches > 0));
const hasConvertedPlaylistId = state && state.convertedSpotifyPlaylistId;
switch (phase) {
case 'fresh':
case 'discovering':
// Show start discovery button for fresh playlists
if (phase === 'fresh') {
if (isListenBrainz) {
return `<button class="modal-btn modal-btn-primary" onclick="startListenBrainzDiscovery('${urlHash}')">🔍 Start Discovery</button>`;
} else if (isTidal) {
return `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDiscovery('${urlHash}')">🔍 Start Discovery</button>`;
} else if (isBeatport) {
return `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDiscovery('${urlHash}')">🔍 Start Discovery</button>`;
} else {
return `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDiscovery('${urlHash}')">🔍 Start Discovery</button>`;
}
} else {
// Discovering phase - show progress
return `<div class="modal-info">🔍 Discovering Spotify matches...</div>`;
}
case 'discovered':
// Only show buttons if we actually have discovery data
if (!hasDiscoveryResults) {
@ -15763,7 +15892,9 @@ function getModalActionButtons(urlHash, phase, state = null) {
// Only show sync button if there are Spotify matches
if (hasSpotifyMatches) {
if (isTidal) {
if (isListenBrainz) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startListenBrainzPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
} else if (isTidal) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startTidalPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
} else if (isBeatport) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startBeatportPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
@ -15771,10 +15902,13 @@ function getModalActionButtons(urlHash, phase, state = null) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubePlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
}
}
// Only show download button if we have matches or a converted playlist ID
if (hasSpotifyMatches || hasConvertedPlaylistId) {
if (isTidal) {
if (isListenBrainz) {
// ListenBrainz uses same download function as others (to be implemented)
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else if (isTidal) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startTidalDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else if (isBeatport) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startBeatportDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
@ -15790,7 +15924,19 @@ function getModalActionButtons(urlHash, phase, state = null) {
return buttons;
case 'syncing':
if (isTidal) {
if (isListenBrainz) {
return `
<button class="modal-btn modal-btn-danger" onclick="cancelYouTubeSync('${urlHash}')"> Cancel Sync</button>
<div class="playlist-modal-sync-status" id="listenbrainz-sync-status-${urlHash}" style="display: flex;">
<span class="sync-stat total-tracks"> <span id="listenbrainz-total-${urlHash}">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat matched-tracks"> <span id="listenbrainz-matched-${urlHash}">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat failed-tracks"> <span id="listenbrainz-failed-${urlHash}">0</span></span>
<span class="sync-stat percentage">(<span id="listenbrainz-percentage-${urlHash}">0</span>%)</span>
</div>
`;
} else if (isTidal) {
return `
<button class="modal-btn modal-btn-danger" onclick="cancelTidalSync('${urlHash}')"> Cancel Sync</button>
<div class="playlist-modal-sync-status" id="tidal-sync-status-${urlHash}" style="display: flex;">
@ -15830,10 +15976,12 @@ function getModalActionButtons(urlHash, phase, state = null) {
case 'sync_complete':
let syncCompleteButtons = '';
// Only show sync button if there are Spotify matches
if (hasSpotifyMatches) {
if (isTidal) {
if (isListenBrainz) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startListenBrainzPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
} else if (isTidal) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startTidalPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
} else if (isBeatport) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startBeatportPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
@ -15841,10 +15989,12 @@ function getModalActionButtons(urlHash, phase, state = null) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubePlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
}
}
// Only show download button if we have matches or a converted playlist ID
if (hasSpotifyMatches || hasConvertedPlaylistId) {
if (isTidal) {
if (isListenBrainz) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else if (isTidal) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startTidalDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else if (isBeatport) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startBeatportDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
@ -15852,8 +16002,10 @@ function getModalActionButtons(urlHash, phase, state = null) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
}
}
if (isTidal) {
if (isListenBrainz) {
// ListenBrainz playlists don't need reset (they're read-only from ListenBrainz API)
} else if (isTidal) {
// Tidal doesn't have a reset function yet, but could be added
// syncCompleteButtons += `<button class="modal-btn modal-btn-secondary" onclick="resetTidalPlaylist('${urlHash}')">🔄 Reset</button>`;
} else if (isBeatport) {
@ -15861,7 +16013,7 @@ function getModalActionButtons(urlHash, phase, state = null) {
} else {
syncCompleteButtons += `<button class="modal-btn modal-btn-secondary" onclick="resetYouTubePlaylist('${urlHash}')">🔄 Reset</button>`;
}
return syncCompleteButtons;
default:
@ -15869,8 +16021,8 @@ function getModalActionButtons(urlHash, phase, state = null) {
}
}
function getModalDescription(phase, isTidal = false, isBeatport = false) {
const source = isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube');
function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false) {
const source = isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'));
switch (phase) {
case 'fresh':
return `Ready to discover clean Spotify metadata for ${source} tracks...`;
@ -15883,7 +16035,7 @@ function getModalDescription(phase, isTidal = false, isBeatport = false) {
}
}
function getInitialProgressText(phase, isTidal = false, isBeatport = false) {
function getInitialProgressText(phase, isTidal = false, isBeatport = false, isListenBrainz = false) {
switch (phase) {
case 'fresh':
return 'Click Start Discovery to begin...';
@ -15899,42 +16051,64 @@ function getInitialProgressText(phase, isTidal = false, isBeatport = false) {
function generateTableRowsFromState(state, urlHash) {
const isTidal = state.is_tidal_playlist;
const isBeatport = state.is_beatport_playlist;
const platform = isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube');
const isListenBrainz = state.is_listenbrainz_playlist;
const platform = isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube'));
// Support both camelCase and snake_case
const discoveryResults = state.discoveryResults || state.discovery_results;
if (discoveryResults && discoveryResults.length > 0) {
// Generate rows from existing discovery results
return discoveryResults.map((result, index) => `
return discoveryResults.map((result, index) => {
// Handle different field names based on platform
const trackName = result.lb_track || result.yt_track || result.track_name || '-';
const artistName = result.lb_artist || result.yt_artist || result.artist_name || '-';
return `
<tr id="discovery-row-${urlHash}-${result.index}">
<td class="yt-track">${result.yt_track}</td>
<td class="yt-artist">${result.yt_artist}</td>
<td class="yt-track">${trackName}</td>
<td class="yt-artist">${artistName}</td>
<td class="discovery-status ${result.status_class}">${result.status}</td>
<td class="spotify-track">${result.spotify_track || '-'}</td>
<td class="spotify-artist">${result.spotify_artist || '-'}</td>
<td class="spotify-album">${result.spotify_album || '-'}</td>
<td class="discovery-actions">${generateDiscoveryActionButton(result, urlHash, platform)}</td>
</tr>
`).join('');
`;
}).join('');
} else {
// Generate initial rows from playlist tracks
return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash, isBeatport);
return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash, isBeatport, isListenBrainz);
}
}
function generateInitialTableRows(tracks, isTidal = false, urlHash = '', isBeatport = false) {
return tracks.map((track, index) => `
function generateInitialTableRows(tracks, isTidal = false, urlHash = '', isBeatport = false, isListenBrainz = false) {
return tracks.map((track, index) => {
// Handle different track formats based on platform
let trackName, artistName;
if (isListenBrainz) {
// ListenBrainz tracks have track_name and artist_name
trackName = track.track_name || 'Unknown Track';
artistName = track.artist_name || 'Unknown Artist';
} else {
// YouTube/Tidal/Beatport tracks have name and artists
trackName = track.name || 'Unknown Track';
artistName = track.artists ? (Array.isArray(track.artists) ? track.artists.join(', ') : track.artists) : 'Unknown Artist';
}
return `
<tr id="discovery-row-${urlHash}-${index}">
<td class="yt-track">${track.name}</td>
<td class="yt-artist">${track.artists ? (Array.isArray(track.artists) ? track.artists.join(', ') : track.artists) : 'Unknown Artist'}</td>
<td class="yt-track">${trackName}</td>
<td class="yt-artist">${artistName}</td>
<td class="discovery-status">🔍 Pending...</td>
<td class="spotify-track">-</td>
<td class="spotify-artist">-</td>
<td class="spotify-album">-</td>
<td class="discovery-actions">-</td>
</tr>
`).join('');
`;
}).join('');
}
function formatDuration(durationMs) {
@ -16383,9 +16557,10 @@ function updateYouTubeModalButtons(urlHash, phase) {
async function startYouTubeDownloadMissing(urlHash) {
try {
console.log('🔍 Starting download missing tracks for YouTube playlist:', urlHash);
console.log('🔍 Starting download missing tracks:', urlHash);
const state = youtubePlaylistStates[urlHash];
// Check both YouTube and ListenBrainz states (like Beatport does)
const state = youtubePlaylistStates[urlHash] || listenbrainzPlaylistStates[urlHash];
// Support both camelCase and snake_case
const discoveryResults = state?.discoveryResults || state?.discovery_results;
@ -16394,7 +16569,13 @@ async function startYouTubeDownloadMissing(urlHash) {
return;
}
// Convert YouTube results to a format compatible with the download modal
// Determine source type
const isListenBrainz = state.is_listenbrainz_playlist;
const isBeatport = state.is_beatport_playlist;
const isTidal = state.is_tidal_playlist;
const sourcePrefix = isListenBrainz ? '[ListenBrainz]' : (isBeatport ? '[Beatport]' : (isTidal ? '[Tidal]' : '[YouTube]'));
// Convert discovery results to a format compatible with the download modal
const spotifyTracks = discoveryResults
.filter(result => result.spotify_data || (result.spotify_track && result.status_class === 'found'))
.map(result => {
@ -16421,15 +16602,15 @@ async function startYouTubeDownloadMissing(urlHash) {
};
}
});
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
// Create a virtual playlist for the download system
const virtualPlaylistId = `youtube_${urlHash}`;
const playlistName = `[YouTube] ${state.playlist.name}`;
const virtualPlaylistId = isListenBrainz ? `listenbrainz_${urlHash}` : (isBeatport ? `beatport_${urlHash}` : (isTidal ? `tidal_${urlHash}` : `youtube_${urlHash}`));
const playlistName = `${sourcePrefix} ${state.playlist.name}`;
// Store reference for card navigation
state.convertedSpotifyPlaylistId = virtualPlaylistId;
@ -16573,6 +16754,241 @@ async function resetBeatportChart(urlHash) {
}
}
// ============================================================================
// LISTENBRAINZ PLAYLIST DISCOVERY & SYNC
// ============================================================================
function startListenBrainzDiscoveryPolling(playlistMbid) {
console.log(`🔄 Starting ListenBrainz discovery polling for: ${playlistMbid}`);
// Stop any existing polling (reuse YouTube polling infrastructure)
if (activeYouTubePollers[playlistMbid]) {
clearInterval(activeYouTubePollers[playlistMbid]);
}
const pollInterval = setInterval(async () => {
try {
const response = await fetch(`/api/listenbrainz/discovery/status/${playlistMbid}`);
const status = await response.json();
if (status.error) {
console.error('❌ Error polling ListenBrainz discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
return;
}
// Update state and modal (reuse YouTube infrastructure like Beatport/Tidal)
if (listenbrainzPlaylistStates[playlistMbid]) {
// Transform ListenBrainz results to YouTube modal format (like Beatport does)
const transformedStatus = {
progress: status.progress || 0,
spotify_matches: status.spotify_matches || 0,
spotify_total: status.spotify_total || 0,
results: (status.results || []).map((result, index) => ({
index: result.index !== undefined ? result.index : index,
yt_track: result.lb_track || result.track_name || 'Unknown',
yt_artist: result.lb_artist || result.artist_name || 'Unknown',
status: result.status === 'found' || result.status === '✅ Found' || result.status_class === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'),
status_class: result.status_class || (result.status === 'found' || result.status === '✅ Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data ? (result.spotify_data.artists && result.spotify_data.artists[0] ? result.spotify_data.artists[0] : '-') : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? (result.spotify_data.album && result.spotify_data.album.name ? result.spotify_data.album.name : '-') : (result.spotify_album || '-'),
spotify_data: result.spotify_data,
duration: result.duration || '0:00'
})),
complete: status.complete || status.phase === 'discovered'
};
// Store both raw and transformed results (support both naming conventions)
listenbrainzPlaylistStates[playlistMbid].discovery_results = status.results || [];
listenbrainzPlaylistStates[playlistMbid].discoveryResults = transformedStatus.results;
listenbrainzPlaylistStates[playlistMbid].discovery_progress = status.progress || 0;
listenbrainzPlaylistStates[playlistMbid].discoveryProgress = status.progress || 0;
listenbrainzPlaylistStates[playlistMbid].spotify_matches = status.spotify_matches || 0;
listenbrainzPlaylistStates[playlistMbid].spotifyMatches = status.spotify_matches || 0; // camelCase for modal
listenbrainzPlaylistStates[playlistMbid].spotify_total = status.spotify_total || 0;
listenbrainzPlaylistStates[playlistMbid].spotifyTotal = status.spotify_total || 0; // camelCase for modal
// Update modal if open
updateYouTubeDiscoveryModal(playlistMbid, transformedStatus);
}
// Check if complete
if (status.complete || status.phase === 'discovered') {
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
// Update phase
if (listenbrainzPlaylistStates[playlistMbid]) {
listenbrainzPlaylistStates[playlistMbid].phase = 'discovered';
}
// Update modal buttons to show sync and download buttons
updateYouTubeModalButtons(playlistMbid, 'discovered');
console.log('✅ ListenBrainz discovery complete:', playlistMbid);
showToast('ListenBrainz discovery complete!', 'success');
}
} catch (error) {
console.error('❌ Error polling ListenBrainz discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
}
}, 1000);
activeYouTubePollers[playlistMbid] = pollInterval;
}
function startListenBrainzSyncPolling(playlistMbid) {
// Stop any existing polling
if (activeYouTubePollers[playlistMbid]) {
clearInterval(activeYouTubePollers[playlistMbid]);
}
// Define the polling function
const pollFunction = async () => {
try {
const response = await fetch(`/api/listenbrainz/sync/status/${playlistMbid}`);
const status = await response.json();
if (status.error) {
console.error('❌ Error polling ListenBrainz sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
return;
}
// Update modal sync display if open
updateYouTubeModalSyncProgress(playlistMbid, status.progress);
// Check if complete
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
// Update modal buttons
updateYouTubeModalButtons(playlistMbid, 'sync_complete');
console.log('✅ ListenBrainz sync complete:', playlistMbid);
showToast('ListenBrainz playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
// Revert to discovered phase on error
updateYouTubeModalButtons(playlistMbid, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('❌ Error polling ListenBrainz sync:', error);
if (activeYouTubePollers[playlistMbid]) {
clearInterval(activeYouTubePollers[playlistMbid]);
delete activeYouTubePollers[playlistMbid];
}
}
};
// Run immediately to get current status
pollFunction();
// Then continue polling at regular intervals
const pollInterval = setInterval(pollFunction, 1000);
activeYouTubePollers[playlistMbid] = pollInterval;
}
async function startListenBrainzDiscovery(playlistMbid) {
const state = listenbrainzPlaylistStates[playlistMbid];
if (!state) {
console.error('❌ No ListenBrainz playlist state found');
return;
}
try {
console.log('🔍 Starting ListenBrainz discovery for:', state.playlist.name);
// Update local phase to discovering
state.phase = 'discovering';
state.status = 'discovering';
// Call backend to start discovery worker
const response = await fetch(`/api/listenbrainz/discovery/start/${playlistMbid}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
playlist: state.playlist
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to start discovery');
}
console.log('✅ ListenBrainz discovery started on backend');
// Start polling for progress
startListenBrainzDiscoveryPolling(playlistMbid);
// Update modal to show discovering state
updateYouTubeDiscoveryModal(playlistMbid, {
phase: 'discovering',
progress: 0,
results: []
});
showToast('Starting ListenBrainz discovery...', 'info');
} catch (error) {
console.error('❌ Error starting ListenBrainz discovery:', error);
showToast(`Error: ${error.message}`, 'error');
// Revert phase on error
state.phase = 'fresh';
state.status = 'pending';
}
}
async function startListenBrainzPlaylistSync(playlistMbid) {
const state = listenbrainzPlaylistStates[playlistMbid];
if (!state) {
console.error('❌ No ListenBrainz playlist state found');
return;
}
try {
console.log('🔄 Starting ListenBrainz sync for:', state.playlist.name);
// Call backend to start sync
const response = await fetch(`/api/listenbrainz/sync/start/${playlistMbid}`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to start sync');
}
// Update phase to syncing
state.phase = 'syncing';
// Start polling for sync progress
startListenBrainzSyncPolling(playlistMbid);
// Update modal
updateYouTubeModalButtons(playlistMbid, 'syncing');
showToast('Starting ListenBrainz sync...', 'info');
} catch (error) {
console.error('❌ Error starting ListenBrainz sync:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// ============================================================================
// ARTISTS PAGE FUNCTIONALITY - ELEGANT SEARCH & DISCOVERY
// ============================================================================
@ -25937,7 +26353,8 @@ async function loadDiscoverPage() {
loadFamiliarFavorites(), // NEW: Familiar Favorites
initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed)
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
loadGenreBrowserTabs() // Browse by Genre (tabbed by genre)
loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre)
loadListenBrainzPlaylistsFromBackend() // Load ListenBrainz playlist states for persistence
]);
// Check for active syncs after page load
@ -27940,29 +28357,182 @@ async function openDownloadModalForListenBrainzPlaylist(identifier, title) {
return;
}
console.log(`📥 Opening download modal for ListenBrainz playlist: ${title}`);
console.log(`🎵 Opening ListenBrainz discovery modal: ${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: [cleanArtistName(track.artist_name)], // Clean featured artists
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
// Check if state already exists from backend hydration (like Beatport does)
const existingState = listenbrainzPlaylistStates[identifier];
if (existingState && existingState.phase !== 'fresh') {
// State exists - rehydrate the modal with existing data
console.log(`🔄 Rehydrating existing ListenBrainz state (Phase: ${existingState.phase})`);
// If downloading/download_complete, rehydrate download modal instead
if ((existingState.phase === 'downloading' || existingState.phase === 'download_complete') &&
existingState.convertedSpotifyPlaylistId && existingState.download_process_id) {
console.log(`📥 Rehydrating download modal for ListenBrainz playlist: ${title}`);
// Implement download modal rehydration (like Beatport does)
const convertedPlaylistId = existingState.convertedSpotifyPlaylistId;
try {
// Create the download modal using the ListenBrainz state
if (!activeDownloadProcesses[convertedPlaylistId]) {
// Get tracks from the existing state
let spotifyTracks = [];
if (existingState && existingState.discovery_results) {
spotifyTracks = existingState.discovery_results
.filter(result => result.spotify_data)
.map(result => {
const track = result.spotify_data;
// Ensure artists is an array of strings
if (track.artists && Array.isArray(track.artists)) {
track.artists = track.artists.map(artist =>
typeof artist === 'string' ? artist : (artist.name || artist)
);
} else if (track.artists && typeof track.artists === 'string') {
track.artists = [track.artists];
} else {
track.artists = ['Unknown Artist'];
}
return {
id: track.id,
name: track.name,
artists: track.artists,
album: track.album || 'Unknown Album',
duration_ms: track.duration_ms || 0,
external_urls: track.external_urls || {}
};
});
}
if (spotifyTracks.length > 0) {
await openDownloadMissingModalForYouTube(
convertedPlaylistId,
`[ListenBrainz] ${title}`,
spotifyTracks
);
// Set the modal to running state with the correct batch ID
const process = activeDownloadProcesses[convertedPlaylistId];
if (process) {
process.status = existingState.phase === 'download_complete' ? 'complete' : 'running';
process.batchId = existingState.download_process_id;
// Update UI to running state
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for this process
startModalDownloadPolling(convertedPlaylistId);
// Hide modal since this is background rehydration
process.modalElement.style.display = 'none';
console.log(`✅ Rehydrated download modal for ListenBrainz playlist: ${title}`);
}
} else {
console.warn(`⚠️ No Spotify tracks found for ListenBrainz download modal: ${title}`);
}
}
} catch (error) {
console.warn(`⚠️ Error setting up download process for ListenBrainz playlist "${title}":`, error.message);
}
return;
}
// Open discovery modal with existing state
openYouTubeDiscoveryModal(identifier);
// If still discovering, resume polling
if (existingState.phase === 'discovering') {
console.log(`🔄 Resuming discovery polling for: ${title}`);
startListenBrainzDiscoveryPolling(identifier);
}
return;
}
// No existing state - create fresh state and start discovery
console.log(`🆕 Creating fresh ListenBrainz state for: ${title}`);
// Create YouTube-style state entry for this ListenBrainz playlist (like Beatport does)
const listenbrainzState = {
phase: 'fresh',
playlist: {
name: title,
tracks: tracks.map(track => ({
track_name: track.track_name,
artist_name: track.artist_name,
album_name: track.album_name,
duration_ms: track.duration_ms || 0,
mbid: track.mbid,
release_mbid: track.release_mbid,
album_cover_url: track.album_cover_url
})),
description: `${tracks.length} tracks from ${title}`,
source: 'listenbrainz'
},
duration_ms: track.duration_ms || 0,
mbid: track.mbid // Include MusicBrainz ID for matching
}));
is_listenbrainz_playlist: true,
playlist_mbid: identifier, // Link to ListenBrainz playlist
// Initialize discovery state properties (both naming conventions for modal compatibility)
discovery_results: [],
discoveryResults: [],
discovery_progress: 0,
discoveryProgress: 0,
spotify_matches: 0,
spotifyMatches: 0,
spotify_total: tracks.length,
spotifyTotal: tracks.length
};
const virtualPlaylistId = `discover_lb_${identifier}`;
// Store in ListenBrainz playlist states
listenbrainzPlaylistStates[identifier] = listenbrainzState;
// Open the download modal
await openDownloadMissingModalForYouTube(virtualPlaylistId, title, spotifyTracks);
// Start discovery automatically (like Beatport and Tidal do)
try {
console.log(`🔍 Starting ListenBrainz discovery for: ${title}`);
// Call the discovery start endpoint with playlist data
const response = await fetch(`/api/listenbrainz/discovery/start/${identifier}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
playlist: listenbrainzState.playlist
})
});
const result = await response.json();
if (result.success) {
// Update state to discovering
listenbrainzPlaylistStates[identifier].phase = 'discovering';
// Start polling for progress
startListenBrainzDiscoveryPolling(identifier);
console.log(`✅ Started ListenBrainz discovery for: ${title}`);
} else {
console.error('❌ Error starting ListenBrainz discovery:', result.error);
showToast(`Error starting discovery: ${result.error}`, 'error');
}
} catch (error) {
console.error('❌ Error starting ListenBrainz discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
}
// Open the existing YouTube discovery modal infrastructure
openYouTubeDiscoveryModal(identifier);
console.log(`✅ ListenBrainz discovery modal opened for ${title} with ${tracks.length} tracks`);
} catch (error) {
console.error('Error opening download modal for ListenBrainz playlist:', error);
showToast('Failed to open download modal', 'error');
console.error('Error opening discovery modal for ListenBrainz playlist:', error);
showToast('Failed to open discovery modal', 'error');
}
}