From 23b02147120856ad59680d0dd4f28c2dbdb1f40e 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 01/18] 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 | 313 ++++++++++++++-
webui/static/sync-services.js | 5 +
7 files changed, 1437 insertions(+), 23 deletions(-)
diff --git a/web_server.py b/web_server.py
index 1e9c6196..3acf4143 100644
--- a/web_server.py
+++ b/web_server.py
@@ -28923,8 +28923,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:
@@ -31632,6 +31640,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')),
@@ -32109,8 +32118,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")
@@ -32133,6 +32161,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', []))
@@ -32151,6 +32182,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
@@ -32524,7 +32564,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'),
@@ -32625,6 +32665,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:
@@ -32635,6 +32679,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:
@@ -32674,14 +32721,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 ==
@@ -33346,6 +33497,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:
@@ -39488,6 +39641,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,
@@ -39497,7 +39657,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:
@@ -43848,6 +44008,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",
@@ -43864,6 +44027,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():
"""
@@ -43937,6 +44363,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"""
@@ -46523,7 +47009,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 c73140d7..eb289636 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -837,8 +837,7 @@
@@ -874,6 +873,9 @@
Beatport
+
+ SoulSync Discover
+
Import
@@ -1747,6 +1749,17 @@
+
+
+
+
+ Force DL
+
+
+
+
+
@@ -9174,10 +9082,15 @@ async function toggleDiscoverAutoUpdate(playlistType, enabled) {
const _discoverSyncQueue = [];
let _discoverSyncRunning = false;
-async function syncDiscoverPlaylistFromTab(playlistType, playlistName) {
+async function syncDiscoverPlaylistFromTab(playlistType, playlistName, forceDownload) {
+ // Read force-download toggle if not explicitly passed
+ if (forceDownload === undefined) {
+ const fdToggle = document.getElementById(`discover-force-dl-${playlistType}`);
+ forceDownload = fdToggle ? fdToggle.checked : false;
+ }
// Serialize sync operations to avoid concurrent backend contention
return new Promise((resolve) => {
- _discoverSyncQueue.push({ playlistType, playlistName, resolve });
+ _discoverSyncQueue.push({ playlistType, playlistName, forceDownload, resolve });
_processDiscoverSyncQueue();
});
}
@@ -9185,9 +9098,9 @@ async function syncDiscoverPlaylistFromTab(playlistType, playlistName) {
async function _processDiscoverSyncQueue() {
if (_discoverSyncRunning || _discoverSyncQueue.length === 0) return;
_discoverSyncRunning = true;
- const { playlistType, playlistName, resolve } = _discoverSyncQueue.shift();
+ const { playlistType, playlistName, forceDownload, resolve } = _discoverSyncQueue.shift();
try {
- await _doSyncDiscoverPlaylist(playlistType, playlistName);
+ await _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload);
} finally {
_discoverSyncRunning = false;
resolve();
@@ -9195,7 +9108,7 @@ async function _processDiscoverSyncQueue() {
}
}
-async function _doSyncDiscoverPlaylist(playlistType, playlistName) {
+async function _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload) {
const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
if (btn) {
btn.disabled = true;
@@ -9245,19 +9158,22 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName) {
// 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 bodyPayload = {
+ tracks: syncTracks,
+ playlist_name: playlistName
+ };
+ if (forceDownload) bodyPayload.force_download_all = true;
+
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
- })
+ body: JSON.stringify(bodyPayload)
});
const result = await batchResponse.json();
if (result.success) {
- showToast(`Downloading ${playlistName} (${syncTracks.length} tracks)...`, 'info');
+ const forceLabel = forceDownload ? ' (force download)' : '';
+ showToast(`Downloading ${playlistName}${forceLabel} (${syncTracks.length} tracks)...`, 'info');
const card = document.getElementById(`discover-sync-card-${playlistType}`);
if (card) {
const statusEl = card.querySelector('.discover-sync-status');
diff --git a/webui/static/init.js b/webui/static/init.js
index a92a010f..0e9add42 100644
--- a/webui/static/init.js
+++ b/webui/static/init.js
@@ -2152,7 +2152,13 @@ function navigateToPage(pageId, options = {}) {
// Artists page, now replaced by clicking artists from the unified Search.
if (pageId === 'downloads' || pageId === 'artists') pageId = 'search';
- if (pageId === currentPage) return;
+ if (pageId === currentPage) {
+ // Already on this page — still process pending sync tab actions
+ if (pageId === 'sync' && window._pendingSyncTabAction && typeof _applySyncTabAction === 'function') {
+ _applySyncTabAction();
+ }
+ return;
+ }
// Permission guard — redirect to home page if not allowed
if (!isPageAllowed(pageId)) {
@@ -2246,6 +2252,10 @@ async function loadPageData(pageId) {
case 'sync':
initializeSyncPage();
await loadSyncData();
+ // Process any pending deep-link tab switch (e.g. from Discover page)
+ if (window._pendingSyncTabAction && typeof _applySyncTabAction === 'function') {
+ _applySyncTabAction();
+ }
break;
case 'search':
initializeSearch();
diff --git a/webui/static/style.css b/webui/static/style.css
index bc5b9d4a..af33c023 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -60166,6 +60166,16 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
opacity: 0.4;
}
+/* Deep-link highlight animation */
+.discover-sync-card-highlight {
+ animation: discover-card-glow 2.5s ease-out;
+}
+
+@keyframes discover-card-glow {
+ 0% { border-color: rgba(167, 139, 250, 0.7); box-shadow: 0 0 20px rgba(167, 139, 250, 0.3); }
+ 100% { border-color: rgba(255, 255, 255, 0.08); box-shadow: none; }
+}
+
.discover-sync-empty-hint {
background: rgba(255, 193, 7, 0.08);
border: 1px solid rgba(255, 193, 7, 0.25);
diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js
index 525e4558..eb95ef18 100644
--- a/webui/static/sync-services.js
+++ b/webui/static/sync-services.js
@@ -2736,6 +2736,54 @@ async function startDeezerDownloadMissing(urlHash) {
// SYNC PAGE FUNCTIONALITY (REDESIGNED)
// ===============================
+/**
+ * Navigate to the Sync page and activate a specific tab.
+ * Works from any page. If already on the sync page, just switches the tab.
+ * @param {string} tabId - Tab data-tab value (e.g. 'discover', 'spotify', 'mirrored')
+ * @param {object} [opts] - Options
+ * @param {string} [opts.highlight] - Element ID to scroll to and briefly highlight
+ * @param {string} [opts.autoSync] - Discover playlist type to auto-trigger sync on
+ * @param {boolean} [opts.forceDownload] - Pass force_download_all when auto-syncing
+ */
+function navigateToSyncTab(tabId, opts) {
+ window._pendingSyncTabAction = { tabId, ...(opts || {}) };
+ if (typeof currentPage !== 'undefined' && currentPage === 'sync') {
+ _applySyncTabAction();
+ } else {
+ navigateToPage('sync');
+ }
+}
+
+function _applySyncTabAction() {
+ const action = window._pendingSyncTabAction;
+ if (!action) return;
+ window._pendingSyncTabAction = null;
+ const tabId = action.tabId;
+
+ // Click the target tab button to trigger normal tab-switch logic
+ const btn = document.querySelector(`.sync-tab-button[data-tab="${tabId}"]`);
+ if (btn && !btn.classList.contains('active')) {
+ btn.click();
+ }
+
+ // Wait for lazy-loaded content, then highlight / auto-sync
+ const apply = () => {
+ if (action.highlight) {
+ const el = document.getElementById(action.highlight);
+ if (el) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ el.classList.add('discover-sync-card-highlight');
+ setTimeout(() => el.classList.remove('discover-sync-card-highlight'), 2500);
+ }
+ }
+ if (action.autoSync) {
+ syncDiscoverPlaylistFromTab(action.autoSync, action.autoSyncName || action.autoSync, action.forceDownload);
+ }
+ };
+ // Small delay to let lazy tab content render
+ setTimeout(apply, 400);
+}
+
function initializeSyncPage() {
// Logic for tab switching
const tabButtons = document.querySelectorAll('.sync-tab-button');
From a967a3d5263c2bfa8046a2a8b85fe9684538c9ed Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Thu, 23 Apr 2026 01:30:43 -0700
Subject: [PATCH 04/18] Fire-and-forget discover sync with toast link to sync
tab
---
webui/static/discover.js | 115 ++++++++++++++++++++++++++++++++++++---
1 file changed, 108 insertions(+), 7 deletions(-)
diff --git a/webui/static/discover.js b/webui/static/discover.js
index f23843c5..cf3b2b11 100644
--- a/webui/static/discover.js
+++ b/webui/static/discover.js
@@ -7859,14 +7859,115 @@ function checkForActiveDiscoverDownloads() {
}
async function startDiscoverPlaylistSync(playlistType, playlistName) {
- console.log(`🔄 Navigating to Sync → Discover tab for ${playlistName}`);
+ console.log(`🔄 Starting sync for ${playlistName} (fire-and-forget from Discover page)`);
- // Navigate to the Sync page → Discover tab, highlight the card, and auto-sync
- navigateToSyncTab('discover', {
- highlight: `discover-sync-card-${playlistType}`,
- autoSync: playlistType,
- autoSyncName: playlistName,
- });
+ // Disable the sync button on the Discover page
+ 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';
+ }
+
+ try {
+ // Fetch tracks from API
+ const apiUrl = _discoverPlaylistApiUrl(playlistType);
+ if (!apiUrl) {
+ showToast(`Unknown playlist type: ${playlistType}`, 'error');
+ if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
+ return;
+ }
+
+ const tracksResponse = await fetch(apiUrl);
+ let tracks = [];
+ if (tracksResponse.ok) {
+ const data = await tracksResponse.json();
+ tracks = data.tracks || [];
+ }
+
+ if (!tracks.length) {
+ showToast(`No tracks available for ${playlistName}`, 'warning');
+ if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
+ return;
+ }
+
+ // Convert to sync format
+ 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}`;
+
+ // Fire the batch download
+ 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) {
+ // Show toast with clickable link to Sync → Discover tab
+ _showSyncToastWithLink(
+ `${playlistName} (${syncTracks.length} tracks) syncing...`,
+ 'info',
+ 'View in Sync \u2192',
+ () => navigateToSyncTab('discover', { highlight: `discover-sync-card-${playlistType}` })
+ );
+ } else {
+ showToast(`Failed to start sync: ${result.error || 'Unknown error'}`, 'error');
+ if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
+ }
+ } catch (error) {
+ console.error(`Error syncing ${playlistName}:`, error);
+ showToast(`Failed to sync ${playlistName}`, 'error');
+ if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
+ }
+}
+
+/**
+ * Show a toast with a clickable action link (like showToast but with a custom link).
+ */
+function _showSyncToastWithLink(message, type, linkText, onClick) {
+ const container = document.getElementById('toast-container');
+ if (!container) { showToast(message, type); return; }
+
+ const icon = { success: '\u2705', error: '\u274c', warning: '\u26a0\ufe0f', info: '\u2139\ufe0f' }[type] || '\u2139\ufe0f';
+ const toast = document.createElement('div');
+ toast.className = `toast-compact toast-${type}`;
+ toast.innerHTML = `${icon} ${_escToast(message)} `;
+
+ const link = document.createElement('span');
+ link.className = 'toast-compact-link';
+ link.textContent = linkText;
+ link.onclick = e => { e.stopPropagation(); onClick(); };
+ toast.appendChild(link);
+
+ toast.onclick = () => { toast.classList.add('toast-exit'); setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 200); };
+ container.appendChild(toast);
+ requestAnimationFrame(() => toast.classList.add('toast-enter'));
+
+ setTimeout(() => {
+ if (container.contains(toast)) {
+ toast.classList.add('toast-exit');
+ setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 300);
+ }
+ }, 6000);
}
// Track active discover sync pollers
From 1103efa11336fd68dddc539eaea8347303920b07 Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Thu, 23 Apr 2026 10:26:17 -0700
Subject: [PATCH 05/18] Fix seasonal playlist track ID for non-spotify/itunes
sources, fix polling interval leak
---
core/seasonal_discovery.py | 6 ++++--
web_server.py | 11 ++++++++---
webui/static/discover.js | 21 +++++++++++++++++++++
webui/static/init.js | 7 +++++++
4 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py
index 0e7dfadc..16ede399 100644
--- a/core/seasonal_discovery.py
+++ b/core/seasonal_discovery.py
@@ -371,8 +371,10 @@ class SeasonalDiscoveryService:
config = SEASONAL_CONFIG[season_key]
keywords = config['keywords']
- # Use the right track ID column based on source
- track_id_col = 'spotify_track_id' if source == 'spotify' else 'itunes_track_id'
+ # itunes stores IDs in itunes_track_id; all other sources
+ # (spotify, deezer, discogs, hydrabase, etc.) use spotify_track_id
+ # as the generic ID column.
+ track_id_col = 'itunes_track_id' if source == 'itunes' else 'spotify_track_id'
seasonal_tracks = []
diff --git a/web_server.py b/web_server.py
index ab8b926f..48f5a28b 100644
--- a/web_server.py
+++ b/web_server.py
@@ -44407,7 +44407,10 @@ def get_current_seasonal_playlist():
if not track_ids:
return jsonify({"success": True, "tracks": []})
- track_id_col = 'spotify_track_id' if active_source == 'spotify' else 'itunes_track_id'
+ # itunes stores IDs in itunes_track_id; all other sources
+ # (spotify, deezer, discogs, hydrabase, etc.) use spotify_track_id
+ # as the generic ID column.
+ track_id_col = 'itunes_track_id' if active_source == 'itunes' else 'spotify_track_id'
tracks = []
with database._get_connection() as conn:
cursor = conn.cursor()
@@ -44536,8 +44539,10 @@ def get_seasonal_playlist(season_key):
if not track_ids:
return jsonify({"success": True, "tracks": []})
- # Use source-appropriate ID column for lookups
- track_id_col = 'spotify_track_id' if active_source == 'spotify' else 'itunes_track_id'
+ # itunes stores IDs in itunes_track_id; all other sources
+ # (spotify, deezer, discogs, hydrabase, etc.) use spotify_track_id
+ # as the generic ID column.
+ track_id_col = 'itunes_track_id' if active_source == 'itunes' else 'spotify_track_id'
# Fetch track details from seasonal tracks or discovery pool (filtered by source)
tracks = []
diff --git a/webui/static/discover.js b/webui/static/discover.js
index cf3b2b11..79bc375d 100644
--- a/webui/static/discover.js
+++ b/webui/static/discover.js
@@ -9342,7 +9342,23 @@ function pollDiscoverSyncFromTab(playlistType, virtualPlaylistId, playlistName)
}
function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) {
+ // Clear any existing poller for this playlist type
+ if (discoverSyncPollers[playlistType]) {
+ clearInterval(discoverSyncPollers[playlistType]);
+ delete discoverSyncPollers[playlistType];
+ }
+
+ let ticks = 0;
+ const maxTicks = 600; // 30 min at 3s intervals
+
const pollInterval = setInterval(async () => {
+ ticks++;
+ // Stall guard — stop polling after maxTicks or if the card is no longer in DOM
+ if (ticks > maxTicks || !document.getElementById(`discover-sync-card-${playlistType}`)) {
+ clearInterval(pollInterval);
+ delete discoverSyncPollers[playlistType];
+ return;
+ }
try {
const resp = await fetch(`/api/playlists/${batchId}/download_status`);
if (!resp.ok) { clearInterval(pollInterval); return; }
@@ -9351,6 +9367,7 @@ function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) {
if (phase === 'complete' || phase === 'error' || phase === 'cancelled') {
clearInterval(pollInterval);
+ delete discoverSyncPollers[playlistType];
const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
@@ -9397,8 +9414,12 @@ function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) {
}
} catch (error) {
clearInterval(pollInterval);
+ delete discoverSyncPollers[playlistType];
}
}, 3000);
+
+ // Register so page-leave cleanup can clear it
+ discoverSyncPollers[playlistType] = pollInterval;
}
/**
diff --git a/webui/static/init.js b/webui/static/init.js
index 0e9add42..d46ac6d8 100644
--- a/webui/static/init.js
+++ b/webui/static/init.js
@@ -2243,6 +2243,13 @@ async function loadPageData(pageId) {
if (typeof _stopNebulaLivePolling === 'function') _stopNebulaLivePolling();
if (pageId !== 'sync') {
cleanupBeatportContent();
+ // Clear any discover sync tab pollers when leaving the sync page
+ if (typeof discoverSyncPollers === 'object') {
+ for (const key of Object.keys(discoverSyncPollers)) {
+ clearInterval(discoverSyncPollers[key]);
+ delete discoverSyncPollers[key];
+ }
+ }
}
switch (pageId) {
case 'dashboard':
From a12f31fafa635d5ffdaa162eb8fbac31d71ce57c Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Thu, 23 Apr 2026 10:30:27 -0700
Subject: [PATCH 06/18] Skip playlist server push for non-navidrome servers
instead of failing
---
web_server.py | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/web_server.py b/web_server.py
index 48f5a28b..7bf1ba48 100644
--- a/web_server.py
+++ b/web_server.py
@@ -32750,9 +32750,17 @@ def _record_sync_history_completion(batch_id, batch):
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."""
+ Supports discover, mirrored, and auto-mirror playlists.
+ Currently only Navidrome is supported; Plex/Jellyfin users get 'skipped'."""
database = get_database()
try:
+ # Only Navidrome supports playlist push right now
+ active_server = config_manager.get_active_media_server()
+ if active_server != 'navidrome' or not navidrome_client or not navidrome_client.is_connected():
+ logger.info(f"[PlaylistPush] Server push skipped — active server is '{active_server}', not navidrome (or not connected)")
+ database.update_sync_history_push_status(batch_id, 'skipped')
+ return
+
playlist_id = batch.get('playlist_id', '')
playlist_name = batch.get('playlist_name', '')
if not playlist_name:
From fce62f7b837a38851c472c0ce2eaf05729b5c043 Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Thu, 23 Apr 2026 10:33:50 -0700
Subject: [PATCH 07/18] Support Plex and Jellyfin in playlist server push, add
Jellyfin search_tracks
---
core/jellyfin_client.py | 44 ++++++++++++++++++++++++-
web_server.py | 73 ++++++++++++++++++++---------------------
2 files changed, 78 insertions(+), 39 deletions(-)
diff --git a/core/jellyfin_client.py b/core/jellyfin_client.py
index 4cf1500e..47ca8152 100644
--- a/core/jellyfin_client.py
+++ b/core/jellyfin_client.py
@@ -1194,7 +1194,49 @@ class JellyfinClient:
stats['bulk_tracks_cached'] = len(self._all_tracks_cache)
return stats
-
+
+ def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[JellyfinTrack]:
+ """Search for tracks by title and artist on the Jellyfin server."""
+ if not self.ensure_connection():
+ return []
+
+ try:
+ search_term = f"{artist} {title}".strip()
+ params = {
+ 'ParentId': self.music_library_id,
+ 'IncludeItemTypes': 'Audio',
+ 'Recursive': True,
+ 'SearchTerm': search_term,
+ 'Fields': 'AlbumId,ArtistItems,Path,MediaSources',
+ 'Limit': limit,
+ }
+
+ response = self._make_request(f'/Users/{self.user_id}/Items', params)
+ if not response:
+ return []
+
+ results = []
+ lower_title = title.lower()
+ lower_artist = artist.lower()
+ for item in response.get('Items', []):
+ track = JellyfinTrack(item, self)
+ # Basic relevance filter: title should appear in track name
+ if lower_title and lower_title not in track.title.lower():
+ continue
+ # If artist provided, check artist names
+ if lower_artist:
+ artist_names = [a.get('Name', '').lower() for a in item.get('ArtistItems', [])]
+ album_artist = (item.get('AlbumArtist') or '').lower()
+ if not any(lower_artist in n for n in artist_names) and lower_artist not in album_artist:
+ continue
+ results.append(track)
+
+ return results[:limit]
+
+ except Exception as e:
+ logger.error(f"Error searching Jellyfin tracks for '{title}' by '{artist}': {e}")
+ return []
+
def get_all_playlists(self) -> List[JellyfinPlaylistInfo]:
"""Get all playlists from Jellyfin server"""
if not self.ensure_connection():
diff --git a/web_server.py b/web_server.py
index 7bf1ba48..6af8d1e6 100644
--- a/web_server.py
+++ b/web_server.py
@@ -32748,16 +32748,23 @@ def _record_sync_history_completion(batch_id, batch):
def _push_playlist_to_server(batch_id, batch):
- """After a batch completes, push the playlist to the media server.
+ """After a batch completes, push the playlist to the active 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.
- Currently only Navidrome is supported; Plex/Jellyfin users get 'skipped'."""
+ Supports Navidrome, Plex, and Jellyfin."""
database = get_database()
try:
- # Only Navidrome supports playlist push right now
+ # Resolve the active media server client
active_server = config_manager.get_active_media_server()
- if active_server != 'navidrome' or not navidrome_client or not navidrome_client.is_connected():
- logger.info(f"[PlaylistPush] Server push skipped — active server is '{active_server}', not navidrome (or not connected)")
+ server_client = None
+ if active_server == 'navidrome' and navidrome_client and navidrome_client.is_connected():
+ server_client = navidrome_client
+ elif active_server == 'plex' and plex_client and plex_client.is_connected():
+ server_client = plex_client
+ elif active_server == 'jellyfin' and jellyfin_client and jellyfin_client.is_connected():
+ server_client = jellyfin_client
+
+ if not server_client:
+ logger.info(f"[PlaylistPush] Server push skipped — no connected media server (active: '{active_server}')")
database.update_sync_history_push_status(batch_id, 'skipped')
return
@@ -32808,53 +32815,43 @@ def _push_playlist_to_server(batch_id, batch):
database.update_sync_history_push_status(batch_id, 'skipped')
return
- logger.info(f"[PlaylistPush] {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 {active_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"Playlist push: {playlist_name}")
+ server_client.trigger_library_scan()
# 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"[PlaylistPush] Scan complete, searching for tracks")
- else:
- time.sleep(30)
+ for _ in range(18):
+ time.sleep(5)
+ if not server_client.is_library_scanning():
+ break
+ logger.info(f"[PlaylistPush] Scan complete, searching for tracks on {active_server}")
# 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"[PlaylistPush] Matched: '{t['title']}' by '{t['artist']}' → {best.id}")
- else:
- matched_server_tracks.append(best)
- else:
- logger.info(f"[PlaylistPush] No match for: '{t['title']}' by '{t['artist']}'")
+ for t in tracks_to_find:
+ results = server_client.search_tracks(t['title'], t['artist'], limit=5)
+ if results:
+ best = results[0]
+ # Navidrome/Plex store the original server object for playlist creation
+ original = getattr(best, '_original_navidrome_track', None) or getattr(best, '_original_plex_track', None)
+ matched_server_tracks.append(original if original else best)
+ logger.debug(f"[PlaylistPush] Matched: '{t['title']}' by '{t['artist']}' → {getattr(best, 'id', getattr(best, 'ratingKey', '?'))}")
+ else:
+ logger.info(f"[PlaylistPush] No match for: '{t['title']}' by '{t['artist']}'")
if not matched_server_tracks:
- logger.warning(f"[PlaylistPush] No tracks matched on server for {playlist_name}")
+ logger.warning(f"[PlaylistPush] No tracks matched on {active_server} for {playlist_name}")
database.update_sync_history_push_status(batch_id, 'failed')
return
- 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)
+ logger.info(f"[PlaylistPush] Pushing {len(matched_server_tracks)}/{len(tracks_to_find)} tracks to '{playlist_name}' on {active_server}")
+ success = server_client.update_playlist(playlist_name, matched_server_tracks)
if success:
- logger.info(f"[PlaylistPush] Successfully pushed '{playlist_name}' to server with {len(matched_server_tracks)} tracks")
+ logger.info(f"[PlaylistPush] Successfully pushed '{playlist_name}' to {active_server} with {len(matched_server_tracks)} tracks")
database.update_sync_history_push_status(batch_id, 'success')
else:
- logger.warning(f"[PlaylistPush] Failed to push '{playlist_name}' to server")
+ logger.warning(f"[PlaylistPush] Failed to push '{playlist_name}' to {active_server}")
database.update_sync_history_push_status(batch_id, 'failed')
except Exception as e:
From 68a23b5dc5edb349a35ce91decdb9d5a73fee38f Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Thu, 23 Apr 2026 11:13:07 -0700
Subject: [PATCH 08/18] Route discover batch card clicks to discover download
modal
---
webui/static/pages-extra.js | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js
index 1ac5f919..6aaf6694 100644
--- a/webui/static/pages-extra.js
+++ b/webui/static/pages-extra.js
@@ -2638,6 +2638,12 @@ function _adlOpenBatchModal(batchId, playlistId, batchName) {
return;
}
+ // For discover batches, use the discover-specific modal path
+ if (playlistId.startsWith('discover_') && typeof openDiscoverDownloadModal === 'function') {
+ openDiscoverDownloadModal(playlistId);
+ return;
+ }
+
// For other batches, try to show existing modal or rehydrate
for (const [pid, process] of Object.entries(activeDownloadProcesses)) {
if (process.batchId === batchId && process.modalElement && document.body.contains(process.modalElement)) {
From dbe3db2a7bada553b6b954ebd11f8e7408bbf3cf Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Thu, 23 Apr 2026 11:26:21 -0700
Subject: [PATCH 09/18] fix: add missing closing brace in style.css that broke
discover sync CSS
---
webui/static/style.css | 1 +
1 file changed, 1 insertion(+)
diff --git a/webui/static/style.css b/webui/static/style.css
index af33c023..fcde56c0 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -59969,6 +59969,7 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
margin: 3px 6px;
+}
padding: 8px 14px;
display: flex;
align-items: center;
From 450e246b776c4d1b83fd5f7ace119d3382f0f56a Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Thu, 23 Apr 2026 13:37:59 -0700
Subject: [PATCH 10/18] discover: replace Force DL toggle with Any Quality
placeholder (Phase 0)
Replace the misleading Force DL toggle on the Sync page Discover tab with
a disabled Any Quality toggle. Stop sending force_download_all from
discover sync so discover playlists run ownership analysis instead of
blindly re-downloading owned tracks.
Adds PLAN-download-quality-flags.md describing the full refactor plan.
---
PLAN-download-quality-flags.md | 123 +++++++++++++++++++++++++++++++++
webui/static/discover.js | 27 +++-----
webui/static/sync-services.js | 2 +-
3 files changed, 134 insertions(+), 18 deletions(-)
create mode 100644 PLAN-download-quality-flags.md
diff --git a/PLAN-download-quality-flags.md b/PLAN-download-quality-flags.md
new file mode 100644
index 00000000..f68ccb89
--- /dev/null
+++ b/PLAN-download-quality-flags.md
@@ -0,0 +1,123 @@
+# Plan: Download Quality Flags Refactor
+
+## Background
+
+The current `force_download_all` flag is misleadingly named. It does NOT lower
+quality requirements or "force download any quality". It only skips the library
+ownership check so every track is treated as missing and re-downloaded regardless
+of whether the user already owns it.
+
+This causes two distinct problems:
+
+1. The name implies quality-related behavior that doesn't exist.
+2. The discover sync tab currently sends `force_download_all: true`, which means
+ every discover playlist sync re-downloads tracks the user already owns.
+
+Additionally, users have expressed a real need for a per-batch quality override:
+their main library should stay strict (FLAC preferred, high bitrate) while
+rotating/ephemeral playlists (discover) should grab whatever is available for
+quantity over quality.
+
+Per-client fallback settings already exist (Soulseek quality profile
+`fallback_enabled`, Deezer/Tidal/Qobuz `allow_fallback` chains) but they are
+global all-or-nothing flags. There is no way today to say "relax quality just
+for this one batch".
+
+## Goals
+
+1. Rename `force_download_all` to reflect what it actually does (skip ownership
+ check / re-download owned).
+2. Stop discover sync from blindly re-downloading owned tracks.
+3. Add a new per-batch "Any Quality" flag that bypasses quality filtering for
+ that specific batch only, without touching the user's global quality
+ settings.
+
+## Non-Goals
+
+- No changes to the global per-client quality/fallback settings.
+- No changes to the matching engine scoring.
+- No changes to album consistency / MusicBrainz preflight logic.
+
+## Phase 0 (this PR): UI framework only
+
+Scope is tiny and safe to ship immediately.
+
+- Replace the "Force DL" toggle in the Sync page Discover tab with an "Any
+ Quality" toggle.
+- Leave the new toggle permanently disabled / greyed out for now.
+- Tooltip on the toggle reads something like "Coming soon: download any
+ available quality for this batch".
+- Remove the `force_download_all: true` body payload from the discover sync
+ path. Discover playlists will now always run ownership analysis.
+- No backend changes in this phase.
+
+Files touched:
+- `webui/static/discover.js` - replace toggle HTML, remove `forceDownload`
+ plumbing from `syncDiscoverPlaylistFromTab` / `_doSyncDiscoverPlaylist`.
+
+## Phase 1: Rename `force_download_all`
+
+Rename to `skip_ownership_check` (backend) and surface in the UI as
+"Re-download Owned" (or equivalent).
+
+- Backend: add new key `skip_ownership_check` everywhere the flag is used.
+ Accept both keys on inbound API payloads for one release (back-compat).
+- Frontend: rename the Wishlist / Downloads modal toggles, keep the same
+ default behavior (wishlists still skip the library check by default).
+- Update `helper.js` tooltip description to match the new name and behavior.
+
+Files touched:
+- `web_server.py` (lines ~15663, 24858, 25858, 26065, 29051, 29057, 29062,
+ 29104, 29135, 33556, 33596)
+- `webui/static/downloads.js` (lines ~195, 598, 2152, 2155, 2195, 2436)
+- `webui/static/wishlist-tools.js` (line ~6347)
+- `webui/static/helper.js` (lines ~663)
+
+## Phase 2: Implement "Any Quality" per-batch override
+
+Introduce a new batch flag `any_quality` that, when set, bypasses quality
+filtering for that batch only.
+
+Backend behavior:
+- Add `any_quality` to the batch dict alongside `skip_ownership_check`.
+- For Soulseek: when `any_quality` is true, skip the call to
+ `soulseek_client.filter_results_by_quality_preference()` and pass ranked
+ candidates through unchanged.
+- For Deezer / Tidal / Qobuz: when `any_quality` is true, temporarily force
+ the candidate selection path to treat `allow_fallback=True` AND start from
+ the lowest quality tier so downloads succeed fastest.
+- The flag is per-batch only. Global quality profile / `allow_fallback`
+ settings remain untouched.
+
+Frontend behavior:
+- Enable the "Any Quality" toggle added in Phase 0.
+- Remove the "Coming soon" tooltip, replace with a real description.
+- Wire the toggle into the discover sync POST body as `any_quality: true`.
+- Consider exposing the same toggle on the Wishlist / Downloads modal.
+
+Files touched:
+- `web_server.py` - batch creation + candidate selection path
+- `core/soulseek_client.py` - accept override in filter call
+- `core/deezer_download_client.py`, `core/tidal_download_client.py`,
+ `core/qobuz_client.py` - accept per-request quality override
+- `webui/static/discover.js` - enable toggle, send flag
+- `webui/static/downloads.js` - add matching toggle on manual download
+ modals
+
+## Risks / Open Questions
+
+- Soulseek quality profile bypass: is it safe to pass all density-filtered
+ candidates through without the priority filter? Likely yes, since the
+ matching engine already ranks by confidence and peer quality.
+- Streaming clients with strict API quality params (Tidal HiRes vs Lossless
+ entitlement, Qobuz subscription tiers): forcing lowest tier should be safe
+ for all users regardless of subscription.
+- Back-compat on the renamed flag: keep the old `force_download_all` key
+ accepted for at least one release cycle to avoid breaking any third-party
+ callers or stale browser sessions.
+
+## Rollout
+
+- Phase 0 ships with this PR (UI framework + discover fix).
+- Phase 1 and Phase 2 can ship as separate PRs on dev.
+- No migrations required; all flags are request-scoped and batch-scoped.
diff --git a/webui/static/discover.js b/webui/static/discover.js
index 79bc375d..78800af5 100644
--- a/webui/static/discover.js
+++ b/webui/static/discover.js
@@ -9133,10 +9133,10 @@ function renderDiscoverSyncCard(playlist, container, sourceLabel) {
-
-
Force DL
-
-
+
+ Any Quality
+
+
@@ -9183,15 +9183,10 @@ async function toggleDiscoverAutoUpdate(playlistType, enabled) {
const _discoverSyncQueue = [];
let _discoverSyncRunning = false;
-async function syncDiscoverPlaylistFromTab(playlistType, playlistName, forceDownload) {
- // Read force-download toggle if not explicitly passed
- if (forceDownload === undefined) {
- const fdToggle = document.getElementById(`discover-force-dl-${playlistType}`);
- forceDownload = fdToggle ? fdToggle.checked : false;
- }
+async function syncDiscoverPlaylistFromTab(playlistType, playlistName) {
// Serialize sync operations to avoid concurrent backend contention
return new Promise((resolve) => {
- _discoverSyncQueue.push({ playlistType, playlistName, forceDownload, resolve });
+ _discoverSyncQueue.push({ playlistType, playlistName, resolve });
_processDiscoverSyncQueue();
});
}
@@ -9199,9 +9194,9 @@ async function syncDiscoverPlaylistFromTab(playlistType, playlistName, forceDown
async function _processDiscoverSyncQueue() {
if (_discoverSyncRunning || _discoverSyncQueue.length === 0) return;
_discoverSyncRunning = true;
- const { playlistType, playlistName, forceDownload, resolve } = _discoverSyncQueue.shift();
+ const { playlistType, playlistName, resolve } = _discoverSyncQueue.shift();
try {
- await _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload);
+ await _doSyncDiscoverPlaylist(playlistType, playlistName);
} finally {
_discoverSyncRunning = false;
resolve();
@@ -9209,7 +9204,7 @@ async function _processDiscoverSyncQueue() {
}
}
-async function _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload) {
+async function _doSyncDiscoverPlaylist(playlistType, playlistName) {
const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
if (btn) {
btn.disabled = true;
@@ -9263,7 +9258,6 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload
tracks: syncTracks,
playlist_name: playlistName
};
- if (forceDownload) bodyPayload.force_download_all = true;
const batchResponse = await fetch(`/api/playlists/${virtualPlaylistId}/start-missing-process`, {
method: 'POST',
@@ -9273,8 +9267,7 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload
const result = await batchResponse.json();
if (result.success) {
- const forceLabel = forceDownload ? ' (force download)' : '';
- showToast(`Downloading ${playlistName}${forceLabel} (${syncTracks.length} tracks)...`, 'info');
+ showToast(`Downloading ${playlistName} (${syncTracks.length} tracks)...`, 'info');
const card = document.getElementById(`discover-sync-card-${playlistType}`);
if (card) {
const statusEl = card.querySelector('.discover-sync-status');
diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js
index eb95ef18..26a817f1 100644
--- a/webui/static/sync-services.js
+++ b/webui/static/sync-services.js
@@ -2777,7 +2777,7 @@ function _applySyncTabAction() {
}
}
if (action.autoSync) {
- syncDiscoverPlaylistFromTab(action.autoSync, action.autoSyncName || action.autoSync, action.forceDownload);
+ syncDiscoverPlaylistFromTab(action.autoSync, action.autoSyncName || action.autoSync);
}
};
// Small delay to let lazy tab content render
From 04f958d88e46b0349bdc9b6aedaa30f52a7eed2e Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Thu, 23 Apr 2026 13:40:10 -0700
Subject: [PATCH 11/18] fix: server push status NameError in completion
handlers
- Replace bare 'database' references with get_database() calls in both
completion handlers (NameError was silently swallowed, leaving
server_push_status null even after successful Navidrome pushes).
- Add playlist_id and playlist_name to download_missing batch dicts so
the push prefix check fires for those code paths.
---
web_server.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/web_server.py b/web_server.py
index 6af8d1e6..d3264873 100644
--- a/web_server.py
+++ b/web_server.py
@@ -28931,7 +28931,7 @@ def _on_download_completed(batch_id, task_id, success=True):
'listenbrainz_', 'beatport_',
)
if playlist_id and playlist_id.startswith(_push_prefixes):
- database.update_sync_history_push_status(batch_id, 'pending')
+ get_database().update_sync_history_push_status(batch_id, 'pending')
threading.Thread(
target=_push_playlist_to_server,
args=(batch_id, batch),
@@ -31161,6 +31161,8 @@ def start_playlist_missing_downloads(playlist_id):
'active_count': 0,
'max_concurrent': _get_max_concurrent(),
'queue_index': 0,
+ 'playlist_id': playlist_id,
+ 'playlist_name': playlist_name,
# Track state management (replicating sync.py)
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
@@ -32197,7 +32199,7 @@ def _check_batch_completion_v2(batch_id):
'listenbrainz_', 'beatport_',
)
if playlist_id and playlist_id.startswith(_push_prefixes):
- database.update_sync_history_push_status(batch_id, 'pending')
+ get_database().update_sync_history_push_status(batch_id, 'pending')
threading.Thread(
target=_push_playlist_to_server,
args=(batch_id, batch),
@@ -33613,6 +33615,8 @@ def start_missing_downloads():
'active_count': 0,
'max_concurrent': _get_max_concurrent(),
'queue_index': 0,
+ 'playlist_id': playlist_id,
+ 'playlist_name': 'Legacy Modal',
# Track state management (replicating sync.py)
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
From d33712c829e99daa14ee15dba4a27afe71aa485c Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Thu, 23 Apr 2026 13:42:55 -0700
Subject: [PATCH 12/18] remove plan file from branch
---
PLAN-download-quality-flags.md | 123 ---------------------------------
1 file changed, 123 deletions(-)
delete mode 100644 PLAN-download-quality-flags.md
diff --git a/PLAN-download-quality-flags.md b/PLAN-download-quality-flags.md
deleted file mode 100644
index f68ccb89..00000000
--- a/PLAN-download-quality-flags.md
+++ /dev/null
@@ -1,123 +0,0 @@
-# Plan: Download Quality Flags Refactor
-
-## Background
-
-The current `force_download_all` flag is misleadingly named. It does NOT lower
-quality requirements or "force download any quality". It only skips the library
-ownership check so every track is treated as missing and re-downloaded regardless
-of whether the user already owns it.
-
-This causes two distinct problems:
-
-1. The name implies quality-related behavior that doesn't exist.
-2. The discover sync tab currently sends `force_download_all: true`, which means
- every discover playlist sync re-downloads tracks the user already owns.
-
-Additionally, users have expressed a real need for a per-batch quality override:
-their main library should stay strict (FLAC preferred, high bitrate) while
-rotating/ephemeral playlists (discover) should grab whatever is available for
-quantity over quality.
-
-Per-client fallback settings already exist (Soulseek quality profile
-`fallback_enabled`, Deezer/Tidal/Qobuz `allow_fallback` chains) but they are
-global all-or-nothing flags. There is no way today to say "relax quality just
-for this one batch".
-
-## Goals
-
-1. Rename `force_download_all` to reflect what it actually does (skip ownership
- check / re-download owned).
-2. Stop discover sync from blindly re-downloading owned tracks.
-3. Add a new per-batch "Any Quality" flag that bypasses quality filtering for
- that specific batch only, without touching the user's global quality
- settings.
-
-## Non-Goals
-
-- No changes to the global per-client quality/fallback settings.
-- No changes to the matching engine scoring.
-- No changes to album consistency / MusicBrainz preflight logic.
-
-## Phase 0 (this PR): UI framework only
-
-Scope is tiny and safe to ship immediately.
-
-- Replace the "Force DL" toggle in the Sync page Discover tab with an "Any
- Quality" toggle.
-- Leave the new toggle permanently disabled / greyed out for now.
-- Tooltip on the toggle reads something like "Coming soon: download any
- available quality for this batch".
-- Remove the `force_download_all: true` body payload from the discover sync
- path. Discover playlists will now always run ownership analysis.
-- No backend changes in this phase.
-
-Files touched:
-- `webui/static/discover.js` - replace toggle HTML, remove `forceDownload`
- plumbing from `syncDiscoverPlaylistFromTab` / `_doSyncDiscoverPlaylist`.
-
-## Phase 1: Rename `force_download_all`
-
-Rename to `skip_ownership_check` (backend) and surface in the UI as
-"Re-download Owned" (or equivalent).
-
-- Backend: add new key `skip_ownership_check` everywhere the flag is used.
- Accept both keys on inbound API payloads for one release (back-compat).
-- Frontend: rename the Wishlist / Downloads modal toggles, keep the same
- default behavior (wishlists still skip the library check by default).
-- Update `helper.js` tooltip description to match the new name and behavior.
-
-Files touched:
-- `web_server.py` (lines ~15663, 24858, 25858, 26065, 29051, 29057, 29062,
- 29104, 29135, 33556, 33596)
-- `webui/static/downloads.js` (lines ~195, 598, 2152, 2155, 2195, 2436)
-- `webui/static/wishlist-tools.js` (line ~6347)
-- `webui/static/helper.js` (lines ~663)
-
-## Phase 2: Implement "Any Quality" per-batch override
-
-Introduce a new batch flag `any_quality` that, when set, bypasses quality
-filtering for that batch only.
-
-Backend behavior:
-- Add `any_quality` to the batch dict alongside `skip_ownership_check`.
-- For Soulseek: when `any_quality` is true, skip the call to
- `soulseek_client.filter_results_by_quality_preference()` and pass ranked
- candidates through unchanged.
-- For Deezer / Tidal / Qobuz: when `any_quality` is true, temporarily force
- the candidate selection path to treat `allow_fallback=True` AND start from
- the lowest quality tier so downloads succeed fastest.
-- The flag is per-batch only. Global quality profile / `allow_fallback`
- settings remain untouched.
-
-Frontend behavior:
-- Enable the "Any Quality" toggle added in Phase 0.
-- Remove the "Coming soon" tooltip, replace with a real description.
-- Wire the toggle into the discover sync POST body as `any_quality: true`.
-- Consider exposing the same toggle on the Wishlist / Downloads modal.
-
-Files touched:
-- `web_server.py` - batch creation + candidate selection path
-- `core/soulseek_client.py` - accept override in filter call
-- `core/deezer_download_client.py`, `core/tidal_download_client.py`,
- `core/qobuz_client.py` - accept per-request quality override
-- `webui/static/discover.js` - enable toggle, send flag
-- `webui/static/downloads.js` - add matching toggle on manual download
- modals
-
-## Risks / Open Questions
-
-- Soulseek quality profile bypass: is it safe to pass all density-filtered
- candidates through without the priority filter? Likely yes, since the
- matching engine already ranks by confidence and peer quality.
-- Streaming clients with strict API quality params (Tidal HiRes vs Lossless
- entitlement, Qobuz subscription tiers): forcing lowest tier should be safe
- for all users regardless of subscription.
-- Back-compat on the renamed flag: keep the old `force_download_all` key
- accepted for at least one release cycle to avoid breaking any third-party
- callers or stale browser sessions.
-
-## Rollout
-
-- Phase 0 ships with this PR (UI framework + discover fix).
-- Phase 1 and Phase 2 can ship as separate PRs on dev.
-- No migrations required; all flags are request-scoped and batch-scoped.
From f3357cb90df8f1da446e0b9a6c8166e3fc24b605 Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Fri, 24 Apr 2026 01:04:50 -0700
Subject: [PATCH 13/18] fix: malformed CSS, seasonal source column, profile_id
filter, discover tab stale flag
- style.css: move orphan color declaration inside .discover-sync-card-meta so downstream rules parse correctly
- seasonal_discovery.py: branch on deezer_track_id for deezer source instead of falling through to spotify_track_id
- web_server.py: add AND profile_id = ? to discovery pool count so other profiles don't inflate the result
- init.js: reset discoverSyncPlaylistsLoaded on page leave so the discover tab refetches on revisit
---
core/seasonal_discovery.py | 11 +++++++----
web_server.py | 2 +-
webui/static/init.js | 2 ++
webui/static/style.css | 1 -
4 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py
index 16ede399..c14ee8e2 100644
--- a/core/seasonal_discovery.py
+++ b/core/seasonal_discovery.py
@@ -371,10 +371,13 @@ class SeasonalDiscoveryService:
config = SEASONAL_CONFIG[season_key]
keywords = config['keywords']
- # itunes stores IDs in itunes_track_id; all other sources
- # (spotify, deezer, discogs, hydrabase, etc.) use spotify_track_id
- # as the generic ID column.
- track_id_col = 'itunes_track_id' if source == 'itunes' else 'spotify_track_id'
+ # Each source stores IDs in its own column
+ if source == 'itunes':
+ track_id_col = 'itunes_track_id'
+ elif source == 'deezer':
+ track_id_col = 'deezer_track_id'
+ else:
+ track_id_col = 'spotify_track_id'
seasonal_tracks = []
diff --git a/web_server.py b/web_server.py
index d3264873..10877548 100644
--- a/web_server.py
+++ b/web_server.py
@@ -44185,7 +44185,7 @@ def get_discover_synced_playlists():
try:
with database._get_connection() as conn:
pool_count = conn.execute(
- "SELECT COUNT(*) FROM discovery_pool WHERE source = ?", (active_source,)
+ "SELECT COUNT(*) FROM discovery_pool WHERE source = ? AND profile_id = ?", (active_source, pid)
).fetchone()[0]
except Exception:
pool_count = 0
diff --git a/webui/static/init.js b/webui/static/init.js
index d46ac6d8..ef0f05d6 100644
--- a/webui/static/init.js
+++ b/webui/static/init.js
@@ -2250,6 +2250,8 @@ async function loadPageData(pageId) {
delete discoverSyncPollers[key];
}
}
+ // Reset so discover tab refetches on next visit
+ discoverSyncPlaylistsLoaded = false;
}
switch (pageId) {
case 'dashboard':
diff --git a/webui/static/style.css b/webui/static/style.css
index fcde56c0..53f6c973 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -60036,7 +60036,6 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
.discover-sync-card-meta {
display: none;
-}
color: rgba(255, 255, 255, 0.4);
}
From 5ce2030655cea7d9013fad4c92197a1c7867c941 Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Fri, 24 Apr 2026 01:16:57 -0700
Subject: [PATCH 14/18] fix: address Copilot review comments on PR 357
- Escape all interpolated text in renderDiscoverSyncCard innerHTML to prevent XSS
- Replace inline onclick/onchange handlers with addEventListener bindings
- Remove familiar_favorites hardcoded track_count of 0, use pool_count like other personalized playlists
- Guard request.get_json() against None in manage_discover_auto_update POST handler
- Set push status to skipped when playlist_name is missing in _push_playlist_to_server
---
web_server.py | 12 +++++++-----
webui/static/discover.js | 39 ++++++++++++++++++++++++++++-----------
2 files changed, 35 insertions(+), 16 deletions(-)
diff --git a/web_server.py b/web_server.py
index 10877548..2e7b570c 100644
--- a/web_server.py
+++ b/web_server.py
@@ -32773,6 +32773,8 @@ def _push_playlist_to_server(batch_id, batch):
playlist_id = batch.get('playlist_id', '')
playlist_name = batch.get('playlist_name', '')
if not playlist_name:
+ logger.info(f"[PlaylistPush] No playlist_name for batch {batch_id} - skipping server push")
+ database.update_sync_history_push_status(batch_id, 'skipped')
return
database.update_sync_history_push_status(batch_id, 'pushing')
@@ -44211,10 +44213,7 @@ def get_discover_synced_playlists():
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:
+ if pool_count > 0:
track_count = min(50, pool_count)
else:
track_count = 0
@@ -44310,10 +44309,13 @@ def manage_discover_auto_update():
settings[key] = bool(val)
return jsonify({"success": True, "settings": settings})
- data = request.get_json()
+ data = request.get_json(silent=True) or {}
playlist_type = data.get('playlist_type')
enabled = data.get('enabled', False)
+ if not playlist_type:
+ return jsonify({"success": False, "error": "Missing playlist_type"}), 400
+
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
diff --git a/webui/static/discover.js b/webui/static/discover.js
index 78800af5..c0a9711b 100644
--- a/webui/static/discover.js
+++ b/webui/static/discover.js
@@ -9110,17 +9110,17 @@ function renderDiscoverSyncCard(playlist, container, sourceLabel) {
const trackLabel = isEmpty ? 'No tracks yet' : `${playlist.track_count} tracks`;
card.innerHTML = `
- ${playlist.icon}
+ ${_esc(playlist.icon)}
`;
+ // Bind event listeners instead of inline handlers (avoids XSS from playlist names)
+ const autoUpdateToggle = card.querySelector('.discover-auto-update-toggle');
+ if (autoUpdateToggle) {
+ autoUpdateToggle.addEventListener('change', function() {
+ toggleDiscoverAutoUpdate(playlist.type, this.checked);
+ });
+ }
+
+ const anyQualityToggle = card.querySelector('.discover-any-quality-toggle');
+ if (anyQualityToggle) {
+ anyQualityToggle.id = `discover-any-quality-${playlist.type}`;
+ }
+
+ const syncButton = card.querySelector('.discover-sync-btn');
+ if (syncButton) {
+ syncButton.id = `discover-sync-btn-${playlist.type}`;
+ syncButton.addEventListener('click', () => syncDiscoverPlaylistFromTab(playlist.type, playlist.name));
+ }
+
// Make the icon + info area clickable to view tracks
if (!isEmpty) {
const clickArea = card.querySelector('.discover-sync-card-info');
From 973f4f78ed1f19f5c6dce8941793d6f2068ce918 Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Fri, 24 Apr 2026 03:13:33 -0700
Subject: [PATCH 15/18] fix: close mb-card-icon:hover rule and merge
discover-sync-card properties
The hover rule was missing its closing brace, bleeding into the Discover
CSS section. The discover-sync-card rule also had a premature close that
left padding, display, gap, and transition orphaned outside the block.
---
webui/static/style.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/webui/static/style.css b/webui/static/style.css
index 53f6c973..d1772a5a 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -59939,6 +59939,7 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
#artist-detail-page .release-card.album-card .mb-card-icon:hover {
opacity: 1;
+}
/* ── SoulSync Discover Sync Tab ───────────────────────────────────────── */
@@ -59969,7 +59970,6 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
margin: 3px 6px;
-}
padding: 8px 14px;
display: flex;
align-items: center;
From 5609efa1e9d2f8a6e1b92f8d233c9f5b7db21f4f Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Fri, 24 Apr 2026 09:49:28 -0700
Subject: [PATCH 16/18] fix: address review round 3 - build_playlist, push
timing, log levels
- Handle build_playlist type in _doSyncDiscoverPlaylist by using
in-memory buildPlaylistTracks instead of missing API endpoint
- Remove duplicate _push_playlist_to_server call from
_on_download_completed (fires per-task); keep only the one in
_check_batch_completion_v2 (fires once per batch)
- Downgrade 4 logger.warning calls to logger.info in
_record_sync_history_completion for normal completion stats
---
web_server.py | 28 ++++++++--------------------
webui/static/discover.js | 25 ++++++++++++++-----------
2 files changed, 22 insertions(+), 31 deletions(-)
diff --git a/web_server.py b/web_server.py
index 2e7b570c..79d34926 100644
--- a/web_server.py
+++ b/web_server.py
@@ -28923,20 +28923,8 @@ def _on_download_completed(batch_id, task_id, success=True):
except Exception:
pass
- # Push playlists to media server after downloads complete
+ # Push is handled in _check_batch_completion_v2 (once per batch).
playlist_id = batch.get('playlist_id')
- _push_prefixes = (
- 'discover_', 'auto_mirror_', 'youtube_mirrored_',
- 'youtube_', 'tidal_', 'deezer_', 'spotify_public_',
- 'listenbrainz_', 'beatport_',
- )
- if playlist_id and playlist_id.startswith(_push_prefixes):
- get_database().update_sync_history_push_status(batch_id, 'pending')
- threading.Thread(
- target=_push_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_'):
@@ -32679,9 +32667,9 @@ 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}")
+ logger.info(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 = {}
@@ -32693,8 +32681,8 @@ 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)}")
+ logger.info(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 = []
@@ -32736,12 +32724,12 @@ def _record_sync_history_completion(batch_id, batch):
db = MusicDatabase()
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}")
+ logger.info(f"[SyncHistory] DB update for batch {batch_id}: updated={updated}")
# Save per-track results
if 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)}")
+ logger.info(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}")
diff --git a/webui/static/discover.js b/webui/static/discover.js
index c0a9711b..8ecbf6de 100644
--- a/webui/static/discover.js
+++ b/webui/static/discover.js
@@ -9229,18 +9229,21 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName) {
}
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 (playlistType === 'build_playlist') {
+ // Build Playlist tracks are assembled client-side; no API endpoint.
+ tracks = (typeof buildPlaylistTracks !== 'undefined' && buildPlaylistTracks) || [];
+ } else {
+ // Use unified URL helper (handles ListenBrainz + standard discover types)
+ const apiUrl = _discoverPlaylistApiUrl(playlistType);
+ if (apiUrl) {
+ const tracksResponse = await fetch(apiUrl);
+ if (tracksResponse.ok) {
+ const data = await tracksResponse.json();
+ tracks = data.tracks || [];
+ }
+ }
}
if (!tracks.length) {
From 20cad61e4f2a69a8319f2baf5a8e6a16bd6798f0 Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Mon, 27 Apr 2026 09:50:22 -0700
Subject: [PATCH 17/18] fix: clamp negative alreadyOwned, map deezer
track_id_col in seasonal endpoints
- Clamp alreadyOwned to Math.max(0, ...) in batch completion display
to avoid showing negative owned count
- Map active_source deezer to deezer_track_id in both seasonal
playlist endpoints (current-playlist and curated), consistent
with core/seasonal_discovery.py logic
---
web_server.py | 21 ++++++++++++++-------
webui/static/pages-extra.js | 2 +-
2 files changed, 15 insertions(+), 8 deletions(-)
diff --git a/web_server.py b/web_server.py
index 79d34926..0f458af4 100644
--- a/web_server.py
+++ b/web_server.py
@@ -44407,9 +44407,13 @@ def get_current_seasonal_playlist():
return jsonify({"success": True, "tracks": []})
# itunes stores IDs in itunes_track_id; all other sources
- # (spotify, deezer, discogs, hydrabase, etc.) use spotify_track_id
- # as the generic ID column.
- track_id_col = 'itunes_track_id' if active_source == 'itunes' else 'spotify_track_id'
+ # Each source stores IDs in its own column
+ if active_source == 'itunes':
+ track_id_col = 'itunes_track_id'
+ elif active_source == 'deezer':
+ track_id_col = 'deezer_track_id'
+ else:
+ track_id_col = 'spotify_track_id'
tracks = []
with database._get_connection() as conn:
cursor = conn.cursor()
@@ -44538,10 +44542,13 @@ def get_seasonal_playlist(season_key):
if not track_ids:
return jsonify({"success": True, "tracks": []})
- # itunes stores IDs in itunes_track_id; all other sources
- # (spotify, deezer, discogs, hydrabase, etc.) use spotify_track_id
- # as the generic ID column.
- track_id_col = 'itunes_track_id' if active_source == 'itunes' else 'spotify_track_id'
+ # Each source stores IDs in its own column
+ if active_source == 'itunes':
+ track_id_col = 'itunes_track_id'
+ elif active_source == 'deezer':
+ track_id_col = 'deezer_track_id'
+ else:
+ track_id_col = 'spotify_track_id'
# Fetch track details from seasonal tracks or discovery pool (filtered by source)
tracks = []
diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js
index 6aaf6694..a8722975 100644
--- a/webui/static/pages-extra.js
+++ b/webui/static/pages-extra.js
@@ -2515,7 +2515,7 @@ function _adlRenderBatchPanel() {
if (batch.active > 0) phaseIcon = ' ';
} else if (batch.phase === 'complete') {
const analysisTotal = batch.analysis_total || 0;
- const alreadyOwned = analysisTotal > 0 ? analysisTotal - total : 0;
+ const alreadyOwned = analysisTotal > 0 ? Math.max(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`);
From 6ce90ba10db5319cc5476abf75f0ad8fa91c9cfd Mon Sep 17 00:00:00 2001
From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com>
Date: Mon, 27 Apr 2026 10:11:06 -0700
Subject: [PATCH 18/18] fix: seasonal service constructor, source-aware track
IDs, deep-link timing
- Use get_seasonal_discovery_service(spotify_client, database) instead
of broken SeasonalDiscoveryService(database) in both auto-sync and
track_count paths
- Replace nonexistent get_current_season_playlist() with working
get_current_season() + get_curated_seasonal_playlist() pattern
- Use generic track_id field for personalized playlist auto-sync
instead of hardcoded spotify_track_id (fixes Deezer/iTunes)
- Replace fixed 400ms setTimeout in _applySyncTabAction with polling
loop that waits for target element (up to 4s)
---
web_server.py | 60 +++++++++++++++++++++++++----------
webui/static/sync-services.js | 15 +++++++--
2 files changed, 57 insertions(+), 18 deletions(-)
diff --git a/web_server.py b/web_server.py
index 0f458af4..08a65f9c 100644
--- a/web_server.py
+++ b/web_server.py
@@ -44103,17 +44103,41 @@ def _auto_sync_discover_playlists(profile_id, active_source):
'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']]
+ from core.seasonal_discovery import get_seasonal_discovery_service, SEASONAL_CONFIG
+ seasonal_svc = get_seasonal_discovery_service(spotify_client, database)
+ current_season = seasonal_svc.get_current_season()
+ if current_season and current_season in SEASONAL_CONFIG:
+ track_ids = seasonal_svc.get_curated_seasonal_playlist(current_season, source=active_source)
+ if track_ids:
+ if active_source == 'itunes':
+ s_id_col = 'itunes_track_id'
+ elif active_source == 'deezer':
+ s_id_col = 'deezer_track_id'
+ else:
+ s_id_col = 'spotify_track_id'
+ with database._get_connection() as conn:
+ cursor = conn.cursor()
+ for tid in track_ids:
+ cursor.execute(f"""
+ SELECT {s_id_col} as track_id, track_name, artist_name, album_name, duration_ms
+ FROM seasonal_tracks WHERE {s_id_col} = ? AND source = ?
+ """, (tid, active_source))
+ row = cursor.fetchone()
+ if not row:
+ cursor.execute(f"""
+ SELECT {s_id_col} as track_id, track_name, artist_name, album_name, duration_ms
+ FROM discovery_pool WHERE {s_id_col} = ? AND source = ?
+ """, (tid, active_source))
+ row = cursor.fetchone()
+ if row:
+ r = dict(row)
+ tracks.append({
+ 'id': r.get('track_id', ''),
+ 'name': r.get('track_name', ''),
+ 'artists': [r.get('artist_name', '')],
+ 'album': r.get('album_name', ''),
+ 'duration_ms': r.get('duration_ms', 0)
+ })
else:
from core.personalized_playlists import PersonalizedPlaylistsService
service = PersonalizedPlaylistsService(database)
@@ -44126,7 +44150,7 @@ def _auto_sync_discover_playlists(profile_id, active_source):
if ptype in method_map:
raw_tracks = method_map[ptype](limit=50)
tracks = [{
- 'id': t.get('spotify_track_id', ''),
+ 'id': t.get('track_id') or t.get('spotify_track_id') or t.get('deezer_track_id') or t.get('itunes_track_id') or '',
'name': t.get('track_name', ''),
'artists': [t.get('artist_name', '')],
'album': t.get('album_name', ''),
@@ -44192,11 +44216,15 @@ def get_discover_synced_playlists():
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
+ from core.seasonal_discovery import get_seasonal_discovery_service
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
+ seasonal_svc = get_seasonal_discovery_service(spotify_client, database)
+ current_season = seasonal_svc.get_current_season()
+ if current_season:
+ curated = seasonal_svc.get_curated_seasonal_playlist(current_season, source=active_source)
+ track_count = len(curated) if curated else 0
+ else:
+ track_count = 0
except Exception:
track_count = 0
else:
diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js
index 26a817f1..a6e8ddf9 100644
--- a/webui/static/sync-services.js
+++ b/webui/static/sync-services.js
@@ -2780,8 +2780,19 @@ function _applySyncTabAction() {
syncDiscoverPlaylistFromTab(action.autoSync, action.autoSyncName || action.autoSync);
}
};
- // Small delay to let lazy tab content render
- setTimeout(apply, 400);
+ // Wait for lazy-loaded content to appear before applying
+ let attempts = 0;
+ const maxAttempts = 20; // 20 * 200ms = 4s max
+ const waitAndApply = () => {
+ const ready = !action.highlight || document.getElementById(action.highlight);
+ if (ready || attempts >= maxAttempts) {
+ apply();
+ } else {
+ attempts++;
+ setTimeout(waitAndApply, 200);
+ }
+ };
+ setTimeout(waitAndApply, 200);
}
function initializeSyncPage() {