diff --git a/web_server.py b/web_server.py index 8164f3f4..ef27e463 100644 --- a/web_server.py +++ b/web_server.py @@ -28652,8 +28652,16 @@ def _on_download_completed(batch_id, task_id, success=True): except Exception: pass - # Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist + # Push discover playlists to media server after downloads complete playlist_id = batch.get('playlist_id') + if playlist_id and playlist_id.startswith('discover_'): + threading.Thread( + target=_push_discover_playlist_to_server, + args=(batch_id, batch), + daemon=True + ).start() + + # Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist if playlist_id and playlist_id.startswith('youtube_'): url_hash = playlist_id.replace('youtube_', '') if url_hash in youtube_playlist_states: @@ -31361,6 +31369,7 @@ def get_all_downloads_unified(): 'source_page': batch.get('source_page') or batch.get('initiated_from') or '', 'phase': batch.get('phase', 'unknown'), 'total': len(queue), + 'analysis_total': batch.get('analysis_total', len(queue)), 'completed': sum(1 for s in statuses if s in ('completed', 'skipped', 'already_owned')), 'failed': sum(1 for s in statuses if s in ('failed', 'not_found', 'cancelled')), 'active': sum(1 for s in statuses if s in ('downloading', 'searching', 'post_processing')), @@ -31838,8 +31847,27 @@ def _check_batch_completion_v2(batch_id): finished_count += 1 else: retrying_count += 1 + elif task_status == 'downloading': + task_age = current_time - task.get('status_change_time', current_time) + if no_active_workers and task_age > 300: # 5 minutes with no worker running + logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in downloading for {task_age:.0f}s with no active workers - forcing failed") + task['status'] = 'failed' + task['error_message'] = f'Download stuck for {int(task_age // 60)} minutes with no active worker — timed out' + finished_count += 1 + else: + retrying_count += 1 elif task_status in ['completed', 'failed', 'cancelled', 'not_found']: finished_count += 1 + else: + # Catch-all for any other non-terminal state (queued, retrying, etc.) + task_age = current_time - task.get('status_change_time', current_time) + if no_active_workers and task_age > 600: # 10 minutes with no worker + logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in '{task_status}' for {task_age:.0f}s with no active workers - forcing failed") + task['status'] = 'failed' + task['error_message'] = f'Task stuck in {task_status} for {int(task_age // 60)} minutes with no active worker — timed out' + finished_count += 1 + else: + retrying_count += 1 else: # Task ID in queue but not in download_tasks - treat as completed to prevent blocking logger.warning(f"[Orphaned Task V2] Task {task_id} in queue but not in download_tasks - counting as finished") @@ -31862,6 +31890,9 @@ def _check_batch_completion_v2(batch_id): batch['phase'] = 'complete' batch['completion_time'] = time.time() # Track when batch completed + # Record sync history completion + _record_sync_history_completion(batch_id, batch) + # Add activity for batch completion playlist_name = batch.get('playlist_name', 'Unknown Playlist') failed_count = len(batch.get('permanently_failed_tracks', [])) @@ -31880,6 +31911,15 @@ def _check_batch_completion_v2(batch_id): }) except Exception: pass + + # Push discover playlists to media server after downloads complete + playlist_id = batch.get('playlist_id') + if playlist_id and playlist_id.startswith('discover_'): + threading.Thread( + target=_push_discover_playlist_to_server, + args=(batch_id, batch), + daemon=True + ).start() else: logger.warning(f"[Completion Check V2] Batch {batch_id} already marked complete - skipping duplicate processing") return True # Already complete @@ -32253,7 +32293,7 @@ def _detect_sync_source(playlist_id): ('auto_mirror_', 'mirrored'), ('youtube_mirrored_', 'mirrored'), ('youtube_', 'youtube'), ('beatport_', 'beatport'), ('tidal_', 'tidal'), ('deezer_', 'deezer'), ('listenbrainz_', 'listenbrainz'), - ('spotify_public_', 'spotify_public'), ('discover_album_', 'discover'), + ('spotify_public_', 'spotify_public'), ('discover_', 'discover'), ('seasonal_album_', 'discover'), ('library_redownload_', 'library'), ('issue_download_', 'library'), ('artist_album_', 'spotify'), ('enhanced_search_', 'spotify'), ('spotify_library_', 'spotify'), @@ -32354,6 +32394,10 @@ def _record_sync_history_completion(batch_id, batch): completed_count = 0 failed_count = len(batch.get('permanently_failed_tracks', [])) + logger.warning(f"[SyncHistory] Recording completion for batch {batch_id}: " + f"analysis_results={len(analysis_results)}, tracks_found={tracks_found}, " + f"queue_len={len(queue)}, failed={failed_count}") + # Build download status map: track_index → status download_status_map = {} for task_id in queue: @@ -32364,6 +32408,9 @@ def _record_sync_history_completion(batch_id, batch): if task.get('status') == 'completed': completed_count += 1 + logger.warning(f"[SyncHistory] Batch {batch_id}: completed_downloads={completed_count}, " + f"download_status_map_size={len(download_status_map)}") + # Build per-track results from analysis track_results = [] for res in analysis_results: @@ -32403,14 +32450,118 @@ def _record_sync_history_completion(batch_id, batch): track_results.append(entry) db = MusicDatabase() - db.update_sync_history_completion(batch_id, tracks_found, completed_count, failed_count) + updated = db.update_sync_history_completion(batch_id, tracks_found, completed_count, failed_count) + logger.warning(f"[SyncHistory] DB update for batch {batch_id}: updated={updated}") # Save per-track results if track_results: - db.update_sync_history_track_results(batch_id, json.dumps(track_results)) + tr_updated = db.update_sync_history_track_results(batch_id, json.dumps(track_results)) + logger.warning(f"[SyncHistory] Track results saved for batch {batch_id}: updated={tr_updated}, count={len(track_results)}") except Exception as e: logger.warning(f"Failed to record sync history completion: {e}") + import traceback + traceback.print_exc() + + +def _push_discover_playlist_to_server(batch_id, batch): + """After a discover batch completes, push the playlist to the media server. + Runs in a background thread. Triggers a scan, waits, then searches for tracks and creates/updates the playlist.""" + try: + playlist_id = batch.get('playlist_id', '') + playlist_name = batch.get('playlist_name', '') + if not playlist_name: + return + + analysis_results = batch.get('analysis_results', []) + if not analysis_results: + logger.info(f"[DiscoverPush] No analysis results for {playlist_name} - skipping server push") + return + + # Build list of tracks that should be in the playlist (found in library OR successfully downloaded) + queue = batch.get('queue', []) + download_status_map = {} + with tasks_lock: + for task_id in queue: + task = download_tasks.get(task_id, {}) + ti = task.get('track_index') + if ti is not None: + download_status_map[ti] = task.get('status', 'unknown') + + tracks_to_find = [] + for res in analysis_results: + idx = res.get('track_index', 0) + found = res.get('found', False) + dl_status = download_status_map.get(idx) + if found or dl_status == 'completed': + track_data = res.get('track', {}) + artists = track_data.get('artists', []) + if artists: + first = artists[0] + artist_name = first.get('name', first) if isinstance(first, dict) else str(first) + else: + artist_name = '' + tracks_to_find.append({ + 'index': idx, + 'title': track_data.get('name', ''), + 'artist': artist_name, + }) + + if not tracks_to_find: + logger.info(f"[DiscoverPush] No tracks to push for {playlist_name}") + return + + logger.info(f"[DiscoverPush] {playlist_name}: {len(tracks_to_find)} tracks to push to server, triggering scan first") + + # Trigger a library scan so newly downloaded tracks are indexed + if navidrome_client and navidrome_client.is_connected(): + navidrome_client.trigger_library_scan() + elif hasattr(web_scan_manager, 'request_scan'): + web_scan_manager.request_scan(f"Discover playlist push: {playlist_name}") + + # Wait for scan to finish (poll every 5s, up to 90s) + if navidrome_client and navidrome_client.is_connected(): + for _ in range(18): + time.sleep(5) + if not navidrome_client.is_library_scanning(): + break + logger.info(f"[DiscoverPush] Scan complete, searching for tracks") + else: + time.sleep(30) + + # Search for each track on the media server + matched_server_tracks = [] + if navidrome_client and navidrome_client.is_connected(): + for t in tracks_to_find: + results = navidrome_client.search_tracks(t['title'], t['artist'], limit=5) + if results: + # Use the first result's underlying NavidromeTrack for playlist creation + best = results[0] + nav_track = getattr(best, '_original_navidrome_track', None) + if nav_track: + matched_server_tracks.append(nav_track) + logger.debug(f"[DiscoverPush] Matched: '{t['title']}' by '{t['artist']}' → {best.id}") + else: + matched_server_tracks.append(best) + else: + logger.info(f"[DiscoverPush] No match for: '{t['title']}' by '{t['artist']}'") + + if not matched_server_tracks: + logger.warning(f"[DiscoverPush] No tracks matched on server for {playlist_name}") + return + + logger.info(f"[DiscoverPush] Pushing {len(matched_server_tracks)}/{len(tracks_to_find)} tracks to '{playlist_name}' on server") + success = navidrome_client.update_playlist(playlist_name, matched_server_tracks) + if success: + logger.info(f"[DiscoverPush] Successfully pushed '{playlist_name}' to server with {len(matched_server_tracks)} tracks") + else: + logger.warning(f"[DiscoverPush] Failed to push '{playlist_name}' to server") + + except Exception as e: + logger.error(f"[DiscoverPush] Error pushing playlist to server: {e}") + import traceback + traceback.print_exc() + # =============================== # == SERVER PLAYLIST MANAGER == @@ -33075,6 +33226,8 @@ def start_missing_tracks_process(playlist_id): _source_page = 'wishlist' elif is_album_download: _source_page = 'album' + elif playlist_id.startswith('discover_') or playlist_id.startswith('seasonal_'): + _source_page = 'discover' elif playlist_id.startswith('youtube_'): _source_page = 'sync' else: @@ -39217,6 +39370,13 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, except (IndexError, ValueError): pass else: + # Derive source_page from playlist_id prefix + if playlist_id.startswith('discover_') or playlist_id.startswith('seasonal_'): + _source_page = 'discover' + elif playlist_id.startswith('listenbrainz_') or playlist_id.startswith('discover_listenbrainz_'): + _source_page = 'discover' + else: + _source_page = 'sync' _record_sync_history_start( batch_id=sync_batch_id, playlist_id=playlist_id, @@ -39226,7 +39386,7 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, album_context=None, artist_context=None, playlist_folder_mode=False, - source_page='sync' + source_page=_source_page ) try: @@ -43573,6 +43733,9 @@ def refresh_discover_data(): logger.info(f"[Discover Refresh] Complete! Recent albums: {len(recent_albums)}, Release Radar: {len(release_radar)} tracks, Discovery Weekly: {len(discovery_weekly)} tracks") + # Auto-sync any "Keep it updated" playlists + _auto_sync_discover_playlists(refresh_pid, active_source) + return jsonify({ "success": True, "message": "Discover data refreshed", @@ -43589,6 +43752,269 @@ def refresh_discover_data(): return jsonify({"success": False, "error": str(e)}), 500 +def _auto_sync_discover_playlists(profile_id, active_source): + """Auto-sync Discover playlists that have 'Keep it updated' enabled.""" + try: + playlist_configs = { + 'release_radar': 'Fresh Tape', + 'discovery_weekly': 'The Archives', + 'seasonal_playlist': 'Seasonal Mix', + 'popular_picks': 'Popular Picks', + 'hidden_gems': 'Hidden Gems', + 'discovery_shuffle': 'Discovery Shuffle', + 'familiar_favorites': 'Familiar Favorites', + } + + for ptype, pname in playlist_configs.items(): + if not config_manager.get(f'discover.auto_sync.{ptype}', False): + continue + + logger.info(f"[Auto-Sync] {pname} has 'Keep it updated' enabled, triggering sync...") + + try: + database = get_database() + tracks = [] + + if ptype in ('release_radar', 'discovery_weekly'): + curated_ids = database.get_curated_playlist(f'{ptype}_{active_source}', profile_id=profile_id) + if not curated_ids: + curated_ids = database.get_curated_playlist(ptype, profile_id=profile_id) + if curated_ids: + pool_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source, profile_id=profile_id) + tracks_by_id = {} + for track in pool_tracks: + tid = None + if active_source == 'spotify' and track.spotify_track_id: + tid = track.spotify_track_id + elif active_source == 'deezer' and getattr(track, 'deezer_track_id', None): + tid = track.deezer_track_id + elif active_source == 'itunes' and track.itunes_track_id: + tid = track.itunes_track_id + if tid: + tracks_by_id[tid] = track + + for track_id in curated_ids: + if track_id in tracks_by_id: + t = tracks_by_id[track_id] + tracks.append({ + 'id': t.spotify_track_id or getattr(t, 'deezer_track_id', None) or t.itunes_track_id or '', + 'name': t.track_name, + 'artists': [t.artist_name], + 'album': t.album_name, + 'duration_ms': t.duration_ms or 0 + }) + elif ptype == 'seasonal_playlist': + from core.seasonal_discovery import SeasonalDiscoveryService + seasonal_svc = SeasonalDiscoveryService(database) + season_data = seasonal_svc.get_current_season_playlist() + if season_data and season_data.get('tracks'): + tracks = [{ + 'id': t.get('spotify_track_id', ''), + 'name': t.get('track_name', ''), + 'artists': [t.get('artist_name', '')], + 'album': t.get('album_name', ''), + 'duration_ms': t.get('duration_ms', 0) + } for t in season_data['tracks']] + else: + from core.personalized_playlists import PersonalizedPlaylistsService + service = PersonalizedPlaylistsService(database) + method_map = { + 'popular_picks': service.get_popular_picks, + 'hidden_gems': service.get_hidden_gems, + 'discovery_shuffle': service.get_discovery_shuffle, + 'familiar_favorites': service.get_familiar_favorites, + } + if ptype in method_map: + raw_tracks = method_map[ptype](limit=50) + tracks = [{ + 'id': t.get('spotify_track_id', ''), + 'name': t.get('track_name', ''), + 'artists': [t.get('artist_name', '')], + 'album': t.get('album_name', ''), + 'duration_ms': t.get('duration_ms', 0) + } for t in raw_tracks] + + if tracks: + virtual_id = f'discover_{ptype}' + with sync_lock: + if virtual_id in active_sync_workers and not active_sync_workers[virtual_id].done(): + logger.info(f"[Auto-Sync] {pname} already syncing, skipping") + continue + sync_states[virtual_id] = {"status": "starting", "progress": {}} + future = sync_executor.submit(_run_sync_task, virtual_id, pname, tracks, None, profile_id, '') + active_sync_workers[virtual_id] = future + logger.info(f"[Auto-Sync] Started sync for {pname} with {len(tracks)} tracks") + else: + logger.info(f"[Auto-Sync] No tracks available for {pname}, skipping") + + except Exception as e: + logger.error(f"[Auto-Sync] Error syncing {pname}: {e}") + + except Exception as e: + logger.error(f"[Auto-Sync] Error in auto-sync: {e}") + + +@app.route('/api/discover/synced-playlists', methods=['GET']) +def get_discover_synced_playlists(): + """Get all Discover playlist types with sync status and auto-update config.""" + try: + database = get_database() + active_source = _get_active_discovery_source() + pid = get_current_profile_id() + + playlist_types = [ + {'type': 'release_radar', 'name': 'Fresh Tape', 'description': 'New drops from recent releases', 'icon': '🎵'}, + {'type': 'discovery_weekly', 'name': 'The Archives', 'description': 'Curated from your collection', 'icon': '📚'}, + {'type': 'seasonal_playlist', 'name': 'Seasonal Mix', 'description': 'Seasonal curated playlist', 'icon': '🌿'}, + {'type': 'popular_picks', 'name': 'Popular Picks', 'description': 'Most popular from your discovery pool', 'icon': '🔥'}, + {'type': 'hidden_gems', 'name': 'Hidden Gems', 'description': 'Underappreciated gems from your pool', 'icon': '💎'}, + {'type': 'discovery_shuffle', 'name': 'Discovery Shuffle', 'description': 'Random tracks from discovery', 'icon': '🔀'}, + {'type': 'familiar_favorites', 'name': 'Familiar Favorites', 'description': 'Familiar tracks you love', 'icon': '❤️'}, + ] + + # Check if discovery pool has any data (needed for personalized playlists) + try: + with database._get_connection() as conn: + pool_count = conn.execute( + "SELECT COUNT(*) FROM discovery_pool WHERE source = ?", (active_source,) + ).fetchone()[0] + except Exception: + pool_count = 0 + + results = [] + for pt in playlist_types: + ptype = pt['type'] + + # Get track count + track_count = 0 + if ptype in ('release_radar', 'discovery_weekly'): + curated_ids = database.get_curated_playlist(f'{ptype}_{active_source}', profile_id=pid) + if not curated_ids: + curated_ids = database.get_curated_playlist(ptype, profile_id=pid) + track_count = len(curated_ids) if curated_ids else 0 + elif ptype == 'seasonal_playlist': + from core.seasonal_discovery import SeasonalDiscoveryService + try: + seasonal_svc = SeasonalDiscoveryService(database) + season_data = seasonal_svc.get_current_season_playlist() + track_count = len(season_data.get('tracks', [])) if season_data else 0 + except Exception: + track_count = 0 + else: + # Personalized playlists come from the discovery pool + # familiar_favorites is not implemented — always report 0 + if ptype == 'familiar_favorites': + track_count = 0 + elif pool_count > 0: + track_count = min(50, pool_count) + else: + track_count = 0 + + # Get last sync info + virtual_id = f'discover_{ptype}' + sync_status = 'never' + last_synced = None + matched_tracks = 0 + total_sync_tracks = 0 + + with sync_lock: + state = sync_states.get(virtual_id) + if state and state.get('status') in ('syncing', 'starting'): + sync_status = 'syncing' + + # Also check download_batches for active discover batches + active_batch_id = None + if sync_status == 'never': + with tasks_lock: + for bid, b in download_batches.items(): + if b.get('playlist_id') == virtual_id and b.get('phase') not in ('complete', 'error', 'cancelled'): + sync_status = 'syncing' + active_batch_id = bid + break + + if sync_status == 'never': + try: + entries, _ = database.get_sync_history(source='discover', page=1, limit=100) + for entry in entries: + if entry.get('playlist_name') == pt['name'] or entry.get('playlist_id', '').startswith(virtual_id): + sync_status = 'synced' + last_synced = entry.get('completed_at') or entry.get('started_at') + matched_tracks = (entry.get('tracks_found') or 0) + (entry.get('tracks_downloaded') or 0) + total_sync_tracks = entry.get('total_tracks') or 0 + break + except Exception: + pass + + auto_update = config_manager.get(f'discover.auto_sync.{ptype}', False) + + # Use actual track count from last sync if available (curated_playlist can be stale) + if total_sync_tracks > 0: + track_count = total_sync_tracks + + results.append({ + **pt, + 'track_count': track_count, + 'sync_status': sync_status, + 'last_synced': last_synced, + 'matched_tracks': matched_tracks, + 'total_sync_tracks': total_sync_tracks, + 'auto_update': bool(auto_update), + 'virtual_id': virtual_id, + 'active_batch_id': active_batch_id, + }) + + source_labels = { + 'spotify': 'Spotify', 'deezer': 'Deezer', 'itunes': 'iTunes/Apple Music', + 'discogs': 'Discogs', 'hydrabase': 'Hydrabase' + } + has_any_data = pool_count > 0 or any(r['track_count'] > 0 for r in results) + + return jsonify({ + "success": True, + "playlists": results, + "source": active_source, + "source_label": source_labels.get(active_source, active_source), + "has_data": has_any_data, + }) + except Exception as e: + logger.error(f"Error getting discover synced playlists: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/discover/auto-update', methods=['POST', 'GET']) +def manage_discover_auto_update(): + """Toggle or get auto-update settings for Discover playlists.""" + valid_types = ['release_radar', 'discovery_weekly', 'seasonal_playlist', + 'popular_picks', 'hidden_gems', 'discovery_shuffle', 'familiar_favorites'] + + if request.method == 'GET': + settings = {} + for ptype in valid_types: + settings[ptype] = bool(config_manager.get(f'discover.auto_sync.{ptype}', False)) + # Also include any listenbrainz_* auto-sync settings + all_config = config_manager.get('discover.auto_sync', {}) + if isinstance(all_config, dict): + for key, val in all_config.items(): + if key.startswith('listenbrainz_'): + settings[key] = bool(val) + return jsonify({"success": True, "settings": settings}) + + data = request.get_json() + playlist_type = data.get('playlist_type') + enabled = data.get('enabled', False) + + is_lb_type = playlist_type and playlist_type.startswith('listenbrainz_') + if playlist_type not in valid_types and not is_lb_type: + return jsonify({"success": False, "error": f"Invalid playlist type: {playlist_type}"}), 400 + + config_manager.set(f'discover.auto_sync.{playlist_type}', bool(enabled)) + logger.info(f"Discover auto-sync for {playlist_type}: {'enabled' if enabled else 'disabled'}") + + return jsonify({"success": True, "playlist_type": playlist_type, "enabled": bool(enabled)}) + + @app.route('/api/discover/diagnose', methods=['GET']) def diagnose_discover_data(): """ @@ -43662,6 +44088,66 @@ def diagnose_discover_data(): # SEASONAL DISCOVERY ENDPOINTS # ======================================== +@app.route('/api/discover/seasonal/current-playlist', methods=['GET']) +def get_current_seasonal_playlist(): + """Auto-detect current season and return its playlist tracks""" + try: + from core.seasonal_discovery import get_seasonal_discovery_service, SEASONAL_CONFIG + + database = get_database() + seasonal_service = get_seasonal_discovery_service(spotify_client, database) + current_season = seasonal_service.get_current_season() + + if not current_season or current_season not in SEASONAL_CONFIG: + return jsonify({"success": True, "tracks": []}) + + active_source = _get_active_discovery_source() + track_ids = seasonal_service.get_curated_seasonal_playlist(current_season, source=active_source) + + if not track_ids: + return jsonify({"success": True, "tracks": []}) + + track_id_col = 'spotify_track_id' if active_source == 'spotify' else 'itunes_track_id' + tracks = [] + with database._get_connection() as conn: + cursor = conn.cursor() + for track_id in track_ids: + cursor.execute(""" + SELECT spotify_track_id, track_name, artist_name, album_name, + album_cover_url, duration_ms, popularity, track_data_json + FROM seasonal_tracks WHERE spotify_track_id = ? AND source = ? + """, (track_id, active_source)) + result = cursor.fetchone() + if not result: + cursor.execute(f""" + SELECT {track_id_col} as spotify_track_id, track_name, artist_name, album_name, + album_cover_url, duration_ms, popularity, track_data_json + FROM discovery_pool WHERE {track_id_col} = ? AND source = ? + """, (track_id, active_source)) + result = cursor.fetchone() + if result: + track_dict = dict(result) + if track_dict.get('track_data_json'): + try: + import json + track_dict['track_data_json'] = json.loads(track_dict['track_data_json']) + except: + pass + tracks.append(track_dict) + + config = SEASONAL_CONFIG[current_season] + return jsonify({ + "success": True, + "season": current_season, + "name": config['name'], + "tracks": tracks + }) + except Exception as e: + logger.error(f"Error getting current seasonal playlist: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/discover/seasonal/current', methods=['GET']) def get_current_seasonal_content(): """Auto-detect and return current season's content""" @@ -46248,7 +46734,8 @@ def _get_lb_discover_playlists(playlist_type): "title": playlist['title'], "creator": playlist['creator'], "annotation": playlist.get('annotation', {}), - "track": [] + "track": [], + "track_count": playlist.get('track_count', 0), } }) diff --git a/webui/index.html b/webui/index.html index 10c63f68..d9b21256 100644 --- a/webui/index.html +++ b/webui/index.html @@ -843,8 +843,7 @@
Playlist SyncSynchronize your Spotify, Tidal, and YouTube playlists with your media - server
+Sync playlists from Spotify, Tidal, YouTube, Beatport, Deezer, and ListenBrainz to your media server
Playlists generated from your Discover page. Toggle "Keep it updated" to auto-sync when playlists refresh.
+Your Discover playlists don't have any tracks yet.
+Go to the Discover page and let it build your playlist pool — it uses your ${data.source_label || 'configured source'} data and watchlist to generate personalized playlists.
+ `; + container.appendChild(hint); + } + + data.playlists.forEach(playlist => { + renderDiscoverSyncCard(playlist, container, data.source_label || data.source); + // Resume polling if there's an active batch for this playlist + if (playlist.active_batch_id && playlist.sync_status === 'syncing') { + const btn = document.getElementById(`discover-sync-btn-${playlist.type}`); + if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; } + pollDiscoverBatchFromTab(playlist.type, playlist.active_batch_id, playlist.name); + } + }); + + // Also fetch ListenBrainz playlists and add them + try { + // Fetch saved auto-update settings so LB toggles persist across restarts + let lbAutoSettings = {}; + try { + const settingsRes = await fetch('/api/discover/auto-update'); + if (settingsRes.ok) { + const settingsData = await settingsRes.json(); + if (settingsData.success) lbAutoSettings = settingsData.settings || {}; + } + } catch (_) {} + + const lbRes = await fetch('/api/discover/listenbrainz/created-for'); + if (lbRes.ok) { + const lbData = await lbRes.json(); + if (lbData.success && lbData.playlists && lbData.playlists.length > 0) { + // Fetch sync history once for all LB playlists + let historyEntries = []; + try { + const histRes = await fetch('/api/sync/history?source=discover&limit=50'); + if (histRes.ok) { + const histData = await histRes.json(); + historyEntries = histData.entries || []; + } + } catch (_) {} + + // Deduplicate by base name — only show the latest of each type + const seen = new Map(); + for (const p of lbData.playlists) { + const pl = p.playlist || p; + const rawTitle = pl.title || 'ListenBrainz Playlist'; + // Strip ", week of YYYY-MM-DD ..." suffix for a stable display name + const baseName = rawTitle.replace(/,\s*week of .+$/i, '').trim(); + // Keep only the first (latest) for each base name + if (!seen.has(baseName)) seen.set(baseName, { pl, rawTitle, baseName }); + } + + for (const { pl, rawTitle, baseName } of seen.values()) { + const identifier = pl.identifier || ''; + const mbid = identifier.split('/').pop(); + const trackCount = pl.track_count || (pl.track || []).length; + // Determine icon from title + let icon = '🧠'; + if (rawTitle.toLowerCase().includes('jam')) icon = '🎸'; + else if (rawTitle.toLowerCase().includes('explor')) icon = '🔭'; + + const lbType = `listenbrainz_${mbid}`; + + // Check sync history for this playlist by matching the base name + let syncStatus = 'never'; + let lastSynced = null; + let matchedTracks = 0; + let totalSyncTracks = 0; + for (const entry of historyEntries) { + const eName = entry.playlist_name || ''; + if (eName === baseName || eName.startsWith(baseName)) { + syncStatus = 'synced'; + lastSynced = entry.completed_at || entry.started_at || entry.created_at; + matchedTracks = entry.tracks_found || 0; + totalSyncTracks = entry.total_tracks || 0; + break; + } + } + + renderDiscoverSyncCard({ + type: lbType, + name: baseName, + description: '', + icon: icon, + track_count: trackCount, + sync_status: syncStatus, + last_synced: lastSynced, + matched_tracks: matchedTracks, + total_sync_tracks: totalSyncTracks, + auto_update: !!lbAutoSettings[lbType], + virtual_id: `discover_listenbrainz_${mbid}`, + _lb_mbid: mbid, + }, container, 'ListenBrainz'); + } + } + } + } catch (lbErr) { + console.warn('Could not load ListenBrainz playlists for discover sync tab:', lbErr); + } + + } catch (error) { + console.error('Error loading discover sync playlists:', error); + container.innerHTML = '