From 5415d0fe3c5c6f7589c099e9ef4c35a8f3a06b8e Mon Sep 17 00:00:00 2001 From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:56:25 -0700 Subject: [PATCH 1/4] feat: add SoulSync Discover sync tab with ListenBrainz, progress tracking, and Navidrome push Adds a full Discover Sync tab to the Sync page with: - Core UI scaffolding, playlist modal, empty-state handling - ListenBrainz playlist integration with auto-update toggle persistence - Sync progress tracking with matched/total counts on cards - Navidrome playlist push on batch completion (V1 and V2 paths) - Active download state display with polling resume on page reload - Stuck-download detection for downloading and catch-all states - Serialized sync queue to prevent concurrent backend contention - Source badges, compact card layout, URL fixes --- web_server.py | 499 +++++++++++++++++++++++- webui/index.html | 17 +- webui/static/discover.js | 618 +++++++++++++++++++++++++++++- webui/static/pages-extra.js | 7 +- webui/static/stats-automations.js | 1 + webui/static/style.css | 314 ++++++++++++++- webui/static/sync-services.js | 5 + 7 files changed, 1438 insertions(+), 23 deletions(-) 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 Sync

-

Synchronize your Spotify, Tidal, and YouTube playlists with your media - server

+

Sync playlists from Spotify, Tidal, YouTube, Beatport, Deezer, and ListenBrainz to your media server

@@ -880,6 +879,9 @@ + @@ -1753,6 +1755,17 @@ + +
+
+

SoulSync Discover

+

Playlists generated from your Discover page. Toggle "Keep it updated" to auto-sync when playlists refresh.

+
+
+
Loading Discover playlists...
+
+
+
diff --git a/webui/static/discover.js b/webui/static/discover.js index 6763054f..8ddfb0a3 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -2330,8 +2330,17 @@ function startDecadeSyncPolling(decade, virtualPlaylistId) { delete _syncProgressCallbacks[virtualPlaylistId]; const syncButton = el(`decade-${decade}-sync-btn`); if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } - showToast(`${decade}s Classics sync complete!`, 'success'); - setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + const _m2 = progress.matched_tracks || matched || 0; + const _t2 = progress.total_tracks || total || 0; + const _miss2 = _t2 - _m2; + if (_miss2 > 0) { + showToast(`${decade}s Classics: ${_m2}/${_t2} matched, ${_miss2} missing`, 'warning'); + } else { + showToast(`${decade}s Classics: all ${_t2} tracks matched!`, 'success'); + } + if (el(`decade-${decade}-sync-percentage`)) el(`decade-${decade}-sync-percentage`).textContent = '100'; + if (el(`decade-${decade}-sync-pending`)) el(`decade-${decade}-sync-pending`).textContent = '0'; + setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000); } }; } @@ -2373,12 +2382,19 @@ function startDecadeSyncPolling(decade, virtualPlaylistId) { syncButton.style.cursor = 'pointer'; } - showToast(`${decade}s Classics sync complete!`, 'success'); + const missing = total - matched; + if (missing > 0) { + showToast(`${decade}s Classics: ${matched}/${total} matched, ${missing} missing`, 'warning'); + } else { + showToast(`${decade}s Classics: all ${total} tracks matched!`, 'success'); + } + if (percentageEl) percentageEl.textContent = '100'; + if (pendingEl) pendingEl.textContent = '0'; setTimeout(() => { const statusDisplay = document.getElementById(`decade-${decade}-sync-status`); if (statusDisplay) statusDisplay.style.display = 'none'; - }, 3000); + }, 5000); } } catch (error) { console.error(`Error polling sync status for decade ${decade}:`, error); @@ -2731,8 +2747,17 @@ function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) { delete _syncProgressCallbacks[virtualPlaylistId]; const syncButton = el(`genre-${genreId}-sync-btn`); if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } - showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success'); - setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + const _m3 = progress.matched_tracks || matched || 0; + const _t3 = progress.total_tracks || total || 0; + const _miss3 = _t3 - _m3; + if (_miss3 > 0) { + showToast(`${capitalizeGenre(genreName)} Mix: ${_m3}/${_t3} matched, ${_miss3} missing`, 'warning'); + } else { + showToast(`${capitalizeGenre(genreName)} Mix: all ${_t3} tracks matched!`, 'success'); + } + if (el(`genre-${genreId}-sync-percentage`)) el(`genre-${genreId}-sync-percentage`).textContent = '100'; + if (el(`genre-${genreId}-sync-pending`)) el(`genre-${genreId}-sync-pending`).textContent = '0'; + setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000); } }; } @@ -2774,12 +2799,19 @@ function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) { syncButton.style.cursor = 'pointer'; } - showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success'); + const missing = total - matched; + if (missing > 0) { + showToast(`${capitalizeGenre(genreName)} Mix: ${matched}/${total} matched, ${missing} missing`, 'warning'); + } else { + showToast(`${capitalizeGenre(genreName)} Mix: all ${total} tracks matched!`, 'success'); + } + if (percentageEl) percentageEl.textContent = '100'; + if (pendingEl) pendingEl.textContent = '0'; setTimeout(() => { const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`); if (statusDisplay) statusDisplay.style.display = 'none'; - }, 3000); + }, 5000); } } catch (error) { console.error(`Error polling sync status for genre ${genreName}:`, error); @@ -7993,8 +8025,18 @@ function startDiscoverSyncPolling(playlistType, virtualPlaylistId) { 'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle', 'familiar_favorites': 'Familiar Favorites', 'build_playlist': 'Custom Playlist' }; - showToast(`${playlistNames[playlistType] || playlistType} sync complete!`, 'success'); - setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + const dn = playlistNames[playlistType] || playlistType; + const _m = progress.matched_tracks || matched || 0; + const _t = progress.total_tracks || total || 0; + const _miss = _t - _m; + if (_miss > 0) { + showToast(`${dn}: ${_m}/${_t} matched, ${_miss} missing`, 'warning'); + } else { + showToast(`${dn}: all ${_t} tracks matched!`, 'success'); + } + if (el(`${prefix}-sync-percentage`)) el(`${prefix}-sync-percentage`).textContent = '100'; + if (el(`${prefix}-sync-pending`)) el(`${prefix}-sync-pending`).textContent = '0'; + setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000); } }; } @@ -8061,15 +8103,22 @@ function startDiscoverSyncPolling(playlistType, virtualPlaylistId) { 'build_playlist': 'Custom Playlist' }; const displayName = playlistNames[playlistType] || playlistType; - showToast(`${displayName} sync complete!`, 'success'); + const missing = total - matched; + if (missing > 0) { + showToast(`${displayName}: ${matched}/${total} matched, ${missing} missing`, 'warning'); + } else { + showToast(`${displayName}: all ${total} tracks matched!`, 'success'); + } - // Hide status display after 3 seconds + // Update status display to show final result, then hide after 5s + if (percentageEl) percentageEl.textContent = '100'; + if (pendingEl) pendingEl.textContent = '0'; setTimeout(() => { const statusDisplay = document.getElementById(`${prefix}-sync-status`); if (statusDisplay) { statusDisplay.style.display = 'none'; } - }, 3000); + }, 5000); } } catch (error) { @@ -8919,3 +8968,546 @@ if (document.readyState === 'loading') { // ============================================================================ + +// ── SoulSync Discover Sync Tab ───────────────────────────────────────── + +async function loadDiscoverSyncPlaylists() { + if (discoverSyncPlaylistsLoaded) return; + discoverSyncPlaylistsLoaded = true; + const container = document.getElementById('discover-sync-playlist-container'); + if (!container) return; + container.innerHTML = '
Loading Discover playlists...
'; + + try { + const response = await fetch('/api/discover/synced-playlists'); + const data = await response.json(); + + if (!data.success || !data.playlists || data.playlists.length === 0) { + container.innerHTML = '
No Discover playlists available. Visit the Discover page to generate playlists first.
'; + return; + } + + container.innerHTML = ''; + + // Show source info and empty-state hint if no playlists have data + if (!data.has_data) { + const hint = document.createElement('div'); + hint.className = 'discover-sync-empty-hint'; + hint.innerHTML = ` +

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 = '
Error loading Discover playlists.
'; + } +} + +function renderDiscoverSyncCard(playlist, container, sourceLabel) { + const card = document.createElement('div'); + const isEmpty = playlist.track_count === 0; + card.className = `discover-sync-card${isEmpty ? ' discover-sync-card-empty' : ''}`; + card.id = `discover-sync-card-${playlist.type}`; + + const lastSyncedText = playlist.last_synced + ? `Last synced ${timeAgo(playlist.last_synced)}` + : 'Never synced'; + + const statusClass = playlist.sync_status === 'syncing' ? 'syncing' : + playlist.sync_status === 'synced' ? 'synced' : 'not-synced'; + let statusText = playlist.sync_status === 'syncing' ? 'Syncing...' : + playlist.sync_status === 'synced' ? 'Synced' : 'Not synced'; + + // Show matched/total counts if available (only when matched > 0, meaning completion was recorded) + if (playlist.sync_status === 'synced' && playlist.matched_tracks > 0 && playlist.total_sync_tracks > 0) { + statusText = `Synced ${playlist.matched_tracks}/${playlist.total_sync_tracks}`; + } + + const trackLabel = isEmpty ? 'No tracks yet' : `${playlist.track_count} tracks`; + + card.innerHTML = ` +
${playlist.icon}
+
+
${playlist.name} + + ${sourceLabel || 'unknown'} + \u00b7 + ${trackLabel} + \u00b7 + ${statusText} + \u00b7 + ${lastSyncedText} + +
+
+
+
+ + +
+ +
+ `; + + // Make the icon + info area clickable to view tracks + if (!isEmpty) { + const clickArea = card.querySelector('.discover-sync-card-info'); + const iconArea = card.querySelector('.discover-sync-card-icon'); + [clickArea, iconArea].forEach(el => { + el.style.cursor = 'pointer'; + el.addEventListener('click', () => openDiscoverPlaylistModal(playlist.type, playlist.name, playlist.icon)); + }); + } + + container.appendChild(card); +} + +async function toggleDiscoverAutoUpdate(playlistType, enabled) { + try { + const response = await fetch('/api/discover/auto-update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playlist_type: playlistType, enabled: enabled }) + }); + const data = await response.json(); + if (data.success) { + showToast(`Auto-update ${enabled ? 'enabled' : 'disabled'} for ${playlistType.replace(/_/g, ' ')}`, 'success'); + } else { + showToast(`Failed to update setting: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error toggling auto-update:', error); + showToast('Failed to update setting', 'error'); + } +} + +const _discoverSyncQueue = []; +let _discoverSyncRunning = false; + +async function syncDiscoverPlaylistFromTab(playlistType, playlistName) { + // Serialize sync operations to avoid concurrent backend contention + return new Promise((resolve) => { + _discoverSyncQueue.push({ playlistType, playlistName, resolve }); + _processDiscoverSyncQueue(); + }); +} + +async function _processDiscoverSyncQueue() { + if (_discoverSyncRunning || _discoverSyncQueue.length === 0) return; + _discoverSyncRunning = true; + const { playlistType, playlistName, resolve } = _discoverSyncQueue.shift(); + try { + await _doSyncDiscoverPlaylist(playlistType, playlistName); + } finally { + _discoverSyncRunning = false; + resolve(); + _processDiscoverSyncQueue(); + } +} + +async function _doSyncDiscoverPlaylist(playlistType, playlistName) { + const btn = document.getElementById(`discover-sync-btn-${playlistType}`); + if (btn) { + btn.disabled = true; + btn.textContent = 'Syncing...'; + } + + try { + let tracksResponse; + + // Use unified URL helper (handles ListenBrainz + standard discover types) + const apiUrl = _discoverPlaylistApiUrl(playlistType); + if (apiUrl) { + tracksResponse = await fetch(apiUrl); + } + + let tracks = []; + if (tracksResponse && tracksResponse.ok) { + const data = await tracksResponse.json(); + tracks = data.tracks || []; + } + + if (!tracks.length) { + showToast(`No tracks available for ${playlistName}. Visit the Discover page first.`, 'warning'); + if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } + return; + } + + const syncTracks = tracks.map(track => { + if (track.track_data_json) { + const t = track.track_data_json; + if (t.artists && Array.isArray(t.artists)) { + t.artists = t.artists.map(a => a.name || a); + } + return t; + } + return { + id: track.spotify_track_id || track.track_id || '', + name: track.track_name || track.name || '', + artists: [track.artist_name || 'Unknown Artist'], + album: track.album_name || '', + duration_ms: track.duration_ms || 0, + image_url: track.album_cover_url || track.image_url || '' + }; + }); + + const virtualPlaylistId = `discover_${playlistType}`; + + // Use the download batch endpoint directly so the batch is labeled + // as "Discover" instead of going through sync → wishlist → "Wishlist" batch. + // Omit force_download_all so it checks the library first and only downloads missing tracks. + const batchResponse = await fetch(`/api/playlists/${virtualPlaylistId}/start-missing-process`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tracks: syncTracks, + playlist_name: playlistName + }) + }); + + const result = await batchResponse.json(); + if (result.success) { + showToast(`Downloading ${playlistName} (${syncTracks.length} tracks)...`, 'info'); + const card = document.getElementById(`discover-sync-card-${playlistType}`); + if (card) { + const statusEl = card.querySelector('.discover-sync-status'); + if (statusEl) { + statusEl.className = 'discover-sync-status syncing'; + statusEl.textContent = 'Downloading...'; + } + } + // Poll the download batch status + pollDiscoverBatchFromTab(playlistType, result.batch_id, playlistName); + } else { + showToast(`Download failed: ${result.error || 'Unknown error'}`, 'error'); + if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } + } + } catch (error) { + console.error(`Error syncing ${playlistName}:`, error); + showToast(`Failed to sync ${playlistName}`, 'error'); + if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } + } +} + +function pollDiscoverSyncFromTab(playlistType, virtualPlaylistId, playlistName) { + const pollInterval = setInterval(async () => { + try { + const resp = await fetch(`/api/sync/status/${virtualPlaylistId}`); + if (!resp.ok) { clearInterval(pollInterval); return; } + const data = await resp.json(); + + if (data.status === 'finished' || data.status === 'error') { + clearInterval(pollInterval); + const btn = document.getElementById(`discover-sync-btn-${playlistType}`); + if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } + + const card = document.getElementById(`discover-sync-card-${playlistType}`); + if (card) { + const statusEl = card.querySelector('.discover-sync-status'); + if (statusEl) { + if (data.status === 'finished') { + const progress = data.progress || data.result || {}; + const matched = progress.matched_tracks || 0; + const total = progress.total_tracks || 0; + statusEl.className = 'discover-sync-status synced'; + statusEl.textContent = matched > 0 && total > 0 ? `Synced ${matched}/${total}` : 'Synced'; + } else { + statusEl.className = 'discover-sync-status not-synced'; + statusEl.textContent = 'Failed'; + } + } + const lastSyncedEl = card.querySelector('.discover-sync-last-synced'); + if (lastSyncedEl && data.status === 'finished') { + lastSyncedEl.textContent = 'Last synced just now'; + } + } + + if (data.status === 'finished') { + showToast(`${playlistName} synced successfully!`, 'success'); + } else { + showToast(`${playlistName} sync failed`, 'error'); + } + } + } catch (error) { + clearInterval(pollInterval); + } + }, 2000); +} + +function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) { + const pollInterval = setInterval(async () => { + try { + const resp = await fetch(`/api/playlists/${batchId}/download_status`); + if (!resp.ok) { clearInterval(pollInterval); return; } + const data = await resp.json(); + const phase = data.phase || data.status; + + if (phase === 'complete' || phase === 'error' || phase === 'cancelled') { + clearInterval(pollInterval); + const btn = document.getElementById(`discover-sync-btn-${playlistType}`); + if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } + + // Extract matched/total from analysis_results + const analysisResults = data.analysis_results || []; + const totalTracks = analysisResults.length; + const matchedTracks = analysisResults.filter(r => r.found).length; + const tasks = data.tasks || []; + const downloaded = tasks.filter(t => t.status === 'completed').length; + const failed = tasks.filter(t => t.status === 'failed' || t.status === 'not_found').length; + + const card = document.getElementById(`discover-sync-card-${playlistType}`); + const syncedCount = matchedTracks + downloaded; + if (card) { + const statusEl = card.querySelector('.discover-sync-status'); + if (statusEl) { + statusEl.className = `discover-sync-status ${phase === 'complete' ? 'synced' : 'not-synced'}`; + if (phase === 'complete' && totalTracks > 0) { + statusEl.textContent = `Synced ${syncedCount}/${totalTracks}`; + } else { + statusEl.textContent = phase === 'complete' ? 'Synced' : (phase === 'cancelled' ? 'Cancelled' : 'Failed'); + } + } + const lastSyncedEl = card.querySelector('.discover-sync-last-synced'); + if (lastSyncedEl && phase === 'complete') { + lastSyncedEl.textContent = 'Last synced just now'; + } + } + + if (phase === 'complete') { + if (totalTracks > 0) { + const missing = totalTracks - syncedCount; + let msg = `${playlistName}: ${syncedCount}/${totalTracks} in library`; + if (downloaded > 0) msg += `, ${downloaded} downloaded`; + if (failed > 0) msg += `, ${failed} failed`; + if (missing === 0) msg += ' - all owned!'; + showToast(msg, 'success'); + } else { + showToast(`${playlistName} download complete!`, 'success'); + } + } else if (phase !== 'cancelled') { + showToast(`${playlistName} download failed`, 'error'); + } + } + } catch (error) { + clearInterval(pollInterval); + } + }, 3000); +} + +/** + * Map a discover playlist type to its API endpoint for fetching tracks. + */ +function _discoverPlaylistApiUrl(playlistType) { + // ListenBrainz playlists + if (playlistType.startsWith('listenbrainz_')) { + const mbid = playlistType.replace('listenbrainz_', ''); + return `/api/discover/listenbrainz/playlist/${mbid}`; + } + const map = { + release_radar: '/api/discover/release-radar', + discovery_weekly: '/api/discover/weekly', + seasonal_playlist: '/api/discover/seasonal/current-playlist', + popular_picks: '/api/discover/personalized/popular-picks', + hidden_gems: '/api/discover/personalized/hidden-gems', + discovery_shuffle: '/api/discover/personalized/discovery-shuffle', + familiar_favorites: '/api/discover/personalized/familiar-favorites', + }; + return map[playlistType] || null; +} + +/** + * Open a modal showing all tracks in a Discover playlist (mirrored-modal style). + */ +async function openDiscoverPlaylistModal(playlistType, playlistName, icon) { + const apiUrl = _discoverPlaylistApiUrl(playlistType); + if (!apiUrl) { showToast('Unknown playlist type', 'error'); return; } + + showLoadingOverlay(`Loading ${playlistName}...`); + try { + const res = await fetch(apiUrl); + const data = await res.json(); + const tracks = data.tracks || []; + + hideLoadingOverlay(); + + if (!tracks.length) { + showToast(`No tracks in ${playlistName}. Visit the Discover page first.`, 'warning'); + return; + } + + // Remove any existing modal + const old = document.getElementById('discover-playlist-modal'); + if (old) old.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'discover-playlist-modal'; + overlay.className = 'mirrored-modal-overlay'; + + const trackRows = tracks.map((t, idx) => { + const name = t.track_name || t.name || ''; + const artist = t.artist_name || (t.artists ? (Array.isArray(t.artists) ? t.artists.map(a => a.name || a).join(', ') : t.artists) : ''); + const album = t.album_name || t.album || ''; + const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; + const coverUrl = t.album_cover_url || ''; + const coverHtml = coverUrl + ? `` + : `
`; + return `
+ ${idx + 1} + ${coverHtml} + ${_esc(name)} + ${_esc(artist)} + ${_esc(album)} + ${dur} +
`; + }).join(''); + + overlay.innerHTML = ` +
+
+
+
${icon || '🎵'}
+
+

${_esc(playlistName)}

+
+ discover + ${tracks.length} tracks +
+
+
+ × +
+
+
+ #TrackArtistAlbumTime +
+ ${trackRows} +
+ +
+ `; + + overlay.addEventListener('click', e => { if (e.target === overlay) closeDiscoverPlaylistModal(); }); + document.body.appendChild(overlay); + } catch (err) { + hideLoadingOverlay(); + showToast(`Error loading ${playlistName}: ${err.message}`, 'error'); + } +} + +function closeDiscoverPlaylistModal() { + const m = document.getElementById('discover-playlist-modal'); + if (m) m.remove(); +} diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 333b1de6..1ac5f919 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2514,7 +2514,12 @@ function _adlRenderBatchPanel() { phaseText = `${batch.completed}/${total} tracks`; if (batch.active > 0) phaseIcon = ''; } else if (batch.phase === 'complete') { - phaseText = `Done \u2014 ${batch.completed} tracks`; + const analysisTotal = batch.analysis_total || 0; + const alreadyOwned = analysisTotal > 0 ? analysisTotal - total : 0; + let parts = [`${batch.completed} downloaded`]; + if (alreadyOwned > 0) parts.push(`${alreadyOwned} owned`); + if (batch.failed > 0) parts.push(`${batch.failed} failed`); + phaseText = parts.join(', '); phaseIcon = '\u2713'; } else if (batch.phase === 'cancelled') { phaseText = 'Cancelled'; diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 88fbf2a3..5d2801bc 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -2122,6 +2122,7 @@ function importFileSubmit() { // ── Mirrored Playlists ──────────────────────────────────────────────── let mirroredPlaylistsLoaded = false; +let discoverSyncPlaylistsLoaded = false; /** * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. diff --git a/webui/static/style.css b/webui/static/style.css index d71b03ef..7e74e360 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -2482,7 +2482,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .helper-first-launch-tip { position: fixed; bottom: 34px; - right: 84px; + right: 136px; padding: 8px 16px; background: rgba(16, 16, 16, 0.95); border: 1px solid rgba(var(--accent-rgb), 0.3); @@ -11913,6 +11913,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { overflow: hidden; border-top-left-radius: 20px; border-top-right-radius: 20px; + flex-shrink: 0; } .mirrored-modal-hero { @@ -12113,6 +12114,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); border-bottom-left-radius: 20px; border-bottom-right-radius: 20px; + flex-shrink: 0; } .mirrored-modal-footer button { @@ -59312,3 +59314,313 @@ body.reduce-effects *::after { font-size: 18px; } } + +/* ── SoulSync Discover Sync Tab ───────────────────────────────────────── */ + +.discover-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.sync-tab-button[data-tab="discover"].active { + background: linear-gradient(135deg, #a78bfa, #7c3aed); + color: #fff; + box-shadow: 0 4px 15px rgba(167, 139, 250, 0.3); +} + +.sync-tab-button.active .discover-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.discover-sync-subtitle { + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + margin: 4px 0 0 0; + font-weight: 400; + line-height: 1.4; +} + +.discover-sync-card { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + margin: 3px 6px; + padding: 8px 14px; + display: flex; + align-items: center; + gap: 10px; + transition: all 0.25s ease; +} + +.discover-sync-card:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(167, 139, 250, 0.2); +} + +.discover-sync-card-icon { + font-size: 20px; + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; +} + +.discover-sync-card-info { + flex: 1; + min-width: 0; +} + +.discover-sync-card-name { + font-size: 13px; + font-weight: 600; + color: #fff; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.discover-sync-card-meta-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 400; + color: rgba(255, 255, 255, 0.4); +} + +.discover-sync-source-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 7px; + border-radius: 4px; + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.2); +} + +.discover-sync-card-desc { + display: none; +} + +.discover-sync-card-meta { + display: none; +} + color: rgba(255, 255, 255, 0.4); +} + +.discover-sync-separator { + opacity: 0.4; +} + +.discover-sync-track-count { + color: rgba(255, 255, 255, 0.55); +} + +.discover-sync-status { + font-weight: 500; +} + +.discover-sync-status.synced { + color: #22c55e; +} + +.discover-sync-status.syncing { + color: #facc15; +} + +.discover-sync-status.not-synced { + color: rgba(255, 255, 255, 0.35); +} + +.discover-sync-last-synced { + color: rgba(255, 255, 255, 0.35); +} + +.discover-sync-card-actions { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; +} + +.discover-sync-toggle-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.discover-sync-toggle-label { + font-size: 10.5px; + color: rgba(255, 255, 255, 0.4); + white-space: nowrap; +} + +.discover-sync-toggle { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + cursor: pointer; +} + +.discover-sync-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.discover-sync-toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.12); + border-radius: 22px; + transition: all 0.25s ease; +} + +.discover-sync-toggle-slider::before { + content: ''; + position: absolute; + width: 16px; + height: 16px; + left: 3px; + bottom: 3px; + background: #fff; + border-radius: 50%; + transition: all 0.25s ease; +} + +.discover-sync-toggle input:checked + .discover-sync-toggle-slider { + background: #a78bfa; +} + +.discover-sync-toggle input:checked + .discover-sync-toggle-slider::before { + transform: translateX(18px); +} + +.discover-sync-btn { + padding: 8px 16px; + background: linear-gradient(135deg, #a78bfa, #7c3aed); + color: #fff; + border: none; + border-radius: 8px; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: all 0.25s ease; +} + +.discover-sync-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(167, 139, 250, 0.35); +} + +.discover-sync-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Empty-state styling */ +.discover-sync-card-empty { + opacity: 0.55; +} + +.discover-sync-card-empty .discover-sync-toggle-slider { + opacity: 0.4; +} + +.discover-sync-empty-hint { + background: rgba(255, 193, 7, 0.08); + border: 1px solid rgba(255, 193, 7, 0.25); + border-radius: 10px; + padding: 16px 20px; + margin-bottom: 16px; + color: #ccc; + font-size: 0.9rem; + line-height: 1.5; +} + +.discover-sync-empty-hint p { + margin: 0 0 6px 0; +} + +.discover-sync-empty-hint p:last-child { + margin-bottom: 0; +} + +.discover-sync-empty-hint strong { + color: #ffc107; +} + +.discover-sync-source-info { + color: #888; + font-size: 0.82rem; + margin-bottom: 12px; + padding-left: 4px; +} + +.discover-sync-source-info strong { + color: #aaa; +} + +@media (max-width: 768px) { + .discover-sync-card { + flex-wrap: wrap; + gap: 12px; + } + + .discover-sync-card-actions { + width: 100%; + justify-content: flex-end; + } +} + +/* Discover playlist modal extras */ +.mirrored-modal-hero-icon.discover { + background: linear-gradient(135deg, rgba(167, 139, 250, 0.2) 0%, rgba(124, 58, 237, 0.12) 100%); + border-color: rgba(167, 139, 250, 0.3); + box-shadow: 0 8px 24px rgba(167, 139, 250, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1); + color: #a78bfa; +} + +.discover-modal-track-img { + width: 32px; + height: 32px; + border-radius: 4px; + object-fit: cover; + vertical-align: middle; +} + +.discover-modal-track-img-placeholder { + width: 32px; + height: 32px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + display: inline-block; + vertical-align: middle; +} + +.track-cover { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + flex-shrink: 0; +} + +#discover-playlist-modal .mirrored-track-header, +#discover-playlist-modal .mirrored-track-row { + grid-template-columns: 40px 40px 1.4fr 1.2fr 1fr 56px; +} diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 4dc00ed8..525e4558 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -2784,6 +2784,11 @@ function initializeSyncPage() { loadMirroredPlaylists(); } + // Auto-load SoulSync Discover playlists on first tab activation + if (tabId === 'discover' && !discoverSyncPlaylistsLoaded) { + loadDiscoverSyncPlaylists(); + } + // Auto-load server playlists on first tab activation if (tabId === 'server' && !window._serverPlaylistsLoaded) { window._serverPlaylistsLoaded = true; From ed013d79b99886b1373c10585b8bae0020a72831 Mon Sep 17 00:00:00 2001 From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:55:11 -0700 Subject: [PATCH 2/4] Track server push status, expand push to all playlist types, rename to _push_playlist_to_server --- database/music_database.py | 30 +++++++++++++++-- web_server.py | 67 ++++++++++++++++++++++++++------------ 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 7b2be55f..88b96945 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -636,7 +636,8 @@ class MusicDatabase: playlist_folder_mode INTEGER DEFAULT 0, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP, - track_results TEXT + track_results TEXT, + server_push_status TEXT ) """) cursor.execute("CREATE INDEX IF NOT EXISTS idx_sh_started_at ON sync_history (started_at DESC)") @@ -662,6 +663,16 @@ class MusicDatabase: except Exception: pass + # Migration: add server_push_status column to sync_history + try: + cursor.execute("SELECT server_push_status FROM sync_history LIMIT 1") + except Exception: + try: + cursor.execute("ALTER TABLE sync_history ADD COLUMN server_push_status TEXT") + logger.info("Added server_push_status column to sync_history table") + except Exception: + pass + # Migration: add track_artist column for per-track artist on compilations/DJ mixes try: cursor.execute("SELECT track_artist FROM tracks LIMIT 1") @@ -10497,6 +10508,20 @@ class MusicDatabase: logger.debug(f"Error updating sync history track results: {e}") return False + def update_sync_history_push_status(self, batch_id, status): + """Update the server push status for a sync_history entry.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE sync_history SET server_push_status = ? WHERE batch_id = ? + """, (status, batch_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.debug(f"Error updating sync history push status: {e}") + return False + def refresh_sync_history_entry(self, entry_id, tracks_found=0, tracks_downloaded=0, tracks_failed=0): """Update an existing sync_history entry with new stats and reset timestamps to move it to the top.""" try: @@ -10611,7 +10636,8 @@ class MusicDatabase: cursor.execute(""" SELECT id, batch_id, playlist_name, source, sync_type, source_page, total_tracks, tracks_found, tracks_downloaded, tracks_failed, - thumb_url, is_album_download, started_at, completed_at + thumb_url, is_album_download, started_at, completed_at, + server_push_status FROM sync_history WHERE completed_at IS NOT NULL AND started_at >= datetime('now', ? || ' days') diff --git a/web_server.py b/web_server.py index ef27e463..45ce32a8 100644 --- a/web_server.py +++ b/web_server.py @@ -28652,11 +28652,17 @@ def _on_download_completed(batch_id, task_id, success=True): except Exception: pass - # Push discover playlists to media server after downloads complete + # Push playlists to media server after downloads complete playlist_id = batch.get('playlist_id') - if playlist_id and playlist_id.startswith('discover_'): + _push_prefixes = ( + 'discover_', 'auto_mirror_', 'youtube_mirrored_', + 'youtube_', 'tidal_', 'deezer_', 'spotify_public_', + 'listenbrainz_', 'beatport_', + ) + if playlist_id and playlist_id.startswith(_push_prefixes): + database.update_sync_history_push_status(batch_id, 'pending') threading.Thread( - target=_push_discover_playlist_to_server, + target=_push_playlist_to_server, args=(batch_id, batch), daemon=True ).start() @@ -31912,11 +31918,17 @@ def _check_batch_completion_v2(batch_id): except Exception: pass - # Push discover playlists to media server after downloads complete + # Push playlists to media server after downloads complete playlist_id = batch.get('playlist_id') - if playlist_id and playlist_id.startswith('discover_'): + _push_prefixes = ( + 'discover_', 'auto_mirror_', 'youtube_mirrored_', + 'youtube_', 'tidal_', 'deezer_', 'spotify_public_', + 'listenbrainz_', 'beatport_', + ) + if playlist_id and playlist_id.startswith(_push_prefixes): + database.update_sync_history_push_status(batch_id, 'pending') threading.Thread( - target=_push_discover_playlist_to_server, + target=_push_playlist_to_server, args=(batch_id, batch), daemon=True ).start() @@ -32464,18 +32476,23 @@ def _record_sync_history_completion(batch_id, batch): 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.""" +def _push_playlist_to_server(batch_id, batch): + """After a 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. + Supports discover, mirrored, and auto-mirror playlists.""" + database = get_database() try: playlist_id = batch.get('playlist_id', '') playlist_name = batch.get('playlist_name', '') if not playlist_name: return + database.update_sync_history_push_status(batch_id, 'pushing') + analysis_results = batch.get('analysis_results', []) if not analysis_results: - logger.info(f"[DiscoverPush] No analysis results for {playlist_name} - skipping server push") + logger.info(f"[PlaylistPush] No analysis results for {playlist_name} - skipping server push") + database.update_sync_history_push_status(batch_id, 'skipped') return # Build list of tracks that should be in the playlist (found in library OR successfully downloaded) @@ -32508,16 +32525,17 @@ def _push_discover_playlist_to_server(batch_id, batch): }) if not tracks_to_find: - logger.info(f"[DiscoverPush] No tracks to push for {playlist_name}") + logger.info(f"[PlaylistPush] No tracks to push for {playlist_name}") + database.update_sync_history_push_status(batch_id, 'skipped') return - logger.info(f"[DiscoverPush] {playlist_name}: {len(tracks_to_find)} tracks to push to server, triggering scan first") + logger.info(f"[PlaylistPush] {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}") + web_scan_manager.request_scan(f"Playlist push: {playlist_name}") # Wait for scan to finish (poll every 5s, up to 90s) if navidrome_client and navidrome_client.is_connected(): @@ -32525,7 +32543,7 @@ def _push_discover_playlist_to_server(batch_id, batch): time.sleep(5) if not navidrome_client.is_library_scanning(): break - logger.info(f"[DiscoverPush] Scan complete, searching for tracks") + logger.info(f"[PlaylistPush] Scan complete, searching for tracks") else: time.sleep(30) @@ -32540,27 +32558,34 @@ def _push_discover_playlist_to_server(batch_id, batch): 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}") + logger.debug(f"[PlaylistPush] 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']}'") + logger.info(f"[PlaylistPush] 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}") + logger.warning(f"[PlaylistPush] No tracks matched on server for {playlist_name}") + database.update_sync_history_push_status(batch_id, 'failed') return - logger.info(f"[DiscoverPush] Pushing {len(matched_server_tracks)}/{len(tracks_to_find)} tracks to '{playlist_name}' on server") + logger.info(f"[PlaylistPush] 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") + logger.info(f"[PlaylistPush] Successfully pushed '{playlist_name}' to server with {len(matched_server_tracks)} tracks") + database.update_sync_history_push_status(batch_id, 'success') else: - logger.warning(f"[DiscoverPush] Failed to push '{playlist_name}' to server") + logger.warning(f"[PlaylistPush] Failed to push '{playlist_name}' to server") + database.update_sync_history_push_status(batch_id, 'failed') except Exception as e: - logger.error(f"[DiscoverPush] Error pushing playlist to server: {e}") + logger.error(f"[PlaylistPush] Error pushing playlist to server: {e}") import traceback traceback.print_exc() + try: + database.update_sync_history_push_status(batch_id, 'failed') + except Exception: + pass # =============================== From f32da04fd187111ca4bbbbe95cd533ed6d7701df Mon Sep 17 00:00:00 2001 From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:18:34 -0700 Subject: [PATCH 3/4] Add sync tab deep-linking, redirect Discover sync to sync tab, add Force Download toggle --- webui/static/discover.js | 148 ++++++++-------------------------- webui/static/init.js | 12 ++- webui/static/style.css | 10 +++ webui/static/sync-services.js | 48 +++++++++++ 4 files changed, 101 insertions(+), 117 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 8ddfb0a3..9aac9316 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -7875,113 +7875,14 @@ function checkForActiveDiscoverDownloads() { } async function startDiscoverPlaylistSync(playlistType, playlistName) { - console.log(`🔄 Starting sync for ${playlistName}`); + console.log(`🔄 Navigating to Sync → Discover tab for ${playlistName}`); - // Get tracks based on playlist type - let tracks = []; - if (playlistType === 'release_radar') { - tracks = discoverReleaseRadarTracks; - } else if (playlistType === 'discovery_weekly') { - tracks = discoverWeeklyTracks; - } else if (playlistType === 'seasonal_playlist') { - tracks = discoverSeasonalTracks; - } else if (playlistType === 'popular_picks') { - tracks = personalizedPopularPicks; - } else if (playlistType === 'hidden_gems') { - tracks = personalizedHiddenGems; - } else if (playlistType === 'discovery_shuffle') { - tracks = personalizedDiscoveryShuffle; - } else if (playlistType === 'familiar_favorites') { - tracks = personalizedFamiliarFavorites; - } else if (playlistType === 'build_playlist') { - tracks = buildPlaylistTracks; - } - - if (!tracks || tracks.length === 0) { - showToast(`No tracks available for ${playlistName}`, 'warning'); - return; - } - - // Convert to format expected by sync API - const spotifyTracks = tracks.map(track => { - let spotifyTrack; - - // Use track_data_json if available - if (track.track_data_json) { - spotifyTrack = track.track_data_json; - } else { - // Fallback: construct track object - spotifyTrack = { - id: track.spotify_track_id, - name: track.track_name, - artists: [{ name: track.artist_name }], - album: { - name: track.album_name, - images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] - }, - duration_ms: track.duration_ms || 0 - }; - } - - // Normalize artists to array of strings for sync compatibility - if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { - spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); - } - - return spotifyTrack; + // Navigate to the Sync page → Discover tab, highlight the card, and auto-sync + navigateToSyncTab('discover', { + highlight: `discover-sync-card-${playlistType}`, + autoSync: playlistType, + autoSyncName: playlistName, }); - - // Create virtual playlist ID - const virtualPlaylistId = `discover_${playlistType}`; - - // Store in cache for sync function - playlistTrackCache[virtualPlaylistId] = spotifyTracks; - - // Create virtual playlist object - const virtualPlaylist = { - id: virtualPlaylistId, - name: playlistName, - track_count: spotifyTracks.length - }; - - // Add to spotify playlists array if not already there - if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) { - spotifyPlaylists.push(virtualPlaylist); - } - - // Show sync status display (convert underscores to hyphens for ID) - const statusId = playlistType.replace(/_/g, '-') + '-sync-status'; - const statusDisplay = document.getElementById(statusId); - if (statusDisplay) { - statusDisplay.style.display = 'block'; - } - - // Disable sync button to prevent duplicate syncs (convert underscores to hyphens for ID) - const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn'; - const syncButton = document.getElementById(buttonId); - if (syncButton) { - syncButton.disabled = true; - syncButton.style.opacity = '0.5'; - syncButton.style.cursor = 'not-allowed'; - } - - // Start sync using existing function - await startPlaylistSync(virtualPlaylistId); - - // Extract image URL from first track for download bar bubble - let imageUrl = null; - if (spotifyTracks && spotifyTracks.length > 0) { - const firstTrack = spotifyTracks[0]; - if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { - imageUrl = firstTrack.album.images[0].url; - } - } - - // Add to discover download bar - addDiscoverDownload(virtualPlaylistId, playlistName, playlistType, imageUrl); - - // Start polling for progress updates - startDiscoverSyncPolling(playlistType, virtualPlaylistId); } // Track active discover sync pollers @@ -9147,6 +9048,13 @@ function renderDiscoverSyncCard(playlist, container, sourceLabel) {
+
+ + +