listenbrainz discovery fix
This commit is contained in:
parent
ab58b64a17
commit
ffe02d5330
3 changed files with 1317 additions and 79 deletions
|
|
@ -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
|
||||
|
|
|
|||
647
web_server.py
647
web_server.py
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue